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 TanStack Router hooks and 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 @tanstack/react-router 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.
startAuth gives the session of the request Start is handling. Create both
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 "@monarcode/session-kit/tanstack-start/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 .());
},
});
/** The session of the request Start is handling, found on each call. */
export const = ();
/** 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 the session route and server functions
The browser's client signs out and gets the access token through a session
route. sessionRoute from @monarcode/session-kit/tanstack-start/server
makes its GET and POST handlers, for a server route. Start leaves
server.handlers out of the browser bundle, serverAuth included:
import { } from "@monarcode/session-kit/tanstack-start/server";
import { } from "@tanstack/react-router";
import { } from "../../auth/server";
export const = ("/api/session")({
: { : () },
});It answers your own site's pages only, so another site can't sign the user out: see The session route.
Two server functions do the rest. Signing in takes your form's input. Reading
the session is a server function too, because loadSession reads it while
Start renders on the server, where the client can't call a route on its own
site. session finds the request each time it's called, so one object
serves every request:
import { } from "@tanstack/react-start";
import { } from "zod";
import { , } from "./server";
export const = ().(() =>
.(),
);
export const = ({ : "POST" })
.(.({ : .(), : .() }))
.(async ({ }) => {
const = await (., .);
return .();
});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 session 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.
refreshSession from @monarcode/session-kit/tanstack-start/server does
the refresh. Pass it to Start's own createMiddleware().server(). Start
bundles src/start.ts for the browser too, but strips what's inside
.server(), so serverAuth stays on the server:
import { } from "@monarcode/session-kit/tanstack-start/server";
import {
,
,
,
} from "@tanstack/react-start";
import { } from "./auth/server";
const = ({ : "request" }).(
(),
);
export const = (() => ({
: [
({
: ({ }) => === "serverFn",
}),
,
],
}));It refreshes access that expires within 60 seconds. Access tokens that live
a minute or less are refreshed on every request, so pass a smaller within
for them, such as refreshSession(serverAuth, { within: 15 }).
A failed refresh doesn't fail the request. Routes read the session as usual, and an error there reaches the route's error component.
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. It leaves out server routes.
sessionRoute refuses other sites itself, and a server route of your own
that changes the session must too: see
The session route.
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. connectSession sends the session the
server read to the browser with the page, and runs guards again when it
changes there.
First, the client:
import { } from "@monarcode/session-kit";
import { , } from "./functions";
export function () {
return ({
: "app",
: null, // filled by loadSession, then by hydration
: "/api/session",
: () => (),
: (: { : string; : string }) =>
({ }),
});
}
export type = <typeof >;Then the Router, which creates it and connects it:
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. A dehydrate and a
hydrate of your own, such as for a query cache, go to createRouter as
usual. connectSession keeps them.
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 = <{ : }>()({
: ({ }) => (.),
: ,
});When the server can't read the session, such as after a failed refresh,
loadSession rejects, and the root route's errorComponent shows. In the
example app, it offers a retry with router.invalidate(), which runs
loadSession again.
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")({
: ({ , }) =>
(., { }),
: () => < ={<>Loading…</>} />,
});Guard private routes
explains SessionOutlet. Its error component isn't needed here, as
loadSession reports a failed refresh first.
Guards decide what a page shows. Server functions that return private data
must check the session themselves, with session.getSession() or
session.credentials.get().
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";
/** Signs in, and resolves the message to show when it fails. */
export async function (
: ,
: string,
: string,
): <string | null> {
try {
await .({ , });
return null;
} catch () {
// What signInFn threw, such as your backend's message for a wrong
// password.
return instanceof ? . : "Could not sign in";
}
}In a component, useAuthClient from @monarcode/session-kit/tanstack-router
gives you auth, from Router context.
Give the sign-in route the redirectIfSignedIn guard, as in TanStack Router's
Add the sign-in route.
connectSession runs it again after a sign-in, so the form doesn't navigate.
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 instead, as
Hybrid setup shows.
Sign out
The client's signOut() signs out on the server through the session route,
which clears the cookie and calls your revoke option, when
createServerAuth has one. It then signs out this tab and tells the others.
Guards run again, so requireSession sends the user to the sign-in page:
import { } from "@monarcode/session-kit/tanstack-router";
export function () {
const = ();
// A failed sign-out keeps the session, and shows in `state.error`.
return (
< ={() => void .().(() => {})}>
Sign out
</>
);
}Call your API
Call your API with createAuthFetch from @monarcode/session-kit/http.
Given session, it sends the access token of the request Start is handling,
refreshed first when it has expired. After a 401, it renews the token, saves
it in the cookie, and sends a GET again. Make it once, in a server-only
module:
import "@tanstack/react-start/server-only";
import { } from "@monarcode/session-kit/http";
import { } from "./server";
export const = (, {
: "https://api.example.com/",
});Then call it in a server function. Signed out, it rejects with
UNAUTHENTICATED:
import { } from "@tanstack/react-start";
import { } from "zod";
import { } from "./auth/api-fetch";
export const = ().(async () => {
const = await ("orders");
if (!.) throw new (`Orders failed (${.})`);
return .(.({ : .() })).(await .());
});With an HTTP client of your own, such as ky or axios, see
Other HTTP clients. A client without a
fetch option reads the token with session.credentials.get().
To call the API from the browser too, with createAuthFetch or effetch's
sessionBearer, opt in with clientCredentials: true in createServerAuth.
The session route then serves the access token, and the client's
credentials get it there. The browser 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. Without it, a copy of the cookie made before sign-out still works: see Sign-out. - One session per request. Each call to
sessionreads the request Start is handling, and calls during one request share what it read and one refresh. Outside a request, such as in the browser, they reject withINVALID_OPTIONS. - Caching. Pages and server functions that read the session differ per
user.
refreshSessionsendsCache-Control: private, no-storewith each response that doesn't set its own, so a CDN or proxy doesn't cache them. To leave every response as it is, pass{ cacheControl: false }. - 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.