Skip to Content
Setups

Next.js

Keep the session in an encrypted cookie with the Next.js App Router, where Server Components read it and Proxy refreshes it.

In this guide, you keep the session in an encrypted cookie with the Next.js App Router. A Server Function signs in, a session route answers the browser, Proxy refreshes the tokens, and Server Components read the session. The first response already shows the signed-in page, so the browser never shows a "Checking session…" state or a flash of signed-out content. Client Components get a small client that mirrors the session, and the hooks work with it. Server rendering explains how the pieces fit.

A complete app is in examples/next-app.

Before you begin

  • Next.js 16, with the App Router.
  • 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. next build doesn't need it.

Installation

To install the packages, run the following command:

npm install @monarcode/session-kit next 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. Create it in a server-only module, so Next refuses to bundle it for the browser:

lib/auth/server.ts
import "server-only";
import {  } from "@monarcode/session-kit/server";
import {  } from "zod";

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

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

export const  = ({
  : "app",
  : ,
  : ..!,
  : 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 .());
  },
});

/** 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. Read the session in Server Components

nextSession from @monarcode/session-kit/next gives your app its session over Next's cookies. Session kit doesn't import next, so pass it Next's own cookies and redirect. Create it in a server-only module too:

lib/auth/session.ts
import "server-only";
import {  } from "@monarcode/session-kit/next";
import {  } from "next/headers";
import {  } from "next/navigation";

import {  } from "./server";

export const  = (, { ,  });

In a Server Component, await session.get() resolves { sessionId, user } or null. It holds no tokens, so it's safe to pass to Client Components. It doesn't set cookies, as Server Components can't. Proxy refreshes first.

3. Refresh in Proxy

Proxy refreshes access that is about to expire before any page or Server Function reads it. session.refresh(request, response) sets the new cookie on the response, and Next passes it on to the Server Components rendering the same request:

proxy.ts
import { , type  } from "next/server";

import {  } from "./lib/auth/session";

export function (: ) {
  return .(, .());
}

export const  = {
  : ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

If the refresh fails, the page still renders with the cookie as it is. Server Functions are POST requests to the page that calls them, so keep the matcher wide enough to include those pages.

4. Add the session route and sign-in

The browser's client reads the session and signs out through a session route. sessionRoute from @monarcode/session-kit/next makes its GET and POST handlers. Pass it Next's cookies:

app/api/session/route.ts
import {  } from "@monarcode/session-kit/next";
import {  } from "next/headers";

import {  } from "../../../lib/auth/server";

export const { ,  } = (, {  });

It answers your own site's pages only, so another site can't sign the user out: see Security.

Signing in is yours, as it takes your form's input. A Server Function sets the cookie with session.signIn:

app/actions.ts
"use server";

import {  } from "zod";

import {  } from "../lib/auth/server";
import {  } from "../lib/auth/session";

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

export async function (: unknown) {
  const { ,  } = .();
  try {
    const  = await (, );
    return { : await .() };
  } catch () {
    return {
      :  instanceof  ? . : "Sign-in failed",
    };
  }
}

signInAction returns its failure rather than throwing it, as Next advises for expected errors. In production, Next hides a thrown error's message from the browser.

5. Create the browser client

createMirrorAuth makes the client that Client Components use, from the session the layout read, the session route and your sign-in:

lib/auth/client.ts
import { , type  } from "@monarcode/session-kit";

import {  } from "../../app/actions";
// Types only: left out of the browser's bundle.
import type {  } from "./server";

export type  = <typeof >;

export function (:  | null) {
  return ({
    : "app",
    ,
    : "/api/session",
    : async (: { : string; : string }) => {
      const  = await ();
      if ("error" in ) throw new (.);
      return .;
    },
  });
}

export type  = <typeof >;

useMirrorAuth from @monarcode/session-kit/react creates the client in a Client Component, once per request on the server, never at module level. It keeps the client and the server in step both ways. When the session changes in the browser, it calls onChange, here router.refresh(), which renders Server Components again. Register the client's type where you render AuthProvider, so the hooks carry your user:

app/providers.tsx
"use client";

import type {  } from "react";

import { ,  } from "@monarcode/session-kit/react";
import {  } from "next/navigation";

import {
  ,
  type ,
  type ,
} from "../lib/auth/client";

// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
  interface Register {
    : ;
  }
}

export function (: {
  :  | null;
  : ;
}) {
  const  = ();
  const  = ({
    : ,
    : .,
    : () => .(),
  });
  return < ={}>{.}</>;
}

Render the providers in the root layout, with the session it reads:

app/layout.tsx
import type {  } from "react";

import {  } from "../lib/auth/session";
import {  } from "./providers";

export default async function (: { :  }) {
  const  = await .();
  return (
    < ="en">
      <>
        < ={}>{.}</>
      </>
    </>
  );
}

6. Protect pages

Check the session in each page with session.require, rather than in a layout. Layouts don't render again on navigation. It takes the page's own path, where the sign-in page sends the user back, as Server Components don't know their URL:

app/page.tsx
import {  } from "../lib/auth/session";

export default async function () {
  const {  } = await .("/");
  return <>Welcome, {.}</>;
}

The sign-in page sends a signed-in user on to redirectTo with session.redirectIfSignedIn, and its form calls the client's signIn. When the session changes in the browser, after a sign-in or sign-out here or in another tab, useMirrorAuth renders the page again on the server, so these redirects run without code in the form:

app/login/page.tsx
import {  } from "../../lib/auth/session";
import {  } from "./sign-in-form";

export default async function ({
  ,
}: {
  : <<string, string | string[] | undefined>>;
}) {
  await .((await ).redirectTo);
  return < />;
}

The form is a Client Component that calls the client's signIn:

app/login/sign-in-form.tsx
"use client";

import { , type  } from "react";

import {  } from "@monarcode/session-kit/react";

export function () {
  const  = ();
  const [, ] = ("");

  async function (: <>) {
    .();
    const  = new (.);
    try {
      await .({
        : (.("username")),
        : (.("password")),
      });
    } catch () {
      // What the client's signIn option threw, from signInAction's error.
      ( instanceof  ? . : "Sign-in failed");
    }
  }

  return (
    < ={}>
      <>
        Username
        < ="username" ="username" />
      </>
      <>
        Password
        <
          ="password"
          ="password"
          ="current-password"
        />
      </>
      { && < ="alert">{}</>}
      <>Sign in</>
    </>
  );
}

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, have signInAction take the tokens instead, as Hybrid setup shows for TanStack Start.

Call your API

Call your API with createAuthFetch from @monarcode/session-kit/http. It sends the request's access token to your API's origin only. In Server Components, pass it session.readOnly(), the request's session that doesn't set cookies. Proxy refreshed the token before the render. A token that expired anyway rejects with REFRESH_FAILED, and so does a 401 from your API, since nothing new can be saved:

app/orders/page.tsx
import {  } from "@monarcode/session-kit/http";
import {  } from "zod";

import {  } from "../../lib/auth/session";

export default async function () {
  await .("/orders");
  const  = (await .(), {
    : "https://api.example.com/",
  });
  const  = await ("orders");
  if (!.) throw new (`Orders failed (${.})`);
  const  = 
    .(.({ : .() }))
    .(await .());
  return <>{.} orders</>;
}

In Server Functions and Route Handlers, pass await session.writable() instead, the request's session that can set cookies. After a 401, it renews the token, saves it in the cookie, and sends a GET again. 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 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; see Server sessions. 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.
  • Writes only where Next allows them. Use signIn, signOut, updateUser and writable() in Server Functions and Route Handlers only. In a Server Component they can't set the cookie, and writable() would refresh and then fail to save the new tokens, which spends a rotating refresh token.
  • One read per request. Calls to session during one request read the cookie once, and a sign-in through signIn shows in get().
  • Separate instances. Next builds Proxy, Route Handlers, and pages with their Server Functions separately, so each has its own serverAuth, even on one server. They share no refresh. A matcher that covers every route that reads the session lets Proxy refresh first, so Route Handlers and Server Functions rarely need to.
  • A basePath. Next doesn't add it to fetch, so the client's endpoint includes it: "/app/api/session". Nor does it add it to a Route Handler's redirects, so a Route Handler reads the session with get and answers 401, rather than calling require.
  • Rendering. Reading cookies makes a page dynamic, and the root layout reads the session, so every page is dynamic. Next sends such pages with Cache-Control: no-store, and plain use cache can't read cookies. Keep pages that read the session on the Node.js runtime, the default. Next may not pass Proxy's refreshed cookie on to Edge runtime pages.
  • Tabs. Signing in or out in one tab reaches the others, which ask the server again and render their pages 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