TanStack Start
Keep the session in an encrypted cookie with TanStack Start, so the server renders the signed-in page first.
In this guide, you keep the session in an encrypted cookie with TanStack Start. The server signs in, refreshes the tokens before rendering, and sends the signed-in page first, so the browser never shows a "Checking session…" state or a flash of signed-out content. The browser holds a small client that mirrors the session, and the hooks and the TanStack Router guards work with it. Server rendering explains how the pieces fit.
For TanStack Start in SPA mode, where the browser keeps the session, follow
TanStack Start in SPA mode
instead. A complete app is in
examples/tanstack-start-ssr.
Before you begin
- Node 22.12 or newer, which Start needs.
- A secret of 32 or more random characters to seal the cookie, set as
SESSION_SECRETon the server. To make one, runopenssl rand -base64 32.
Installation
To install the packages, run the following command:
npm install @monarcode/session-kit @tanstack/react-start zod1. Create the server engine
createServerAuth from @monarcode/session-kit/server keeps the session in
a cookie sealed with your secret, and refreshes it with your backend. Create
it in a server-only module, so Start refuses to bundle it for the browser:
import "@tanstack/react-start/server-only";
import { } from "@monarcode/session-kit/server";
import { } from "zod";
const = "https://api.example.com";
const = .({ : .(), : .() });
const = .({ : .(), : .() });
export const = ({
: "app",
: ,
: ..SESSION_SECRET!,
: async ({ , }) => {
const = await (`${}/auth/refresh`, {
: "POST",
,
: { "Content-Type": "application/json" },
: .({ }),
});
// The backend rejected the refresh token: sign out.
if (. === 401) return null;
if (!.) throw new (`Refresh failed (${.})`);
return .(await .());
},
});
/** Signs in with your backend, from the server. */
export async function (: string, : string) {
const = await (`${}/auth/login`, {
: "POST",
: { "Content-Type": "application/json" },
: .({ , }),
});
const : unknown = await .().(() => null);
if (!.) {
// The backend says why, such as wrong credentials, in `message`.
const = .({ : .() }).();
throw new (
.
? ..
: `Sign-in failed (${.})`,
);
}
return { ....(), : .() };
}Server sessions covers its other options, rotating the secret, signing out, and the limits every setup shares.
2. Add server functions
Every serverAuth method takes the request's cookie jar, and startJar()
from @monarcode/session-kit/tanstack-start/server gives it. Your server
functions decide what the browser may ask for:
import { } from "@monarcode/session-kit/tanstack-start/server";
import { } from "@tanstack/react-start";
import { } from "zod";
import { , } from "./server";
export const = ().(() =>
.(()),
);
export const = ({ : "POST" })
.(.({ : .(), : .() }))
.(async ({ }) =>
.((), await (., .)),
);
export const = ({ : "POST" }).(() =>
.(()),
);getSession and signIn resolve { sessionId, user }, never tokens, so
their results are safe to send to the browser. Start's compiler replaces each
handler with a request in the browser bundle, and leaves startJar and
serverAuth out of it.
3. Refresh before rendering
Request middleware refreshes access that is about to expire before any route or server function reads it, while the response can still set the cookie. Once Start starts streaming a page, a cookie set later is lost, so a refresh then, such as in a deferred loader's promise, would spend the refresh token and drop the new one:
import { } from "@monarcode/session-kit/tanstack-start/server";
import {
,
,
,
} from "@tanstack/react-start";
import { } from "./auth/server";
const = ({ : "request" }).(
async ({ }) => {
// A failed refresh shows up when a route reads the session.
await .(()).(() => {});
return ();
},
);
export const = (() => ({
: [
({
: ({ }) => === "serverFn",
}),
,
],
}));An app that defines createStart has no CSRF protection for its server
functions until it adds createCsrfMiddleware. Its filter keeps the check
to server-function calls: unfiltered, it also refuses page loads from links
on other sites and from the address bar. Server routes are left out too, so
a server route that changes the session must refuse other sites itself:
compare its Sec-Fetch-Site header to same-origin.
4. Create the browser client
createMirrorAuth makes the client that routes and components use. Create it
in getRouter, which Start calls once per request on the server and once in
the browser, never at module level. sessionHydration sends the session the
server read to the browser with the page.
First, the client:
import { } from "@monarcode/session-kit";
import { , , } from "./functions";
export function () {
return ({
: "app",
: null, // filled by loadSession, then by hydration
: () => (),
: (: { : string; : string }) =>
({ }),
: () => (),
});
}
export type = <typeof >;Then the Router, which creates it and connects it:
import { } from "@monarcode/session-kit/tanstack-router";
import { } from "@monarcode/session-kit/tanstack-start";
import { } from "@tanstack/react-router";
import { } from "./auth/client";
import { } from "./routeTree.gen";
export function () {
const = ();
const = ({
,
: { },
...(),
});
();
return ;
}
declare module "@tanstack/react-router" {
interface Register {
: <typeof >;
}
}Let TypeScript infer the client's types from its options: with
createMirrorAuth<User>(…), signIn would be left out.
5. Load the session, then guard routes
The root route's beforeLoad reads the request's session into the client
with loadSession, before any guard runs. In the browser, the hydrated
session is already there, so it doesn't call the server:
import { } from "@monarcode/session-kit/tanstack-start";
import { , } from "@tanstack/react-router";
import type { } from "../auth/client";
export const = <{ : }>()({
: ({ }) => (.),
: ,
});Guards are the ones from @monarcode/session-kit/tanstack-router. On the
server, a redirect is a real HTTP redirect:
import {
,
,
} from "@monarcode/session-kit/tanstack-router";
import { } from "@tanstack/react-router";
export const = ("/_authenticated")({
: ({ , }) =>
(., { , : "/login" }),
: () => < ={<>Loading…</>} />,
});Guard private routes
explains SessionOutlet, and an error component for a failed refresh.
Guards decide what a page shows. Server functions that return private data
must check the session themselves, with serverAuth.getSession(startJar())
or serverAuth.credentials.get(startJar()).
Sign in
The client's signIn sends what it's given to your signInFn, then adopts
the session it returns, re-runs guards and tells other tabs:
import type { } from "./auth/client";
export async function (
: ,
: string,
: string,
) {
try {
await .({ , });
} catch () {
// An AuthError; its cause is what signInFn threw.
.();
}
}In this guide, the server signs in with your backend, so the user in the cookie
comes from the backend. If the browser signs in with the backend itself, for
example through a provider's SDK, have signInFn take the tokens and the user
instead and pass them to serverAuth.signIn. It seals what it's given: until a
refresh or updateUser replaces it, the user in the cookie is what the browser
sent. Use it to show who is signed in, and let your backend authorize requests
with the access token, or check the tokens in signInFn first.
Call your API
On the server, get the access token in a server function.
credentials.get refreshes it first when it has expired:
import { } from "@monarcode/session-kit/tanstack-start/server";
import { } from "@tanstack/react-start";
import { } from "zod";
import { } from "./auth/server";
export const = ().(async () => {
const = await ..(());
if (!) throw new ("Sign in first");
const = await ("https://api.example.com/orders", {
: { : `Bearer ${.}` },
});
if (!.) throw new (`Orders failed (${.})`);
return .(.({ : .() })).(await .());
});To call the API from the browser too, with createAuthFetch or effetch's
sessionBearer, opt in with clientCredentials: true in createServerAuth.
Expose serverAuth.clientCredentials.get and .renew through two POST server
functions, and pass them as the client's credentials. The browser then
keeps the access token in memory, never the refresh token. The example app
does this.
Things to know
- Signing out ends the session only with
revoke. A copy of the cookie works again ten seconds after sign-out: see Sign-out. - One jar per request.
startJar()returns the same jar for every call while Start handles a request. - Caching. Pages and server functions that read the session differ per
user, and Start sends no
Cache-Controlfor them. Behind a CDN or proxy that caches responses, mark them private, for example withsetResponseHeader("Cache-Control", "private, no-store")from@tanstack/react-start/serverin request middleware, as the example app does. - Prerendering. A prerendered page renders at build time, without a cookie, so it shows the signed-out state. Don't prerender pages that read the session.
- Tabs. Signing in or out in one tab reaches the others, which ask the server again. So does returning to a tab after a while.
- More. Several server instances, the cookie's size, and plain http during development: see Server sessions.
Next steps
- Server sessions: the cookie, signing out, and the limits every server-rendered setup shares.
- Using the client: what the browser client can do.
- Effect on the server: run Effects with a request's session.