TanStack Router
Keep the client in Router context, guard private routes before they load, and use the hooks without a provider.
In this guide, you add TanStack Router to the app from Getting started: a
sign-in route, private routes that redirect signed-out users, and hooks that
read the client from the Router. Guards run in beforeLoad, before a private
route loads or renders, and the hooks need no AuthProvider.
A complete app is in
examples/tanstack-router.
TanStack Start in SPA mode differs in two
places. For TanStack Start with server rendering, follow
TanStack Start instead.
Before you begin
Follow Getting started through step 3. This guide
uses its client in src/auth.ts and its form in src/sign-in.tsx.
Installation
To install the packages, run the following commands:
npm install @monarcode/session-kit @tanstack/react-router zod
npm install -D @tanstack/router-pluginThis page uses file-based routing, so add the Router plugin before the React
plugin in vite.config.ts:
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tanstackRouter({ target: "react", autoCodeSplitting: true }),
react(),
],
});The plugin generates src/routeTree.gen.ts from the files under src/routes.
1. Create the hooks
Create hooks that read the client from Router context. They replace Getting
started's src/hooks.ts:
import { } from "@monarcode/session-kit/react";
import { } from "@monarcode/session-kit/tanstack-router";
// Reads the client from Router context, so no provider is needed.
export const { , } = ({
: ,
});Routes take the place of Getting started's src/app.tsx. Registering the
Router in step 5 gives the hooks your user type.
2. Type the root route
The root route declares that Router context holds the client, so every guard
gets a typed context.auth:
import { , } from "@tanstack/react-router";
import type { } from "../auth.js";
import { } from "../hooks.js";
export const = <{ : }>()({
: ,
});
function () {
// Shows failures that outlive the page that caused them, such as a sign-out
// that couldn't clear storage.
const = (() => .);
return (
<>
{ && < ="alert">{.}</>}
< />
</>
);
}3. Add the sign-in route
The sign-in route renders the form from Getting started, and sends the user on to the page they asked for:
import { } from "@monarcode/session-kit";
import { } from "@monarcode/session-kit/tanstack-router";
import { , } from "@tanstack/react-router";
import { } from "../sign-in.js";
export const = ("/login")({
: (: <string, unknown>) => ({
: (.redirectTo),
}),
: ({ , }) =>
(., { : . }),
: ,
});
function () {
const = ();
const { } = .();
return (
<
={() => .({ : , : true })}
/>
);
}safeReturnTo accepts only same-origin paths and returns / for anything
else, including the sign-in page itself, so a stale redirectTo can't loop.
Pass { loginPath } if sign-in lives somewhere other than /login.
redirectIfSignedIn sends signed-in users on to redirectTo, checked the
same way.
4. Guard private routes
Guard private routes with a pathless layout. Its child routes are guarded
without authenticated appearing in their URLs:
import {
,
,
} from "@monarcode/session-kit/tanstack-router";
import {
,
type ,
} from "@tanstack/react-router";
import { } from "../hooks.js";
export const = ("/_authenticated")({
: ({ , }) =>
(., { , : "/login" }),
: ,
: () => < ={<>Checking session…</>} />,
});
/** Shown after a failed refresh; a repeated failure stays in state.error. */
function ({ }: ) {
const = ();
return (
< ="alert">
<>{ instanceof ? . : "Something went wrong"}</>
< ={() => void .().(() => {})}>Retry</>
</>
);
}requireSession redirects signed-out users to loginPath with redirectTo
set to where they were going, and puts { session } in route context. A
failed refresh reaches the error component instead of a redirect, because the
user is still signed in.
SessionOutlet renders child routes while the session the guard accepted is
still the signed-in one. After a sign-out or an account switch it shows
pending until the guard runs again, and remounts child routes for the new
account, so nothing from the previous one stays on screen. Profile updates and
token refreshes keep child routes mounted.
Child routes read the session from route context, where user is typed and
never null:
import { } from "@tanstack/react-router";
import { } from "../../hooks.js";
export const = ("/_authenticated/")({
: ,
});
function () {
const { } = .();
const = ();
return (
<>
<>Welcome, {..}</>
< ={() => void .().(() => {})}>
Sign out
</>
</>
);
}5. Create and connect the Router
Create the Router with the client in its context, then connect the two:
import { } from "@monarcode/session-kit/tanstack-router";
import { } from "@tanstack/react-router";
import { } from "./auth.js";
import { } from "./routeTree.gen.js";
export const = ({
,
: { },
: () => <>Loading…</>,
});
// Restores the session, runs expiry timers and tab sync, and re-runs guards
// when auth changes. Call it once, beside the Router.
export const = ();
declare module "@tanstack/react-router" {
interface Register {
: typeof ;
}
}connectAuth does for the Router what AuthProvider does for a plain React
app, and one thing more: when the session changes, it runs the guards again,
so a sign-out in another tab redirects this one.
6. Render the app
Render the Router:
import { } from "react";
import { } from "@tanstack/react-router";
import { } from "react-dom/client";
import { } from "./router.js";
(.("root")!).(
<>
< ={} />
</>,
);To check the app:
- Signed out, open
/. The Router sends you to/login. - Sign in. You're back on
/, with the welcome message. - Open a second tab and sign out there. The first tab goes to
/login.
Layouts around guarded pages
A guarded route often has UI of its own around its pages, such as a sidebar
and a top bar. Render it in the route's component, with SessionOutlet where
the pages go:
import { } from "react";
import { } from "@monarcode/session-kit/tanstack-router";
import { } from "./hooks.js";
/** The guarded route's component: `component: Dashboard`. */
export function () {
// From state, not route context: this stays mounted through a sign-out.
const = (() => .);
const = (() => .);
return (
< ="dashboard">
<>{ ? `Signed in as ${.}` : null}</>
{/* Keyed by session, so one account's choices never show for the next. */}
< ={} />
<>
< ={<>Checking session…</>} />
</>
</>
);
}
function () {
const [, ] = (false);
return (
<>
< ={() => (!)}>
{ ? "Unpin" : "Pin"}
</>
</>
);
}SessionOutlet finds the session its guard accepted from the nearest route,
which is still the guarded one anywhere in that route's component. Three
things follow from where it sits:
- It protects only what it renders. After a sign-out or an account switch,
the pages are swapped for
pendingand remounted, but the layout around them stays mounted until the guard redirects. - Read the user from state there. In the layout, use
useAuth((state) => state.user)and handlenull. Route context'ssession.useris the session the guard accepted, which for that moment is the previous one. Inside the pages, route context's session is always the signed-in one. - Key what belongs to an account. Layout UI that holds state for one
account, such as a draft or an open panel, takes the session as its
key, so the next account starts fresh.
TanStack Start in SPA mode
TanStack Start's SPA mode is TanStack Router with a build-time shell, so every step on this page applies, with two changes.
In src/router.tsx, create the Router and connect auth inside
getRouter(), which Start calls:
import { connectAuth } from "@monarcode/session-kit/tanstack-router";
import { createRouter } from "@tanstack/react-router";
import { auth } from "./auth.js";
import { routeTree } from "./routeTree.gen.js";
export function getRouter() {
const router = createRouter({
routeTree,
context: { auth },
defaultPendingComponent: () => <p>Loading…</p>,
});
// Does nothing while the build prerenders the shell.
connectAuth(router);
return router;
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}And turn server rendering off for routes, in src/start.ts:
import { } from "@tanstack/react-start";
// The session is in browser storage, so guards must run in the browser.
export const = (() => ({
: false,
}));Start's own server still answers requests in SPA mode, and it has no saved
session to check. connectAuth does nothing while the Router renders on a
server, so the build-time shell shows your pending component. Complete apps
are in examples/tanstack-start-spa and,
written with Effect and effetch, in
examples/tanstack-start-spa-effetch.
Things to know
- The first render. With synchronous storage and a synchronous schema,
connectAuthrestores the saved session before the first render, so a signed-in user never sees the sign-in page. - Stopping. Call
disconnectAuthon app teardown or hot-module disposal. It stops background work without signing out. - A failed refresh is not a redirect.
requireSessionrethrows it for the route's error component, which can offerauth.retry(). The user is still signed in. - Locked sessions. Locking is for native apps, so the guards treat a locked session as signed out.
- Cached data.
state.sessionIdis new on every sign-in. Scope private cached data, such as TanStack Query results, to it.
Next steps
- Call your API with
authFetch, as in Getting started. It works unchanged with the Router. - Using the client: updating the user, signing out, and the client's other methods.
- Effect and effetch: the same client as an Effect service.