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 withfetchdo. 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.
| Field | Meaning |
|---|---|
isSignedIn | Whether the token verified |
userId, sessionId | The signed-in user and session |
organizationId, organizationRole | The active organization, if any |
has({ role, permission }) | Whether the active organization grants it |
claims | Every 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