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. The hooks come from @monarcode/session-kit/tanstack-router, typed by the Router you register, and need no AuthProvider.

A complete app is in examples/tanstack-router. TanStack Start in SPA mode lists what changes for Start. 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. 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 "@monarcode/session-kit/tanstack-router";
import { ,  } from "@tanstack/react-router";

import type {  } from "../auth.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">{.}</>}
      < />
    </>
  );
}

2. Add the sign-in route

The sign-in route renders the form from Getting started. Its guard sends a signed-in user on to the page they asked for, and it runs again after a sign-in, so the form doesn't navigate:

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),
  }),
  : ({ ,  }) =>
    (., { : . }),
  : ,
});

safeReturnTo accepts only same-origin paths and returns / for anything else, including the sign-in page itself, so a stale redirectTo can't loop. If sign-in isn't at /login, pass the same { loginPath } to safeReturnTo, redirectIfSignedIn and requireSession. redirectIfSignedIn sends signed-in users on to redirectTo, checked the same way. connectAuth runs it again after a sign-in.

3. 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";

export const  = ("/_authenticated")({
  : ({ ,  }) =>
    (., {  }),
  : ,
  : () => < ={<>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 /login, or the loginPath you pass, 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 "@monarcode/session-kit/tanstack-router";
import {  } from "@tanstack/react-router";

export const  = ("/_authenticated/")({
  : ,
});

function () {
  const {  } = .();
  const  = ();
  return (
    <>
      <>Welcome, {..}</>
      < ={() => void .().(() => {})}>
        Sign out
      </>
    </>
  );
}

4. 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.

Registering the Router also types useAuth and useAuthClient with your client. Until it's registered, they're typed never.

5. 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";

/** 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"}
      </>
    </>
  );
}

In the guarded route's component, SessionOutlet reads the session that route's guard accepted. 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. Steps 2 and 3 apply as they are. Installation, step 1, step 4 and step 5 change:

  • Install @tanstack/react-start instead of @tanstack/router-plugin.
  • In vite.config.ts, use Start's plugin, with SPA mode on, instead of the Router plugin: plugins: [tanstackStart({ spa: { enabled: true } }), react()].
  • Give the root route a shellComponent that renders <html>, <HeadContent /> and <Scripts />, as the example app does. Start renders the app from it, so there is no src/main.tsx.

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. In Start's SPA mode, getRouter() doesn't keep it, as the Router lasts as long as the page.
  • 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