From f05a0249513b6580a40247a964b24c6c69637833 Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 16 Oct 2026 22:18:54 +0300 Subject: [PATCH 1/2] Examples: the header shows the session state in the server render The header read the user with useQuery, which does not run on the server, so a signed-out visitor got an empty header slot and the "Sign in" link appeared only once the browser had hydrated, connected, and asked; the same on both hosts, because the code is the same. The server knows the session from its token: SignedOut and SignedIn from the convex-auth entry render it, so a rendered page carries the right header in its first bytes. The name still comes from the cache the root loader filled for a page rendered as the user. A prerendered page has no token and shows its header once the browser knows, as before. Co-Authored-By: Claude Fable 5.1 --- .../src/routes/__root.tsx | 25 ++++++++++++++++--- examples/tanstack-ssr/src/routes/__root.tsx | 25 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/examples/tanstack-ssr-workers/src/routes/__root.tsx b/examples/tanstack-ssr-workers/src/routes/__root.tsx index 6efdbfd4..536f1379 100644 --- a/examples/tanstack-ssr-workers/src/routes/__root.tsx +++ b/examples/tanstack-ssr-workers/src/routes/__root.tsx @@ -9,6 +9,8 @@ import { useQuery, type QueryClient } from "@tanstack/react-query"; import { convexQuery, type ConvexQueryClient } from "@convex-dev/react-query"; import { SessionProvider, + SignedIn, + SignedOut, sessionBeforeLoad, useSignOutSession, } from "@samebase/convex-hosting-tanstack-start/convex-auth"; @@ -69,13 +71,28 @@ function Root() { } function SessionHeader() { - // `useQuery` does not fetch on the server, so the header of a prerendered page stays empty until - // the browser has the answer. + // The server render knows the session from its token, so a rendered page carries the right + // header; a prerendered page has no token and shows its header once the browser knows. + return ( + <> + + Sign in + + + + + + ); +} + +function SignedInHeader() { + // The root loader has queried the user for a page rendered as the user, so the name is in the + // cache; `useQuery` does not fetch on the server, so a prerendered page shows it when the browser + // has it. const { data: me } = useQuery(convexQuery(api.users.me, {})); const signOut = useSignOutSession("/auth/signout"); const [signOutFailed, setSignOutFailed] = useState(false); - if (me === undefined) return null; - if (me === null) return Sign in; + if (!me) return null; return ( <> {`Signed in as ${me.name}`} diff --git a/examples/tanstack-ssr/src/routes/__root.tsx b/examples/tanstack-ssr/src/routes/__root.tsx index 6efdbfd4..536f1379 100644 --- a/examples/tanstack-ssr/src/routes/__root.tsx +++ b/examples/tanstack-ssr/src/routes/__root.tsx @@ -9,6 +9,8 @@ import { useQuery, type QueryClient } from "@tanstack/react-query"; import { convexQuery, type ConvexQueryClient } from "@convex-dev/react-query"; import { SessionProvider, + SignedIn, + SignedOut, sessionBeforeLoad, useSignOutSession, } from "@samebase/convex-hosting-tanstack-start/convex-auth"; @@ -69,13 +71,28 @@ function Root() { } function SessionHeader() { - // `useQuery` does not fetch on the server, so the header of a prerendered page stays empty until - // the browser has the answer. + // The server render knows the session from its token, so a rendered page carries the right + // header; a prerendered page has no token and shows its header once the browser knows. + return ( + <> + + Sign in + + + + + + ); +} + +function SignedInHeader() { + // The root loader has queried the user for a page rendered as the user, so the name is in the + // cache; `useQuery` does not fetch on the server, so a prerendered page shows it when the browser + // has it. const { data: me } = useQuery(convexQuery(api.users.me, {})); const signOut = useSignOutSession("/auth/signout"); const [signOutFailed, setSignOutFailed] = useState(false); - if (me === undefined) return null; - if (me === null) return Sign in; + if (!me) return null; return ( <> {`Signed in as ${me.name}`} From c40a9596d419a83f979112f88f983916703061c7 Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 16 Oct 2026 23:24:19 +0300 Subject: [PATCH 2/2] The prerender's page carries no session verdict The build's prerender runs src/server.ts without a visitor, and its page is the one every visitor gets, so a header rendered from its "no session" said "Sign in" to signed-in visitors until the browser knew better. TanStack's prerender sends the request headers of `tanstackStart({ prerender: { headers } })`, an option of its config schema; nothing else of the build reaches the server entry on both hosts: TanStack's own TSS_PRERENDERING variable is visible to the Convex example's prerender in Node and invisible to the twin's in workerd, checked by building both. So vite.config.ts marks the prerender's requests, src/server.ts gives them no token at all, and the adapter's request context allows that: SignedIn and SignedOut wait for the browser on such a render, as they already did for a render without a request context, and redirectWithoutSession now redirects it like a render without a session, so the header, which any client can send, can only empty the sender's own header. Checked on the dev deployment: / rendered with the link, /about/ the file without it, /todos 307 with and without the header, / with the header rendered without the link. The session templates of Convex, Clerk, WorkOS, and Better Auth prerender nothing, and TanStack's docs pair auth with per-request rendering only; this is the first mechanism for the combination. Co-Authored-By: Claude Fable 5.1 --- examples/tanstack-ssr-workers.patch | 8 ++++---- examples/tanstack-ssr-workers/src/server.ts | 4 +++- examples/tanstack-ssr-workers/vite.config.ts | 8 ++++++-- examples/tanstack-ssr/src/server.ts | 4 +++- examples/tanstack-ssr/vite.config.ts | 8 ++++++-- .../CHANGELOG.md | 5 +++++ .../convex-hosting-tanstack-start/README.md | 15 +++++++++++---- .../src/convex-auth.tsx | 19 +++++++++++-------- 8 files changed, 49 insertions(+), 22 deletions(-) diff --git a/examples/tanstack-ssr-workers.patch b/examples/tanstack-ssr-workers.patch index 35d99464..56be1234 100644 --- a/examples/tanstack-ssr-workers.patch +++ b/examples/tanstack-ssr-workers.patch @@ -282,7 +282,7 @@ index f712af5c5a73e65f8a9e64cd486a68078218b4ea..7bbec71850aefea2dd2b0d87cfd9ca6f } } diff --git a/src/server.ts b/src/server.ts -index 98c671d10f422e183216c3c6ac33b9d3ff07e712..200022cafe2f901c7cee6a6c92dc89f7a241e25c 100644 +index 276e876364b7ec56cc28b6a2a57229c2835d681e..73deca587d2c5dc5a35d40e073f2b6ed737cec38 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,5 @@ @@ -293,7 +293,7 @@ index 98c671d10f422e183216c3c6ac33b9d3ff07e712..200022cafe2f901c7cee6a6c92dc89f7 import { api } from "../convex/_generated/api.js"; diff --git a/vite.config.ts b/vite.config.ts -index c1fa9a2d4c1224a8cbda8132e633b7b066e137b4..3e6badb4024aed63c5e612137eb9fc19df6cbb9c 100644 +index 8e68d2fff175c09d037e869b6661cae7188aaa51..3e156c689f99f1e5ada6dd2e38c10494338c9504 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,12 @@ @@ -309,8 +309,8 @@ index c1fa9a2d4c1224a8cbda8132e633b7b066e137b4..3e6badb4024aed63c5e612137eb9fc19 - convexSsr(), + cloudflare({ viteEnvironment: { name: "ssr" } }), // Prerenders /about only. `prerender: { enabled: true }` would also prerender every static - // route, and crawling would follow /about's links. - tanstackStart({ pages: [{ path: "/about", prerender: { enabled: true, crawlLinks: false } }] }), + // route, and crawling would follow /about's links. The header marks the prerender's requests + // for src/server.ts; a prerender renders without a visitor. diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000000000000000000000000000000000000..48c90a9e2b5789638c5baf349fe0cd88dcbbb282 diff --git a/examples/tanstack-ssr-workers/src/server.ts b/examples/tanstack-ssr-workers/src/server.ts index 200022ca..73deca58 100644 --- a/examples/tanstack-ssr-workers/src/server.ts +++ b/examples/tanstack-ssr-workers/src/server.ts @@ -40,7 +40,9 @@ const server = { cookies, cookieOptions, }); - const token = await session.getToken(); + // The build's prerender renders without a visitor, so its page, which every visitor gets, must + // not say signed out: vite.config.ts marks its requests, and they get no token at all. + const token = request.headers.has("x-prerender") ? undefined : await session.getToken(); const page = await handler.fetch(request, { context: { token } }); const headers = new Headers(page.headers); cookies.applyTo(headers); diff --git a/examples/tanstack-ssr-workers/vite.config.ts b/examples/tanstack-ssr-workers/vite.config.ts index 3e6badb4..3e156c68 100644 --- a/examples/tanstack-ssr-workers/vite.config.ts +++ b/examples/tanstack-ssr-workers/vite.config.ts @@ -8,8 +8,12 @@ export default defineConfig({ plugins: [ cloudflare({ viteEnvironment: { name: "ssr" } }), // Prerenders /about only. `prerender: { enabled: true }` would also prerender every static - // route, and crawling would follow /about's links. - tanstackStart({ pages: [{ path: "/about", prerender: { enabled: true, crawlLinks: false } }] }), + // route, and crawling would follow /about's links. The header marks the prerender's requests + // for src/server.ts; a prerender renders without a visitor. + tanstackStart({ + prerender: { headers: { "x-prerender": "1" } }, + pages: [{ path: "/about", prerender: { enabled: true, crawlLinks: false } }], + }), react(), ], }); diff --git a/examples/tanstack-ssr/src/server.ts b/examples/tanstack-ssr/src/server.ts index 98c671d1..276e8763 100644 --- a/examples/tanstack-ssr/src/server.ts +++ b/examples/tanstack-ssr/src/server.ts @@ -40,7 +40,9 @@ const server = { cookies, cookieOptions, }); - const token = await session.getToken(); + // The build's prerender renders without a visitor, so its page, which every visitor gets, must + // not say signed out: vite.config.ts marks its requests, and they get no token at all. + const token = request.headers.has("x-prerender") ? undefined : await session.getToken(); const page = await handler.fetch(request, { context: { token } }); const headers = new Headers(page.headers); cookies.applyTo(headers); diff --git a/examples/tanstack-ssr/vite.config.ts b/examples/tanstack-ssr/vite.config.ts index c1fa9a2d..8e68d2ff 100644 --- a/examples/tanstack-ssr/vite.config.ts +++ b/examples/tanstack-ssr/vite.config.ts @@ -8,8 +8,12 @@ export default defineConfig({ plugins: [ convexSsr(), // Prerenders /about only. `prerender: { enabled: true }` would also prerender every static - // route, and crawling would follow /about's links. - tanstackStart({ pages: [{ path: "/about", prerender: { enabled: true, crawlLinks: false } }] }), + // route, and crawling would follow /about's links. The header marks the prerender's requests + // for src/server.ts; a prerender renders without a visitor. + tanstackStart({ + prerender: { headers: { "x-prerender": "1" } }, + pages: [{ path: "/about", prerender: { enabled: true, crawlLinks: false } }], + }), react(), ], }); diff --git a/packages/convex-hosting-tanstack-start/CHANGELOG.md b/packages/convex-hosting-tanstack-start/CHANGELOG.md index 0cd97525..3b03fa9c 100644 --- a/packages/convex-hosting-tanstack-start/CHANGELOG.md +++ b/packages/convex-hosting-tanstack-start/CHANGELOG.md @@ -8,6 +8,11 @@ override of 0.6.0 is gone with it. `convexSsr()` resolves `node:stream` to a stub for the server build: TanStack imports Node's stream classes for a branch that never runs where React's Web Stream renderer exists, and Convex's bundler had nothing to resolve them to. +- The request context's token may be `undefined`: a render that knows nothing about a session, the + build's prerender, whose page every visitor gets. `SignedIn` and `SignedOut` wait for the browser + on it, and `redirectWithoutSession` redirects it like a render without a session. The app's + `src/server.ts` passes `undefined` for the prerender's requests, which it tells apart by the + request header that `tanstackStart({ prerender: { headers } })` sends. ## 0.8.0 diff --git a/packages/convex-hosting-tanstack-start/README.md b/packages/convex-hosting-tanstack-start/README.md index 0cd64f2c..09c47caa 100644 --- a/packages/convex-hosting-tanstack-start/README.md +++ b/packages/convex-hosting-tanstack-start/README.md @@ -107,17 +107,24 @@ does in its [`src/server.ts`](https://github.com/samebase/convex-hosting/blob/main/examples/tanstack-ssr/src/server.ts): ```ts -const token = await session.getToken(); +const token = request.headers.has("x-prerender") ? undefined : await session.getToken(); const page = await handler.fetch(request, { context: { token } }); ``` +The build's prerender renders a page without a visitor, and every visitor gets that page, so its +render must not say signed out. TanStack's prerender sends the request headers of +`tanstackStart({ prerender: { headers } })`; the example sets one, and `src/server.ts` gives such a +request no token at all. `SignedIn` and `SignedOut` then wait for the browser on that page, and a +gate redirects it as it redirects a request without a session. + `convex/http.ts` then forwards every request that is not a GET, and the page render, to the build with `server.fetch(request)`, as the example's [`convex/http.ts`](https://github.com/samebase/convex-hosting/blob/main/examples/tanstack-ssr/convex/http.ts) does. `@samebase/convex-hosting-tanstack-start/convex-auth` has the rest, and loading it declares -`requestContext` as `{ token: string | null }` in the `Register` of `@tanstack/react-start`, which -makes `context` required and typed in `handler.fetch` and gives every route a typed `serverContext`. -The app creates its Convex client with `expectAuth: true`. +`requestContext` as `{ token: string | null | undefined }` in the `Register` of +`@tanstack/react-start`, which makes `context` required and typed in `handler.fetch` and gives every +route a typed `serverContext`. The token is `undefined` for a render that knows nothing about a +session, the prerender. The app creates its Convex client with `expectAuth: true`. Nothing in this entry is about hosting: it is the TanStack Start integration that Convex Auth does not ship, and the diff --git a/packages/convex-hosting-tanstack-start/src/convex-auth.tsx b/packages/convex-hosting-tanstack-start/src/convex-auth.tsx index 25f39b8b..7ce032a4 100644 --- a/packages/convex-hosting-tanstack-start/src/convex-auth.tsx +++ b/packages/convex-hosting-tanstack-start/src/convex-auth.tsx @@ -32,11 +32,13 @@ import { useConvexAuth, type ConvexReactClient } from "convex/react"; import { createContext, useContext, useEffect, useRef, type ReactNode } from "react"; /** - * The request context of a page render: the session's access token, or `null` without one. The - * app's `src/server.ts` reads it off the cookies and passes it to the server entry. + * The request context of a page render: the session's access token, `null` without one, or + * `undefined` for a render that knows nothing about a session: the build's prerender, whose page + * every visitor gets. The app's `src/server.ts` reads it off the cookies and passes it to the + * server entry. */ interface RequestContext { - token: string | null; + token: string | null | undefined; } // Start's own `Register`, where it reads `requestContext`. TypeScript 5.9 does not merge a @@ -104,8 +106,8 @@ function ReleaseWhenSignedOut({ client }: { client: ConvexReactClient }) { * * Convex Auth reports loading during the server render and the hydration, so the token of the * server render decides until the provider settles: a string means that the server rendered as the - * user, and `null` that it found no session; a render without a request context has none. Both - * renders read the same token, so the hydration matches. + * user, and `null` that it found no session; `undefined`, the build's prerender or a render without + * a request context, knows nothing. Both renders read the same token, so the hydration matches. */ function useSignedIn(): boolean | undefined { const token = useContext(SessionToken); @@ -144,14 +146,15 @@ export function useSignOutSession(url: string): () => Promise { } /** - * A gate's `beforeLoad`: a server render without a session throws `redirect(options)`. Only the - * server render has the cookie; a browser navigation has no `serverContext`. + * A gate's `beforeLoad`: a server render without a session throws `redirect(options)`, whether it + * found none or knows nothing about one. Only the server render has the cookie; a browser + * navigation has no `serverContext`. */ export function redirectWithoutSession( serverContext: RequestContext | undefined, options: ValidateRedirectOptions, ) { - if (serverContext?.token === null) { + if (serverContext !== undefined && !serverContext.token) { throw redirect< RegisteredRouter, InferTo,