| 1 | --- |
| 2 | title: Connect the browser |
| 3 | description: Use managed signup and sessions to upload and download private files from Vite. |
| 4 | --- |
| 5 | |
| 6 | `web/src/main.ts` uses the official Neon auth client and ordinary browser APIs. The UI includes signed-out, empty, uploading, event-pending, rejected, expired-session, and service-error states. It renders filenames as text, not HTML. |
| 7 | |
| 8 | ## Deploy the Vite frontend |
| 9 | |
| 10 | ```diff lang="typescript" |
| 11 | +const web = yield* Neon.Website.Vite("Web", { |
| 12 | + branch, |
| 13 | + rootDir: "./web", |
| 14 | + env: { VITE_API_URL: api.url, VITE_NEON_AUTH_URL: auth.baseUrl }, |
| 15 | + assets: { notFoundHandling: "single-page-application" }, |
| 16 | +}); |
| 17 | ``` |
| 18 | |
| 19 | The Website constructor packages Vite's assets into a Neon Function. This is Function hosting, not S3 website hosting; both `VITE_` values are intentionally public browser configuration. |
| 20 | |
| 21 | ## Configure browser API access |
| 22 | |
| 23 | ```diff lang="typescript" |
| 24 | +APP_ORIGIN: appOrigin, |
| 25 | ``` |
| 26 | |
| 27 | This Function environment property uses `UPLOAD_APP_ORIGIN` from deployment configuration, defaulting to `*` for this bearer-token tutorial. API requests never use cookies and always require a verified JWT; CORS is not authorization. Set a known frontend origin before production. Do not bind the generated website URL back to the API: these Functions have no precreate handler, so that cycle fails with `UnsatisfiedResourceCycle`. |
| 28 | |
| 29 | ## Normalize the website origin |
| 30 | |
| 31 | ```diff lang="typescript" |
| 32 | +const siteUrl = typeof web.url === "string" ? Output.literal(web.url) : web.url; |
| 33 | +const origin = Output.map(siteUrl, (url: string | undefined) => new URL(url ?? "").origin); |
| 34 | ``` |
| 35 | |
| 36 | After checking that `web.url` exists, extract its origin with `alchemy/Output`. A Function URL ends with a slash, but managed Auth compares exact origins without that slash; passing the raw URL causes signup to fail with `Invalid origin`. |
| 37 | |
| 38 | ## Trust the frontend in managed Auth |
| 39 | |
| 40 | ```diff lang="typescript" |
| 41 | +yield* Neon.AuthTrustedDomain("WebOrigin", { auth, domain: origin }); |
| 42 | ``` |
| 43 | |
| 44 | The child resource owns this one trusted origin without making Auth settings compete with a separate collection owner. |
| 45 | |
| 46 | ## Initialize the browser auth client |
| 47 | |
| 48 | ```diff lang="typescript" |
| 49 | +const auth = createAuthClient(import.meta.env.VITE_NEON_AUTH_URL); |
| 50 | ``` |
| 51 | |
| 52 | Import `createAuthClient` from `@neondatabase/neon-js/auth`; the client manages the session cookies. The managed URL is public, not an admin secret. Browsers that block third-party cookies can still block this cross-domain setup; use a supported shared-domain or proxy design for those browsers rather than exposing secrets or weakening JWT verification. |
| 53 | |
| 54 | ## Create an account |
| 55 | |
| 56 | ```diff lang="typescript" |
| 57 | +const result = await auth.signUp.email({ name, email, password }); |
| 58 | ``` |
| 59 | |
| 60 | The Create account form submits to managed Better Auth and displays `result.error` when signup fails; the application never stores a password itself. |
| 61 | |
| 62 | ## Sign in |
| 63 | |
| 64 | ```diff lang="typescript" |
| 65 | +const result = await auth.signIn.email({ email, password }); |
| 66 | ``` |
| 67 | |
| 68 | The Sign in button uses the same managed service and leaves error responses visible instead of optimistically displaying a signed-in state. |
| 69 | |
| 70 | ## Restore a session |
| 71 | |
| 72 | ```diff lang="typescript" |
| 73 | +const result = await auth.getSession(); |
| 74 | ``` |
| 75 | |
| 76 | On page load and after authentication, the UI reads the actual session before showing the owner's files; a missing session clears stale rows. |
| 77 | |
| 78 | ## Obtain a short-lived JWT |
| 79 | |
| 80 | ```diff lang="typescript" |
| 81 | +const tokenResponse = await fetch(`${authUrl.replace(/\/$/, "")}/token`, { |
| 82 | + credentials: "include", |
| 83 | + signal: AbortSignal.timeout(15_000), |
| 84 | +}); |
| 85 | ``` |
| 86 | |
| 87 | Before each API call, retrieve a JWT from managed Auth using the authenticated session cookie. The pinned `@neondatabase/auth@0.5.0-beta` client incorrectly serves its cached session object for `auth.token()`, while this actual REST endpoint returns the token. Parse and validate the response, and treat a missing token as expired-session state. This does not replace JWT verification or persist tokens in localStorage. |
| 88 | |
| 89 | ## Call the protected API |
| 90 | |
| 91 | ```diff lang="typescript" |
| 92 | +const response = await fetch(`${apiUrl}/api/uploads`, { |
| 93 | + headers: { authorization: `Bearer ${data.token}` }, |
| 94 | +}); |
| 95 | ``` |
| 96 | |
| 97 | The server verifies the token independently; changing browser state cannot authorize an upload or reveal another user's journal. |
| 98 | |
| 99 | ## Request an upload URL |
| 100 | |
| 101 | ```diff lang="typescript" |
| 102 | +const signed = await api("/api/uploads", { |
| 103 | + method: "POST", |
| 104 | + body: JSON.stringify({ filename: file.name, contentType: file.type || "application/octet-stream", size: file.size }), |
| 105 | +}); |
| 106 | ``` |
| 107 | |
| 108 | The authenticated `api` helper adds the JWT, checks HTTP errors, and returns the newly allocated upload ID and signed URL. |
| 109 | |
| 110 | ## Upload directly to the bucket |
| 111 | |
| 112 | ```diff lang="typescript" |
| 113 | +const response = await fetch(signed.url, { |
| 114 | + method: "PUT", |
| 115 | + body: file, |
| 116 | + headers: { "content-type": signed.contentType }, |
| 117 | +}); |
| 118 | ``` |
| 119 | |
| 120 | File bytes go directly to private storage; this request carries the signed capability, not the session token, database URL, or deployment credentials. |
| 121 | |
| 122 | ## Refresh the processing state |
| 123 | |
| 124 | ```diff lang="typescript" |
| 125 | +const rows = await api("/api/uploads"); |
| 126 | ``` |
| 127 | |
| 128 | The UI refreshes at most nine times after upload, with eight three-second delays, and stops on `ready` or `rejected`. If delivery is still pending, it says so and offers manual refresh rather than inventing a successful event. |
| 129 | |
| 130 | ## Request a download |
| 131 | |
| 132 | ```diff lang="typescript" |
| 133 | +const { url } = await api(`/api/uploads/${id}/download`); |
| 134 | ``` |
| 135 | |
| 136 | The Download button navigates directly to the signed GET URL in a new tab after authorization. It does not need cookie-bearing cross-origin storage access. |
| 137 | |
| 138 | ## Sign out |
| 139 | |
| 140 | ```diff lang="typescript" |
| 141 | +const result = await auth.signOut(); |
| 142 | ``` |
| 143 | |
| 144 | After successful signout, clear the visible journal and invalidate in-flight UI refreshes. A failed signout stays an error; previously issued JWTs may remain valid until expiry even when the session is gone. |
| 145 | |
| 146 | ## Exercise the complete flow |
| 147 | |
| 148 | Create an account, confirm the empty journal, upload a small file, wait for the event-backed ready state, open its download, refresh the page, and sign out. Try a wrong password, a missing or invalid bearer token, an empty file, and an oversized file. Repeat at mobile width. A successful HTTP health check alone does not validate this browser flow. |
| 149 | |
| 150 | No AI is enabled by this example. If you later add model processing, make it an explicit opt-in using Neon's gateway and a configured model; never silently buy credits, switch to another provider, or make basic uploads depend on an AI entitlement. |
| 151 | |
| 152 | Continue with [previews and cleanup](/neon/tutorial/previews/). |