Skip to Content
Server rendering

Hybrid setup

Sign in with your backend from the browser and call your API there, while the server keeps the session in its cookie.

In a hybrid setup, the browser talks to your backend: it signs in, and calls your API with the access token. The server still keeps the session in its cookie, refreshes it, and renders who is signed in. Use it when your backend's sign-in has to run in the browser, such as a provider's SDK, or when your pages call your API themselves.

Two things change from a server-rendered setup:

  • Signing in. The browser gets the tokens from your backend and sends them to a server function, which seals them into the cookie with session.signIn.
  • Calling your API. The session route gives the browser the access token, and createAuthFetch sends it. The refresh token stays in the cookie.

This page builds on the TanStack Start guide. With Next.js or React Router, do the same in a Server Function or an action.

Before you begin

Follow the TanStack Start guide through step 5. This page replaces its src/auth/functions.ts, src/auth/client.ts and src/sign-in.ts.

Then let the session route serve the access token, with clientCredentials in createServerAuth, in src/auth/server.ts:

export const serverAuth = createServerAuth({
  // …the options from the guide
  clientCredentials: true,
});

1. Sign in with your backend in the browser

The browser calls your backend's sign-in, which must allow requests from your site (CORS):

src/backend.ts
import {  } from "zod";

const  = "https://api.example.com";

const  = .({
  : .(),
  : .(),
});
const  = .({ : .(), : .() });

/** Signs in with your backend, from the browser. */
export async function (: string, : string) {
  const  = await (`${}/auth/login`, {
    : "POST",
    : { "Content-Type": "application/json" },
    : .({ ,  }),
  });
  if (!.) throw new (`Sign-in failed (${.})`);
  const : unknown = await .();
  return { ....(), : .() };
}

2. Send the tokens to the server

session.signIn seals what it's given, and never asks your backend. So the server function decides what to trust. Check the tokens first, unless the cookie's user is only ever shown.

Check the tokens first

The server function takes the tokens only, and asks your backend who they belong to. A forged or expired token is refused, and the cookie's user comes from your backend, not from the browser:

src/auth/functions.ts
import {  } from "@tanstack/react-start";
import {  } from "zod";

import {  } from "./server";

const  = "https://api.example.com";

export const  = ().(() =>
  .(),
);

export const  = ({ : "POST" })
  .(.({ : .(), : .() }))
  .(async ({  }) => {
    // Proves the access token is real, and reads its user.
    const  = await (`${}/auth/me`, {
      : { : `Bearer ${.}` },
    });
    if (!.) throw new ("Your backend refused the token");
    const : unknown = await .();
    const  = .({ : .(), : .() }).();
    return .({ ...,  });
  });

The client's signIn takes the tokens, and sends them to signInFn:

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

The form signs in with your backend, then with the client. The sign-in page's guard sends the user on, so the form doesn't navigate:

src/sign-in.ts
import type {  } from "./auth/client";
import {  } from "./backend";

/** Signs in, and resolves the message to show when it fails. */
export async function (
  : ,
  : string,
  : string,
): <string | null> {
  try {
    const { ,  } = await (, );
    await .({ ,  });
    return null;
  } catch () {
    return  instanceof  ? . : "Could not sign in";
  }
}

With fromAccessToken as the server's user option, the server reads the user from the access token instead. Give it a decode that verifies the token's signature, and that's the check.

Pass the tokens without a check

The server function takes the tokens and the user the browser sends, and seals them as they are:

src/auth/trusting-sign-in.ts
import {  } from "@tanstack/react-start";
import {  } from "zod";

import {  } from "./server";

export const  = ({ : "POST" })
  .(
    .({
      : .(),
      : .(),
      : .({ : .(), : .() }),
    }),
  )
  .(({  }) => .());

The client's signIn takes what your backend's sign-in returned, so the form passes it login's result as it is:

signIn: (result: Awaited<ReturnType<typeof login>>) =>
  trustingSignInFn({ data: result }),

Anyone can then put any user in their own cookie, with tokens of their own. That's safe only when the cookie's user is shown, never trusted:

  • Your backend authorizes every request with the access token, and nothing on your server acts on session.user alone, such as reading another account's data by its id.
  • A forged token gets nowhere. Your API refuses it, and the first refresh fails and signs the user out.

When in doubt, check the tokens first. It costs one request at sign-in.

3. Call your API from the browser

createAuthFetch from @monarcode/session-kit/http sends the access token that the session route serves. Make one per client, since the client is made once per page load:

src/orders.tsx
import { ,  } from "react";

import {  } from "@monarcode/session-kit/http";
import {  } from "@monarcode/session-kit/tanstack-router";

export function () {
  const  = ();
  const  = (
    () => (, { : "https://api.example.com/" }),
    [],
  );
  const [, ] = <number | null>(null);

  async function () {
    const  = await ("orders");
    if (!.) throw new (`Orders failed (${.})`);
    const : unknown = await .();
    (.() ? . : 0);
  }

  return (
    <>
      < ={() => void ().(() => {})}>Load orders</>
      { !== null && <>{} orders</>}
    </>
  );
}

With ky, axios or openapi-fetch, pass apiFetch as the client's fetch: see Other HTTP clients.

The browser keeps the access token in memory, never the refresh token. After a 401, the client asks the session route for a new token, and the server refreshes with the cookie's refresh token. Server functions can still call your API too, as the guide's Call your API shows.

Things to know

  • The refresh token passes through the browser once, in your backend's sign-in response, on its way to the server. After that, page scripts never see it. If your backend's sign-in can run on the server, sign in there, as the setup guides do.
  • Your backend must allow your site (CORS) for its sign-in and the API calls you make from the browser. Session kit's own requests go to your session route, on your site.
  • Signing out works as in the guide's Sign out, through the session route. To end the session at your backend too, give createServerAuth a revoke.
  • Security has more on what the cookie's user can be trusted for, in Your backend decides what's allowed.

On this page