Skip to Content
Setups

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-plugin

This 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:

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:

src/routes/__root.tsx
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:

src/routes/login.tsx
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:

src/routes/_authenticated.tsx
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:

src/routes/_authenticated/index.tsx
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:

src/router.tsx
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:

src/main.tsx
import {  } from "react";

import {  } from "@tanstack/react-router";
import {  } from "react-dom/client";

import {  } from "./router.js";

(.("root")!).(
  <>
    < ={} />
  </>,
);

To check the app:

  1. Signed out, open /. The Router sends you to /login.
  2. Sign in. You're back on /, with the welcome message.
  3. 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:

src/dashboard.tsx
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 pending and 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 handle null. Route context's session.user is 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:

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, connectAuth restores the saved session before the first render, so a signed-in user never sees the sign-in page.
  • Stopping. Call disconnectAuth on app teardown or hot-module disposal. It stops background work without signing out.
  • A failed refresh is not a redirect. requireSession rethrows it for the route's error component, which can offer auth.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.sessionId is new on every sign-in. Scope private cached data, such as TanStack Query results, to it.

Next steps

On this page