ZevAuth
Sign up

Building with the SDK

React Router

ZevAuth in a React Router (v7, formerly Remix) app, on the client and in loaders, actions and resource routes.

React Router uses @zevauth/react for everything that renders, and @zevauth/backend/verify for everything that runs on the server.

npm install @zevauth/react @zevauth/js @zevauth/backend

Where the session lives

A ZevAuth session is held by the browser and sent as a bearer token by your own fetch calls. It isn’t a cookie. That shapes the rest of this page:

  • Server loaders don’t see who is signed in. A page request from a browser carries no token, so a server loader has nobody to ask about.
  • clientLoaders and actions you call with fetch do. They run in the browser, where they can ask the client for a token.

Keep user-specific data out of server loaders. Load it in a clientLoader from a resource route that verifies the token.

One client for the app

Create the client in a module of its own. The provider, your loaders and your API calls all share it:

// app/zevauth.ts
import { createZevAuth } from '@zevauth/js';

export const zevauth = createZevAuth({
  publishableKey: import.meta.env.VITE_ZEVAUTH_KEY,
});
// app/root.tsx
import { ZevAuthProvider } from '@zevauth/react';
import { Outlet } from 'react-router';
import { zevauth } from './zevauth';

export default function App() {
  return (
    <ZevAuthProvider client={zevauth}>
      <Outlet />
    </ZevAuthProvider>
  );
}

A sign-in route

// app/routes/sign-in.tsx
import { SignIn } from '@zevauth/react';
import { useSearchParams } from 'react-router';

export default function SignInPage() {
  const [params] = useSearchParams();
  return <SignIn afterSignInUrl={params.get('redirect_url') ?? '/dashboard'} />;
}

Protecting routes

Check for a token in the route’s clientLoader and redirect when there isn’t one:

// app/routes/dashboard.tsx
import { redirect, type ClientLoaderFunctionArgs } from 'react-router';
import { zevauth } from '../zevauth';

export async function clientLoader({ request }: ClientLoaderFunctionArgs) {
  const token = await zevauth.getToken();
  if (!token) {
    const { pathname, search } = new URL(request.url);
    throw redirect(`/sign-in?redirect_url=${encodeURIComponent(pathname + search)}`);
  }

  const response = await fetch('/api/projects', {
    headers: { Authorization: `Bearer ${token}` },
  });
  return response.json();
}

// Render nothing until the clientLoader has run, rather than a server
// render that has no data for this user anyway.
export function HydrateFallback() {
  return null;
}

getToken() is safe to call before the components have loaded. It reads the stored session and refreshes it if needed, and refreshes are serialised across tabs, so it never races the provider.

Resource routes and actions

On the server, getAuth takes the standard Request React Router hands you:

// app/routes/api.projects.ts
import { getAuth, requireAuth } from '@zevauth/backend/verify';
import type { LoaderFunctionArgs, ActionFunctionArgs } from 'react-router';

const config = { environmentId: process.env.ZEVAUTH_ENVIRONMENT_ID! };

export async function loader({ request }: LoaderFunctionArgs) {
  const auth = await requireAuth(request, config); // throws a 401 Response
  return Response.json(await listProjects(auth.userId!, auth.organizationId));
}

export async function action({ request }: ActionFunctionArgs) {
  const auth = await getAuth(request, config);
  if (!auth.has({ permission: 'org:projects:manage' })) {
    return Response.json({ error: 'forbidden' }, { status: 403 });
  }
  // …
}

requireAuth throws a Response with status 401, which React Router returns as it is. getAuth never throws: a missing, expired or tampered token reads as signed out.

FieldMeaning
isSignedInWhether the token verified
userId, sessionIdThe signed-in user and session
organizationId, organizationRoleThe active organization, if any
has({ role, permission })Whether the active organization grants it
claimsEvery claim in the token

To submit a <Form> to an action with the token attached, use useSubmit with your own fetch, or call the resource route directly. A plain form post from the browser carries no bearer token.

Updated at, Sunday, November 1, 2026