TanStack Start
The SSR half of the router choice — the first screen is rendered on the server from Query caches seeded by loaders, server functions call a generated client, and forms map a draft to the API.
Verified 2026-10-03 · Against a production TanStack Start monorepo (seventeen product apps):
@tanstack/react-start1.168.48,@tanstack/react-router1.170.31,@tanstack/react-query5.101.4,@tanstack/react-form1.33.5, zod 4.4.3, Vite 8.2.2, React 19.2.8, Bun 1.3.5. Excerpts below are adapted from that codebase, with names changed. Blocks titledexamples/web/…are those files in this handbook, which its gate type-checks and tests.
The framework choice
| Situation | Choice |
|---|---|
| No SSR needed | Vite + TanStack Router. SPA, simplest build, fastest dev loop. |
| SSR needed | TanStack Start. Same router, same file conventions, adds server functions and server rendering. |
| Ever | Never Next.js. Standing decision, with one narrow exception. |
Migrating Vite + Router → Start is incremental, which is exactly why the SPA default costs nothing. Both paths are Vite underneath, so config, plugins and test setup transfer wholly between them.
Libraries and versions
| Concern | Library | Version |
|---|---|---|
| Runtime, package manager | Bun (packageManager pinned) | 1.3.5 |
| Monorepo tasks | Turborepo | 2.10.11 |
| Language | TypeScript | 7.0.2 |
| Lint and format | Biome | 2.5.9 |
| UI runtime | React, React DOM | 19.2.8 |
| Build | Vite | 8.2.2 |
| Framework | @tanstack/react-start | 1.168.48 |
| Router | @tanstack/react-router | 1.170.31 |
| Server state | @tanstack/react-query | 5.101.4 |
| Router ↔ Query SSR | @tanstack/react-router-ssr-query | 1.167.1 |
| Forms | @tanstack/react-form | 1.33.5 |
| Tables | @tanstack/react-table | 8.21.3 |
| Validation | zod | 4.4.3 |
| Styling | Tailwind CSS (@tailwindcss/vite) | 4.3.3 |
| Primitives | radix-ui | 1.6.7 |
| Variants, class merge | class-variance-authority, tailwind-merge | 0.7.1, 3.3.1 |
| Icons | @phosphor-icons/react | 2.1.10 |
| Toasts, charts | sonner, recharts | 2.0.8, 3.10.1 |
| Unit and component tests | vitest, happy-dom, @testing-library/react | 4.1.11, 20.11.6, 16.3.0 |
| Browser tests | @playwright/test | 1.62.1 |
| Auth | better-auth | 1.7.1 |
| Errors | Sentry (@sentry/react, @sentry/bun) | 10.71.0 |
| Production server | srvx | 0.12.7 |
The TanStack packages are pinned together, exactly, in the root overrides —
Start, Router, Query and their internal cores move as one set, so every workspace
package resolves the same copy of each.
"overrides": {
"vite": "8.2.2",
"@tanstack/react-query": "5.101.4",
"@tanstack/query-core": "5.101.4",
"@tanstack/react-router-ssr-query": "1.167.1",
"@tanstack/react-router": "1.170.31",
"@tanstack/react-start": "1.168.48",
"@tanstack/router-core": "1.171.26",
"@tanstack/start-client-core": "1.170.26",
"@tanstack/start-server-core": "1.169.30"
}Adapted from a production app's root manifest.
When not to: a single-package app with no overrides to coordinate pins its
versions in its own package.json and skips the override block.
Routes are thin
A route file declares its URL, seeds the queries its screen reads, and renders feature components. No business rule, no fetch of its own, no direct call to the backend.
- Routes live in the product package (
web/product/src/routes/) and the deployable app mounts them withphysical()from@tanstack/virtual-file-routes. See feature structure. - The generated route tree (
routeTree.gen.ts) is never edited and never linted, and boundary checks skip it. - Type-safe params and search are the reason to use this router at all — do not reintroduce stringly-typed navigation next to it.
import {
layout,
physical,
rootRoute,
route,
} from "@tanstack/virtual-file-routes";
export default rootRoute("__root.tsx", [
// better-auth's endpoints, outside the signed-in layout.
route("/api/auth/$", "api/auth/$.ts"),
// A pathless LAYOUT, not `route("/")`: the product mounts its own index at `/`,
// and two routes with that id fail at router construction.
layout("app/route.tsx", [
// App-owned: a route that needs a package the product may not import.
route("/members", "app/members.tsx"),
physical("", "../../../product/src/routes"),
]),
]);
Adapted from a production app. The app owns the few routes that need packages a product may not import; everything else is the product's.
When not to: a single-package app keeps ordinary file routes under src/routes/
and needs no virtual route file.
The first screen is rendered on the server
Every read the first screen needs is in the server's HTML. The route loader
seeds TanStack Query with ensureQueryData(xQueryOptions()); the component reads
the same options with useSuspenseQuery. Same options object, same key, one fetch.
This replaces "loaders, or Query only if needed". Query is used everywhere and loaders seed it. A loader returning data straight to the component skips the cache that mutations invalidate, so the screen goes stale after the first write.
One query client per request
import { makeQueryClient } from "@acme/api/query-client";
import { createRouter } from "@tanstack/react-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { DefaultErrorComponent } from "./default-error";
import { routeTree } from "./routeTree.gen";
export const getRouter = () => {
const queryClient = makeQueryClient();
const router = createRouter({
routeTree,
context: { queryClient },
scrollRestoration: true,
defaultPreloadStaleTime: 0,
defaultErrorComponent: DefaultErrorComponent,
});
setupRouterSsrQueryIntegration({ router, queryClient });
return router;
};
Adapted from a production app. getRouter runs once per server request, so the
client is created inside it. A client at module scope is shared by every
viewer the process serves. setupRouterSsrQueryIntegration dehydrates the server
cache into the page and hydrates it in the browser, so the browser does not ask
again.
defaultPreloadStaleTime: 0 hands preload freshness to Query's own staleTime
instead of the router's cache.
Keys from one factory, under one prefix
export function productQueryKey<
const Product extends string,
const Parts extends readonly unknown[],
>(product: Product, ...parts: Parts): readonly ["acme", 1, Product, ...Parts] {
return ["acme", 1, product, ...parts];
}
import { productQueryKey } from "@acme/api/query-key";
import { queryOptions } from "@tanstack/react-query";
export const projectKeys = {
all: () => productQueryKey("projects", "projects"),
list: () => [...projectKeys.all(), "list"] as const,
activity: () => [...projectKeys.all(), "activity"] as const,
};
const getProjectServer = () => import("../server/api.server");
export const projectsQueryOptions = () =>
queryOptions({
queryKey: projectKeys.list(),
queryFn: async () => {
const { listProjects } = await getProjectServer();
return listProjects();
},
staleTime: 30_000,
});
export const activityQueryOptions = () =>
queryOptions({
queryKey: projectKeys.activity(),
queryFn: async () => {
const { listActivity } = await getProjectServer();
return listActivity();
},
staleTime: 30_000,
});
Adapted from a production app. Every key starts with the same prefix and the
product's name, then the entity, so two products' "categories" never share a cache
entry, and invalidating projectKeys.all() reaches every list and detail by prefix.
Seed in the loader, read with the same options
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import {
ActivitySection,
activityQueryOptions,
ProjectForm,
ProjectList,
ProjectListSkeleton,
projectsQueryOptions,
} from "../../features/projects";
export const Route = createFileRoute("/_route/projects/")({
head: () => ({ meta: [{ title: "Projects" }] }),
loader: async ({ context: { queryClient } }) => {
// Streamed: started here, NOT awaited. Its section suspends until the read lands.
void queryClient.prefetchQuery(activityQueryOptions());
// The frame: awaited (with Promise.all when there are several), so it is in the first HTML.
await queryClient.ensureQueryData(projectsQueryOptions());
},
// On a client-side navigation: nothing for 300ms (under that it reads as instant), then the
// skeleton for at least 300ms (so a loader landing at 320ms cannot strobe).
pendingMs: 300,
pendingMinMs: 300,
pendingComponent: ProjectsPending,
component: ProjectsPage,
});
function ProjectsPage() {
const { data: projects } = useSuspenseQuery(projectsQueryOptions());
return (
<>
<h1 className="font-semibold text-2xl">Projects</h1>
<ProjectList projects={projects} />
<ActivitySection />
<ProjectForm />
</>
);
}
function ProjectsPending() {
return (
<>
<h1 className="font-semibold text-2xl">Projects</h1>
<ProjectListSkeleton />
</>
);
}
export const Route = createFileRoute("/_route/projects/$projectId")({
loader: async ({ context, params }) => {
const [project] = await Promise.all([
context.queryClient.ensureQueryData(projectQueryOptions(params.projectId)),
// The header's Publish button reads it, so it is loaded with the project
// rather than fetched on hydration.
context.queryClient.ensureQueryData(projectReadinessQueryOptions(params.projectId)),
context.queryClient.ensureQueryData(labelsQueryOptions()),
]);
return { crumb: project?.name ?? "Project" };
},
component: ProjectLayout,
});Adapted from a production app. Everything the first screen shows is seeded — lists, the selected detail, visible tabs, and the data permission-dependent controls read — in parallel.
A failed read throws
A read the server cannot complete fails the loader. It never becomes an empty
list: an empty list says "you have no projects", which is a false statement about
the viewer's data. ensureQueryData rethrows, and the route's error component
shows the failure. A streamed section's failure stays inside that section — see
streaming and skeletons.
Keys are scoped to the viewer
Prefix matching uses the product key; the hash that identifies a cache entry
also carries who is looking. makeQueryClient passes viewerQueryHash as the
query client's queryKeyHashFn and installs watchViewer before anything is
seeded or hydrated.
/** Who is looking: the person, their sign-in and their active organization. */
export function viewerOf(client: QueryClient): string {
const current = client.getQueryData<SessionIdentity>(sessionQueryKey);
return hashKey([
current?.user?.id ?? null,
current?.session?.id ?? null,
current?.session?.activeOrganizationId ?? null,
]);
}
/** A cached result's identity carries the viewer; prefix matching still uses the key alone. */
export function viewerQueryHash(client: QueryClient, key: QueryKey): string {
return isSession(key) ? hashKey(key) : hashKey([viewerOf(client), key]);
}
/**
* On a change of person, before anything renders the new session: cancel and remove every
* private query, so an answer still in flight lands nowhere, and clear the mutation cache, so
* a write that finishes late cannot invalidate the next person's data.
*/
export function forgetViewer(client: QueryClient): void {
const privateQueries = {
predicate: (query: { queryKey: QueryKey }) => !isSession(query.queryKey),
};
void client.cancelQueries(privateQueries);
client.removeQueries(privateQueries);
client.getMutationCache().clear();
}
/** Installed by `makeQueryClient`, before any seeding or hydration. */
export function watchViewer(client: QueryClient): () => void {
let viewer = viewerOf(client);
return client.getQueryCache().subscribe(({ query }) => {
if (!isSession(query.queryKey)) return;
const next = viewerOf(client);
if (next === viewer) return;
viewer = next;
forgetViewer(client);
});
}Simplified from a production app, which also scopes by permission and by an administrator acting as someone else. What a change of person sets off is under sign-in with better-auth.
Mutations declare what they make stale
A mutation names the keys it invalidates in meta.invalidates; one global
MutationCache acts on it — invalidation, the success toast and the error toast
live in one place instead of in every feature hook.
declare module "@tanstack/react-query" {
interface Register {
mutationMeta: {
success?: ToastContent;
error?: ToastContent;
silentSuccess?: boolean;
/** Matches like `invalidateQueries`: a key invalidates its whole subtree. */
invalidates?: readonly QueryKey[];
};
}
}
/** One client per server request: `getRouter` calls this, never module scope. */
export function makeQueryClient(): QueryClient {
const queryClient: QueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
// Every result but the session's is cached under the viewer who asked for it.
queryKeyHashFn: (key) => viewerQueryHash(queryClient, key),
},
},
mutationCache: new MutationCache({
onSuccess: async (_data, _variables, _result, mutation, { client }) => {
// Cleared by a change of person while in flight: it belongs to the previous viewer.
const cleared = !client
.getMutationCache()
.getAll()
.some((current) => current.mutationId === mutation.mutationId);
if (cleared) return;
if (!mutation.meta?.silentSuccess) {
notify.success(
mutation.meta?.success ?? {
title: "Done",
description: "Your changes were saved.",
},
);
}
const invalidates = mutation.meta?.invalidates ?? [];
// Returned so the mutation stays pending until the refetches settle —
// callers awaiting `mutateAsync` see fresh data, not a stale flash.
return Promise.all(
invalidates.map((queryKey) => client.invalidateQueries({ queryKey })),
);
},
onError: (error, _variables, _result, mutation) => {
// "no active organisation: select or create one first" reads very
// differently from a generic retry.
const cause = error instanceof Error ? error.message : "";
const declared = mutation.meta?.error;
notify.error(
declared
? {
title: declared.title,
description:
declared.description || cause || "Please try again.",
}
: {
title: "Something went wrong",
description: cause || "Please try again.",
},
);
},
}),
});
watchViewer(queryClient);
return queryClient;
}import { useMutation } from "@tanstack/react-query";
import type { CreateProjectRequest } from "../../../lib/sdk/projects/schemas";
import { projectKeys } from "../core/queries";
const getProjectServer = () => import("../server/api.server");
export function useProjectsController() {
const create = useMutation({
mutationFn: async (input: CreateProjectRequest) => {
const { createProject } = await getProjectServer();
return createProject({ data: input });
},
meta: {
success: { title: "Project created", description: "" },
error: { title: "Failed to create project", description: "" },
invalidates: [projectKeys.all()],
},
});
return { create };
}
Adapted from a production app. Hooks keep onSuccess for genuinely local
effects — closing a dialog, navigating. Cache bookkeeping is never there.
Banned, and checked
| Pattern | Why it is banned |
|---|---|
ssr: false or ssr: "data-only" on a route | The screen arrives empty and fills in after JavaScript. |
await queryClient.prefetchQuery(...) in a loader | It waits like a read but swallows the failure, so the page renders empty instead of failing. An un-awaited prefetch read by useSuspenseQuery under a <Suspense> boundary is the streaming pattern, not this. |
if (typeof window !== "undefined") return | A window guard turns a server read into a client-only fetch. |
test("initial route reads cannot opt out of SSR or silently fall back after a failed prefetch", () => {
for (const file of [...routeFiles, ...loaderFiles]) {
const source = readFileSync(root + file, "utf8");
expect(source, file).not.toMatch(/ssr:\s*(?:false|["']data-only["'])/);
expect(source, file).not.toMatch(/await\s+[\w.]*\.prefetchQuery\(/);
expect(source, file).not.toMatch(/if\s*\(typeof window !== ["']undefined["']\) return/);
}
});Adapted from a production repository's tooling tests, with the prefetch rule narrowed to the awaited form. A companion test in the same file fails when a route file is missing from the SSR audit list, so a new route cannot arrive unreviewed.
The proof that a screen really renders on the server is a test beside its route — see testing a server-rendered route.
When not to: data the server genuinely cannot read — the browser's own storage, a device API — is the one client-only read, and it renders a deliberate placeholder rather than an empty state.
Streaming and skeletons
The loader awaits only what the page's frame and its gates need; every other
section streams. The frame — the heading, the main list, whatever a gate decides
on — is read with ensureQueryData, in parallel with Promise.all when there are
several. Every other section's read is started in the loader and not awaited
(void queryClient.prefetchQuery(…)), and the section reads it with
useSuspenseQuery and the same options, inside its own <Suspense> boundary.
- The fallback is a skeleton shaped like the section's final layout — the same
rows at the same heights, marked
aria-busy— so nothing moves when the data lands. - The server sends the frame and the skeleton first, then the section's HTML and its query data in the same response. The browser hydrates from what the server sent; it does not ask again.
- A failed section shows its own error inside its boundary. Never an empty list, never the whole page down: the frame stays.
- A client-side navigation keeps the previous page for 300 ms (
pendingMs): under that it reads as instant and a skeleton would only flash. Once shown, the route'spendingComponentstays at least 300 ms (pendingMinMs), so a loader landing at 320 ms cannot strobe. The router'sdefaultErrorComponentcatches a frame that fails.
The projects route above does both: the projects list is the frame, the recent activity streams. Its section:
/**
* A streamed section: the loader started its read without waiting, the server sends the
* skeleton first and the list when the read lands. A failed read shows its own error here;
* the rest of the page stays.
*/
export function ActivitySection() {
return (
<section aria-labelledby="activity-heading" className="grid gap-3">
<h2 id="activity-heading" className="font-semibold">
Recent activity
</h2>
<CatchBoundary
getResetKey={() => "activity"}
errorComponent={ActivityError}
>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityList />
</Suspense>
</CatchBoundary>
</section>
);
}
function ActivityList() {
const { data: activity } = useSuspenseQuery(activityQueryOptions());
return (
<ul className="divide-y">
{activity.map((entry) => (
<li key={entry.id} className="h-10 py-2">
{entry.text}
</li>
))}
</ul>
);
}
/** The list's own shape: same rows, same height, so nothing moves when the data lands. */
function ActivitySkeleton() {
return (
<ul
aria-busy="true"
aria-label="Loading recent activity"
className="divide-y"
>
{["one", "two", "three"].map((row) => (
<li key={row} className="h-10 py-2">
<Skeleton className="h-5 w-2/3" />
</li>
))}
</ul>
);
}
function ActivityError() {
return (
<p role="alert" className="text-destructive text-sm">
Recent activity could not be loaded.
</p>
);
}What actually happens, checked against the installed
@tanstack/react-router-ssr-query 1.167.1 and @tanstack/query-core 5.101.4 and
the deferred data loading
and Query integration
guides:
- After the loaders, the integration dehydrates every query, a pending one with
its promise, and streams any query started during the render. In the browser,
hydratehands that promise to the query (initialPromise): the query function does not run. - A streamed read that fails reaches the browser as an error whose message is
replaced with
"redacted"(Query's default), so the section's error says what failed, not why. - React cannot run error boundaries on the server. A failed section keeps its skeleton in the server's HTML and React hands that boundary to the browser, which renders the section's error.
- In the browser, Query's retry asks again before the error shows — three times, TanStack's default, by calling the query function. Keep the default: a passing hiccup heals itself. Only tests turn retry off, to see the error at once.
The proof is a test beside the route — see testing a streamed section.
When not to: a page whose reads are all fast and all part of the frame awaits them all; a streamed section is for a read that is slow or optional, never for the data a gate decides on.
Server functions
A server function lives in the feature's server/ folder, in a *.server.ts
file, and is reached by dynamic import. The client never imports it statically,
so the bundler never has a reason to pull server code into the browser bundle.
import { requireSession } from "@acme/auth/session.server";
import { createServerFn } from "@tanstack/react-start";
import { sdk } from "../../../lib/api/projects.server";
import { createProjectRequestSchema } from "../../../lib/sdk/projects/schemas";
// Each handler checks the session itself: the route's gate is not the authority.
export const listProjects = createServerFn({ method: "GET" }).handler(
async () => {
await requireSession();
return sdk().projects.list();
},
);
export const listActivity = createServerFn({ method: "GET" }).handler(
async () => {
await requireSession();
return sdk().activity.list();
},
);
export const createProject = createServerFn({ method: "POST" })
.inputValidator(createProjectRequestSchema)
.handler(async ({ data }) => {
await requireSession();
return sdk().projects.create(data);
});
Adapted from a production app. The input validator is the generated request schema, so the server checks the payload against the service's contract even when the form already checked it.
- Callers reach it with
() => import("../server/api.server")inside aqueryFnormutationFn— see the queries and hooks above. - The
*.server.tssuffix makes the boundary visible in the filename, before any tooling runs, and grep is enough to audit it. - Secrets live in server-only modules that no client-reachable code imports. Ship the client only an explicit app-config object served by the root route — allowlist, not filter. See secrets.
When not to: pure computation the browser can do — formatting, filtering an
already-loaded list — stays a plain function in core/.
Calling the backend
Each service publishes an OpenAPI document; a typed client and zod schemas are generated from it. Web code never hand-writes a request type, and a contract change surfaces at the call site as a type error.
// Auto-generated Zod schemas - do not edit manually
import { z } from "zod";
export const taskStatusSchema = z.enum(["todo", "in_progress", "done"]);
export type TaskStatus = z.infer<typeof taskStatusSchema>;
export const createTaskRequestSchema = z.object({
title: z.string(),
assigneeId: z.string().optional(),
dueDate: z.string().optional(),
});Adapted from a production generator's output. The generated client lives once, beside the service's contract; the web package reaches it through a link, never a second copy.
The product's SDK is a server-only module that resolves the signed-in viewer from the incoming request:
import { createServiceSdk } from "@acme/api/service-sdk";
import { getRequest } from "@tanstack/react-start/server";
import { ApiRequestError, Client } from "../sdk/projects";
/** The Projects service as the signed-in viewer. */
export const sdk = createServiceSdk({
product: "Projects",
serviceUrl: () => (process.env.SERVICE_URL ?? "http://localhost:8090").replace(/\/+$/, ""),
request: getRequest,
client: (baseUrl, options) => new Client(baseUrl, options),
});
export { ApiRequestError };createServiceSdk returns a proxy that reads exactly like the generated client
(sdk.tasks(id).tasks.list()), records the call, and on the first await resolves
the viewer's token and sends it:
async function execute(steps: Step[]): Promise<unknown> {
const request = options.request();
// One key for the logical call: a retry is the same write, never a second one.
const idempotencyKey = crypto.randomUUID();
const token = await serviceToken(request);
try {
return await run(token, idempotencyKey, steps);
} catch (error) {
if (!isRefusedToken(error)) throw error;
await forgetServiceToken(request, token);
return await run(await serviceToken(request), idempotencyKey, steps);
}
}Adapted from a production app. Authenticated reads stay bound to the incoming request; nothing is cached across viewers.
There is one ApiRequestError class, in the shared runtime every generated
client re-exports, so instanceof holds across products:
export class ApiRequestError extends Error {
code: string;
details?: Record<string, unknown>;
/** The HTTP status the service answered with, when the error came from a response. */
status?: number;
constructor(
message: string,
code: string,
details?: Record<string, unknown>,
status?: number,
) {
super(message);
this.name = "ApiRequestError";
this.code = code;
this.details = details;
this.status = status;
}
}
The person sees the server's message. The global error toast uses the mutation's title and the service's own words as its description:
onError: (error, _variables, _result, mutation) => {
// "no active organisation: select or create one first" reads very
// differently from a generic retry.
const cause = error instanceof Error ? error.message : "";
const declared = mutation.meta?.error;
notify.error(
declared
? {
title: declared.title,
description:
declared.description || cause || "Please try again.",
}
: {
title: "Something went wrong",
description: cause || "Please try again.",
},
);
},When not to: a third-party API with no OpenAPI document gets one hand-written
server module in the feature's server/, with a zod schema at its edge.
Sign-in with better-auth
Everything about who is signed in goes through better-auth: its server reads and writes sessions, its browser client signs in and out. No hand-made token, cookie or session code beside it.
Two shapes, one rule:
| Shape | Who runs better-auth | How another server reads the session |
|---|---|---|
| One app | The app itself, in a server-only module, mounted by its catch-all /api/auth/$ route | auth.api.getSession({ headers }) in the same process |
| Several apps | One sign-in app; the others never run it | The incoming request's cookie is forwarded to the sign-in app, server to server, with a short timeout |
In the second shape, only a 401 from the sign-in app means "signed out"; any
other failure fails the page, because guessing "signed out" sends a signed-in
person to the sign-in page. The browser's better-auth client points at the same
origin, which forwards /api/auth/* to the sign-in app. The example is the first
shape.
The server and its route
import { betterAuth } from "better-auth";
import { memoryAdapter } from "better-auth/adapters/memory";
import { organization } from "better-auth/plugins";
import { tanstackStartCookies } from "better-auth/tanstack-start";
/**
* The one better-auth server. Server-only: the catch-all API route imports it lazily, and
* server functions read the session through it. Nothing else talks to identity.
*/
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
secret: process.env.BETTER_AUTH_SECRET,
// A real app passes its database adapter (drizzle, prisma, …); memory keeps the example small.
database: memoryAdapter({}),
session: {
// The resolved session rides a short-lived signed cookie, so a request reads identity from
// the cookie instead of the database; better-auth refreshes it when the session changes.
cookieCache: { enabled: true, maxAge: 5 * 60 },
},
plugins: [
organization(),
// Must be last: it writes better-auth's cookies into TanStack Start's response.
tanstackStartCookies(),
],
});
export type Session = typeof auth.$Infer.Session;
import { createFileRoute } from "@tanstack/react-router";
// `auth` is imported inside the handlers: a top-level import would put better-auth's server
// and its database driver in the client bundle, because this file is in the route tree.
const handle = async ({ request }: { request: Request }) =>
(await import("@acme/auth/auth.server")).auth.handler(request);
export const Route = createFileRoute("/api/auth/$")({
server: { handlers: { GET: handle, POST: handle } },
});
tanstackStartCookies() comes last in the plugin list: it writes better-auth's
cookies into Start's response. A cookieCache keeps most requests off the session
table.
The session is a query, seeded on the server
/** In the browser, through better-auth's client. Only a 401 means "signed out". */
async function fetchSession(): Promise<Session | null> {
const { authClient } = await import("./auth-client");
const { data, error } = await authClient.getSession();
if (error?.status === 401) return null;
if (error) throw new Error(error.message ?? "The session could not be read.");
return data as Session | null;
}
/**
* The session is a query: the root route seeds it on the server, so the first HTML already
* knows who is signed in and the browser starts with it (no signed-out flash).
*/
export const sessionQueryOptions = () =>
queryOptions({
queryKey: sessionQueryKey,
queryFn: async () =>
typeof window === "undefined"
? (await import("./session.server")).getServerSession()
: fetchSession(),
staleTime: 5 * 60_000,
});import { sessionQueryOptions } from "@acme/auth/session";
import { ViewerBoundary } from "@acme/auth/viewer-boundary";
import type { QueryClient } from "@tanstack/react-query";
import {
createRootRouteWithContext,
HeadContent,
Scripts,
} from "@tanstack/react-router";
import type { ReactNode } from "react";
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
}>()({
// The session is read on the server, through better-auth with the request's cookie, and
// seeded into the cache: the first HTML knows who is signed in and the browser reuses it.
beforeLoad: async ({ context }) => {
await context.queryClient.ensureQueryData(sessionQueryOptions());
},
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<ViewerBoundary>{children}</ViewerBoundary>
<Scripts />
</body>
</html>
);
}
- The root's
beforeLoadreads the session on the server, through better-auth with the request's cookie, and seeds it. The first HTML knows who is signed in; the browser starts with it and does not ask again for five minutes — no signed-out flash. - The session's key is the one key not scoped to the viewer — it is what says who the viewer is. Every other key is, see keys are scoped to the viewer.
Gates redirect on the server; server functions check for themselves
import { sessionQueryOptions } from "@acme/auth/session";
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/_route")({
// The gate runs on the server for the first request: a signed-out person is redirected
// before any page renders. `ensureQueryData` asks better-auth when the cache has no answer,
// so a gate never mistakes an empty cache for "signed out".
beforeLoad: async ({ context }) => {
const session = await context.queryClient.ensureQueryData(
sessionQueryOptions(),
);
if (!session) throw redirect({ href: "/sign-in" });
},
component: AppLayout,
});
function AppLayout() {
return (
<main className="mx-auto grid max-w-3xl gap-6 p-6">
<Outlet />
</main>
);
}
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { auth } from "./auth.server";
/** better-auth reads the incoming request's cookie: null when nobody is signed in. */
export const getServerSession = createServerFn({ method: "GET" }).handler(() =>
auth.api.getSession({ headers: getRequest().headers }),
);
/**
* Every server function calls this itself, first. A gate on the route only decides what the
* page shows; it is not the authority, because a server function can be called without it.
*/
export async function requireSession() {
const session = await auth.api.getSession({ headers: getRequest().headers });
if (!session) throw redirect({ href: "/sign-in" });
return session;
}
- A gate uses
ensureQueryData, never the cache directly. A document navigation runsbeforeLoadon the server against a fresh cache; a gate that only reads the cache finds nobody and turns everyone away. - Every server function calls
requireSession()first — see the projects' server functions above. The gate decides what the page shows; it is not the authority, because a server function can be called without the page.
A change of person clears what the previous one saw
On any change of person — sign-in, sign-out, an organization switch, or
better-auth's $sessionSignal from another tab — every private query is cancelled
and removed, an answer still in flight lands nowhere, and the mutation cache is
cleared, before anything renders the new session (forgetViewer, above).
/**
* better-auth signals every change it makes — sign-in, sign-out, an organization switch. The
* session query forgets the previous person first (which clears their private queries), then
* reads the new one.
*/
export async function followSessionSignal(client: QueryClient) {
const { authClient } = await import("./auth-client");
return authClient.$store.atoms.$sessionSignal.listen(() => {
void client.cancelQueries({ queryKey: sessionQueryKey, exact: true });
client.setQueryData(sessionQueryKey, null);
void client
.fetchQuery({ ...sessionQueryOptions(), staleTime: 0 })
.catch(() => undefined); // still signed out in the cache: private data stays cleared
});
}import { sessionQueryKey, viewerOf } from "@acme/api/viewer";
import { useQueryClient } from "@tanstack/react-query";
import {
Fragment,
type ReactNode,
useEffect,
useSyncExternalStore,
} from "react";
import { followSessionSignal } from "./session";
/**
* Remounts the page when the person changes, so no mounted component keeps showing what it
* read for the previous one, and follows better-auth's session signal in the browser.
*/
export function ViewerBoundary({ children }: { children: ReactNode }) {
const client = useQueryClient();
const viewer = useSyncExternalStore(
(onChange) =>
client.getQueryCache().subscribe(({ query }) => {
if (query.queryKey[0] === sessionQueryKey[0]) onChange();
}),
() => viewerOf(client),
() => viewerOf(client),
);
useEffect(() => {
const stop = followSessionSignal(client);
return () => void stop.then((unsubscribe) => unsubscribe());
}, [client]);
return <Fragment key={viewer}>{children}</Fragment>;
}
Simplified from a production app, which also tracks an epoch per change and refuses a session read that a later signal has replaced.
The proofs, with better-auth faked and no database:
examples/web/web/app/src/routes/-signed-in-ssr.test.tsx (the first HTML holds the
signed-in person's data and hydrates without a second read; a signed-out request is
redirected; an unreadable session fails the page),
examples/web/shared/web/api/src/viewer.test.ts (a change of person removes the
previous person's data and drops a late answer) and
examples/web/shared/web/auth/src/session.test.ts (only a 401 is "signed out"; the
session signal clears first).
When not to: a site with no signed-in people has no session query and no gate; it still keeps one query client per request.
Forms
TanStack Form, with zod 4 schemas, and the form described as data. A form definition lists sections and fields; one renderer draws every form the same way.
import { z } from "zod";
import type { ProjectDraft } from "./draft";
type ProjectField = {
path: "identity.name" | "trackerRef.system" | "trackerRef.issueId";
label: string;
required?: boolean;
};
const trackerRefSchema = z
.object({ system: z.string().trim(), issueId: z.string().trim() })
.refine((ref) => (ref.system === "") === (ref.issueId === ""), {
message:
"A tracker link needs both the system and the issue id, or neither.",
path: ["issueId"],
});
/** The form as data: the renderer draws its sections; the schema checks the draft. */
export const projectDefinition: {
id: string;
schema: z.ZodType<ProjectDraft, ProjectDraft>;
sections: { title: string; fields: ProjectField[] }[];
} = {
id: "project",
schema: z.object({
identity: z.object({
name: z.string().trim().min(1, "A project needs a name."),
}),
trackerRef: trackerRefSchema,
}),
sections: [
{
title: "Project",
fields: [{ path: "identity.name", label: "Name", required: true }],
},
{
title: "Tracker link",
fields: [
{ path: "trackerRef.system", label: "System" },
{ path: "trackerRef.issueId", label: "Issue id" },
],
},
],
};
The form edits a draft, and one function maps the draft to the API request. The draft has the shape a person fills in; the request has the shape the service takes. They differ, and the mapper is where that difference is written down once.
import type { CreateProjectRequest } from "../../../lib/sdk/projects/schemas";
export interface ProjectDraft {
identity: { name: string };
trackerRef: { system: string; issueId: string };
}
export function emptyProjectDraft(): ProjectDraft {
return { identity: { name: "" }, trackerRef: { system: "", issueId: "" } };
}
/** Half a pair points nowhere, so it travels only when both halves are filled in. */
export function createProjectInput(draft: ProjectDraft): CreateProjectRequest {
const system = draft.trackerRef.system.trim();
const issueId = draft.trackerRef.issueId.trim();
const linked = system !== "" && issueId !== "";
return {
name: draft.identity.name.trim(),
...(linked ? { trackerSystem: system, trackerIssueId: issueId } : {}),
};
}
import { Button } from "@acme/ui/button";
import { Input } from "@acme/ui/input";
import { useForm } from "@tanstack/react-form";
import { projectDefinition } from "../form/definition";
import { createProjectInput, emptyProjectDraft } from "../form/draft";
import { useProjectsController } from "../hooks/use-projects-controller";
export function ProjectForm() {
const { create } = useProjectsController();
const form = useForm({
defaultValues: emptyProjectDraft(),
validators: { onSubmit: projectDefinition.schema },
onSubmit: async ({ value, formApi }) => {
await create.mutateAsync(createProjectInput(value));
formApi.reset();
},
});
return (
<form
className="grid gap-6"
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
{projectDefinition.sections.map((section) => (
<fieldset key={section.title} className="grid gap-3">
<legend className="font-semibold">{section.title}</legend>
{section.fields.map((field) => (
<form.Field key={field.path} name={field.path}>
{(input) => (
<Input
label={field.label}
required={field.required}
value={input.state.value}
onBlur={input.handleBlur}
onChange={(event) => input.handleChange(event.target.value)}
error={input.state.meta.errors
.map((issue) => issue?.message)
.join(" ")}
/>
)}
</form.Field>
))}
</fieldset>
))}
<form.Subscribe selector={(state) => state.isSubmitting}>
{(isSubmitting) => (
<Button type="submit" disabled={isSubmitting}>
Create project
</Button>
)}
</form.Subscribe>
</form>
);
}
Adapted from a production app. The server function then validates again with
the generated request schema (.inputValidator(createProjectRequestSchema)).
This replaces "one schema shared by the form and the server". The form's schema validates the draft, the generated schema validates the request, and the mapper between them is a pure function with its own unit test. Sharing one hand-written schema forced the form into the API's shape, or the API's into the form's.
A small form that does not need sections uses the same package's useForm, which
binds the shared fields to TanStack Form:
const form = useForm({
defaultValues: { email: "", name: "" },
onSubmit: async (values) => { await saveData(values) },
})
<form.Form>
<form.Input name="email" label="Email" />
<Button type="submit" disabled={form.isSubmitting}>Save</Button>
</form.Form>When not to: a single search box or filter is component state, not a form definition.
Traps
ssr: falseis not a plan. Decide SSR up front; the banned-pattern test above keeps it decided.- A query client at module scope → one viewer's results served to the next.
Create it inside
getRouter. - A loader that returns the data instead of seeding the cache → the first write leaves the screen stale, because nothing invalidates loader data.
- A loader key that differs from the component's → the server fetches, the browser fetches again. Use the same options factory on both sides; a test compares the hashes.
- A static import of a
*.server.tsfile from client-reachable code → server code and its dependencies in the browser bundle. Import it dynamically. - Package manager and runtime drift — pin
packageManagerand the TanStack set together. Start's build is sensitive to both.