1---
2title: Connect the browser
3description: 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
19The 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
27This 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
36After 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
44The 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
52Import `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
60The 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
68The 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
76On 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
87Before 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
97The 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
108The 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
120File 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
128The 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
136The 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
144After 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
148Create 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
150No 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
152Continue with [previews and cleanup](/neon/tutorial/previews/).