Architect
Standards
ReferenceNormative

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-start 1.168.48, @tanstack/react-router 1.170.31, @tanstack/react-query 5.101.4, @tanstack/react-form 1.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 titled examples/web/… are those files in this handbook, which its gate type-checks and tests.

The framework choice

SituationChoice
No SSR neededVite + TanStack Router. SPA, simplest build, fastest dev loop.
SSR neededTanStack Start. Same router, same file conventions, adds server functions and server rendering.
EverNever 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

ConcernLibraryVersion
Runtime, package managerBun (packageManager pinned)1.3.5
Monorepo tasksTurborepo2.10.11
LanguageTypeScript7.0.2
Lint and formatBiome2.5.9
UI runtimeReact, React DOM19.2.8
BuildVite8.2.2
Framework@tanstack/react-start1.168.48
Router@tanstack/react-router1.170.31
Server state@tanstack/react-query5.101.4
Router ↔ Query SSR@tanstack/react-router-ssr-query1.167.1
Forms@tanstack/react-form1.33.5
Tables@tanstack/react-table8.21.3
Validationzod4.4.3
StylingTailwind CSS (@tailwindcss/vite)4.3.3
Primitivesradix-ui1.6.7
Variants, class mergeclass-variance-authority, tailwind-merge0.7.1, 3.3.1
Icons@phosphor-icons/react2.1.10
Toasts, chartssonner, recharts2.0.8, 3.10.1
Unit and component testsvitest, happy-dom, @testing-library/react4.1.11, 20.11.6, 16.3.0
Browser tests@playwright/test1.62.1
Authbetter-auth1.7.1
ErrorsSentry (@sentry/react, @sentry/bun)10.71.0
Production serversrvx0.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.

package.json (root, excerpt)
"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 with physical() 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.
examples/web/web/app/routes.ts
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

examples/web/web/app/src/router.tsx
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

examples/web/shared/web/api/src/query-key.ts
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];
}
examples/web/web/product/src/features/projects/core/queries.ts
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

examples/web/web/product/src/routes/projects/index.tsx
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 />
    </>
  );
}
web/product/src/routes/projects/$projectId/route.tsx
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.

examples/web/shared/web/api/src/viewer.ts (excerpt)
/** 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.

examples/web/shared/web/api/src/query-client.ts (excerpt)
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;
}
examples/web/web/product/src/features/projects/hooks/use-projects-controller.ts
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

PatternWhy it is banned
ssr: false or ssr: "data-only" on a routeThe screen arrives empty and fills in after JavaScript.
await queryClient.prefetchQuery(...) in a loaderIt 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") returnA window guard turns a server read into a client-only fetch.
tooling/ssr-contract.test.ts
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's pendingComponent stays at least 300 ms (pendingMinMs), so a loader landing at 320 ms cannot strobe. The router's defaultErrorComponent catches a frame that fails.

The projects route above does both: the projects list is the frame, the recent activity streams. Its section:

examples/web/web/product/src/features/projects/components/activity-section.tsx (excerpt)
/**
 * 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, hydrate hands 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.

examples/web/web/product/src/features/projects/server/api.server.ts
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 a queryFn or mutationFn — see the queries and hooks above.
  • The *.server.ts suffix 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.

contract/typescript-sdk/features/tasks/schemas.ts (generated)
// 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:

web/product/src/lib/api/projects.server.ts
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:

shared/web/api/src/service-sdk.ts (excerpt)
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:

examples/web/shared/web/api/src/sdk-runtime.ts
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:

examples/web/shared/web/api/src/query-client.ts (excerpt)
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:

ShapeWho runs better-authHow another server reads the session
One appThe app itself, in a server-only module, mounted by its catch-all /api/auth/$ routeauth.api.getSession({ headers }) in the same process
Several appsOne sign-in app; the others never run itThe 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

examples/web/shared/web/auth/src/auth.server.ts
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;
examples/web/web/app/src/routes/api/auth/$.ts
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

examples/web/shared/web/auth/src/session.ts (excerpt)
/** 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,
  });
examples/web/web/app/src/routes/__root.tsx
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 beforeLoad reads 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

examples/web/web/app/src/routes/app/route.tsx
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>
  );
}
examples/web/shared/web/auth/src/session.server.ts
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 runs beforeLoad on 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).

examples/web/shared/web/auth/src/session.ts (excerpt)
/**
 * 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
  });
}
examples/web/shared/web/auth/src/viewer-boundary.tsx
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.

examples/web/web/product/src/features/projects/form/definition.ts
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.

examples/web/web/product/src/features/projects/form/draft.ts
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 } : {}),
  };
}
examples/web/web/product/src/features/projects/components/project-form.tsx
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: false is 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.ts file from client-reachable code → server code and its dependencies in the browser bundle. Import it dynamically.
  • Package manager and runtime drift — pin packageManager and the TanStack set together. Start's build is sensitive to both.

On this page