Skip to Content
Setups

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_SECRET on the server. To make one, run openssl rand -base64 32.

Installation

To install the packages, run the following command:

npm install @monarcode/session-kit @tanstack/react-start @tanstack/react-router zod

1. 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:

src/auth/server.ts
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:

src/routes/api/session.ts
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:

src/auth/functions.ts
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:

src/start.ts
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:

src/auth/client.ts
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:

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

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

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

src/sign-in.ts
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:

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

src/auth/api-fetch.ts
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:

src/orders.ts
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 session reads 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 with INVALID_OPTIONS.
  • Caching. Pages and server functions that read the session differ per user. refreshSession sends Cache-Control: private, no-store with 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

On this page