Skip to Content

Getting started

Build a small app that signs in, stays signed in across reloads and tabs, calls an API, and signs out.

In this guide, you build a small React app that signs in, stays signed in across reloads and tabs, calls an API, and signs out. The six steps are the same for every setup that keeps the session in the browser or on the device. Server-rendered apps create their client differently, so start from Server rendering instead.

The code signs in against DummyJSON's auth API, a public test backend, so it runs as written.

Before you begin

Install Session kit, Zod, and React, as Installation shows.

1. Talk to your backend

Session kit never calls your backend for you, so start with the two requests you would write anyway: one that signs in, and one that exchanges a refresh token for new tokens. Nothing in this file imports Session kit:

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

export const  = "https://dummyjson.com";

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

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

const  = (: string, : unknown, ?: RequestInit) =>
  ( + , {
    ...,
    : "POST",
    : { "Content-Type": "application/json" },
    : .(),
  });

/** Checks the password, and resolves the tokens and the user. */
export async function (: string, : string) {
  const  = await ("/auth/login", { ,  });
  if (!.) throw new (`Sign-in failed (${.})`);
  // DummyJSON returns the user's fields beside the tokens.
  const : unknown = await .();
  return { ....(), : .() };
}

/**
 * Exchanges a refresh token for new tokens. Resolves `null` when the backend
 * rejects the token, and throws for any other failure.
 */
export async function (: string, : ) {
  const  = await ("/auth/refresh", {  }, {  });
  // DummyJSON answers 403 for an invalid token and 401 for a missing one.
  if (. === 401 || . === 403) return null;
  if (!.) throw new (`Refresh failed (${.})`);
  return .(await .());
}

The schemas do two jobs: they check what the backend sent, and they give the rest of the app its types. Requests omit credentials: "include", because Session kit keeps tokens out of cookies and attaches them itself.

2. Create the client

The client is the one object that holds the session. Tell it what a user is, where to save the session, and how to refresh:

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

import { , ,  } from "./backend.js";

export const  = ({
  // Keeps this app's saved session apart from any other on the same origin.
  : "my-app",
  // Every user is checked against this schema, and typed by it.
  : ,
  // Where the session is saved: `localStorage`.
  : (),
  // How expired access is replaced.
  : ({ ,  }) => (, ),
});

/** Signs in with the backend, then hands the result to Session kit. */
export async function (: string, : string) {
  await .(await (, ));
}

export type  = typeof ;

refresh tells two failures apart. null means the backend rejected the refresh token, so the user is signed out. A throw means something else failed, so the session is kept and the error reported. That split is why an outage doesn't sign anyone out. Refresh has the details, and Storage lists the other places a session can be saved.

Create one client per name in each tab, once, as this module does.

DummyJSON's tokens are JWTs, so their expiry comes from the exp and iat claims. Expiry covers backends that say it another way.

3. Add a sign-in form

signInWithPassword is all a form needs. The form has no Session kit code of its own:

src/sign-in.tsx
import { , type  } from "react";

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

export function ({  }: { ?: () => unknown }) {
  const [, ] = ("");
  const [, ] = (false);

  async function (: <>) {
    .();
    const  = new (.);
    ("");
    (true);
    try {
      await (
        (.("username")),
        (.("password")),
      );
      await ?.();
    } catch () {
      ( instanceof  ? . : "Could not sign in");
    } finally {
      (false);
    }
  }

  return (
    < ={}>
      <>Sign in</>
      {/* DummyJSON's documented test user. */}
      <>
        Username
        < ="username" ="emilys" ="username" />
      </>
      <>
        Password
        <
          ="password"
          ="password"
          ="emilyspass"
          ="current-password"
        />
      </>
      { && < ="alert">{}</>}
      < ={}>{ ? "Signing in…" : "Sign in"}</>
    </>
  );
}

The form doesn't have to do anything when it succeeds: signing in changes the client's state, and the next step renders from that state. onSignedIn is for routers, which use it to send the user on to the page they asked for.

4. Read the session in React

Create the hooks once, typed by your client, and export them for every component to share:

src/hooks.ts
import {  } from "@monarcode/session-kit/react";

import type {  } from "./auth.js";

export const { , ,  } =
  <typeof >();

Then render by status. Each case is one of the states in How it works. By the last case, TypeScript has narrowed user to a value that's set:

src/app.tsx
import {  } from "./auth.js";
import { , ,  } from "./hooks.js";
import {  } from "./sign-in.js";

export function () {
  return (
    < ={}>
      < />
      < />
    </>
  );
}

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

  switch () {
    case "initializing":
      return <>Restoring your session…</>;
    case "unauthenticated":
      return < />;
    case "unavailable":
      // Access couldn't be restored or refreshed: offer another try.
      return (
        < ={() => void .().(() => {})}>
          Try again
        </>
      );
    default:
      // Signed in: "authenticated", or "refreshing" with the user on screen.
      return (
        <>
          <>Welcome, {.}</>
          < ={() => void .().(() => {})}>
            Sign out
          </>
        </>
      );
  }
}

/** The latest failure, such as a refresh the backend couldn't answer. */
function () {
  const  = (() => .);
  return  ? < ="alert">{.}</> : null;
}
  • AuthProvider mounts the client: it restores the saved session, starts the refresh timer, and follows other tabs.
  • useAuth() returns the whole state. useAuth(selector) returns a part of it, and re-renders only when that part changes, as AuthError does.
  • useAuthClient() returns the client, for actions.
  • retry and signOut reject when they fail, and publish the same failure in state.error. AuthError shows it, so the handlers have nothing left to do.

With locking, give locked a case of its own: it has a user too, but no usable access.

That's a working app. To check it:

  1. Sign in. The welcome message appears.
  2. Reload the page. You're still signed in.
  3. Open the app in a second tab, and sign out in one of them. Both tabs show the sign-in form.

5. Call your API

createAuthFetch makes a fetch that sends the access token to one origin, and to no other:

src/api.ts
import {  } from "@monarcode/session-kit/http";

import {  } from "./auth.js";
import { ,  } from "./backend.js";

export const  = (, `${}/`);

/** The signed-in user, fresh from the backend. */
export async function () {
  const  = await ("auth/me");
  if (!.) throw new (`Profile failed (${.})`);
  return .(await .());
}

When a request gets a 401, authFetch refreshes, then sends a GET or HEAD again. Other methods are never sent twice. Authenticated requests covers the rest of its behaviour and its options.

6. Protect your routes

The client is the same everywhere. What changes is how each router keeps signed-out users away from private pages. To continue, follow the guide for your router:

With Effect, the same client becomes a service: see Effect and effetch.

Next steps

On this page