BuildBase on React Router (Remix): hosted sign-in, your own session, no loader changes
React Router v7 authentication with hosted sign-in: a login action, a callback route that swaps the code, and your own session, so no loader changes.
In short
To add authentication to a React Router v7 app with BuildBase, have the login action redirect to hosted sign-in and a resource route swap the returned code for a session using the client secret. The callback finds or creates your user and opens your usual session, so requireUserId and every loader stay unchanged.
Remix apps have always done authentication the same way: a loader reads a
cookie session, requireUserId() throws a redirect when it is empty, and every
protected route calls it. React Router v7 kept all of that.
So the useful question is not which auth library to add. It is how little
has to change. With BuildBase the answer is three routes. The login action
redirects to a hosted page, a new callback route swaps the returned code for a
session and opens your own session exactly as before, and logout ends both. requireUserId() does not
know anything happened, and neither does a single loader.
We checked that claim on the Epic Stack, Kent C. Dodds' starter, which builds passwords, verification emails, GitHub OAuth and passkeys by hand. About 4,200 lines of app code and 1,000 lines of tests came out. The notes app, roles and permissions did not change.
Before you start
A React Router v7 app in framework mode (or Remix v2) with a cookie session, a
BuildBase organization, and an auth client created in the console under User
Management → Authentication, with its client ID and secret. Register
http://localhost:3000/auth/buildbase/callback on the client as a redirect
URL.
Tip
Start from the working example.
epic-stack
in the examples repo runs everything on this page in the Epic Stack. Watch it
working,
or copy it with npx degit buildbase-app/examples/epic-stack my-app.
1. Install the SDK and add three server variables
npm i @buildbase/sdk# .env
BUILDBASE_ORG_ID=demo-org-id
BUILDBASE_CLIENT_ID=demo-client-id
BUILDBASE_CLIENT_SECRET=demo-client-secret
# BUILDBASE_SERVER_URL defaults to https://api.console.buildbase.appNo VITE_ prefix on any of them. Sign-in runs on the server, so the browser
never needs them for it. (Step 6's optional React SDK sends the org and client
IDs to the browser; never the secret.)
2. Write the two BuildBase calls, in one server module
// app/utils/buildbase.server.ts
import crypto from 'node:crypto';
import { ApiVersion, AuthApi, BuildBase } from '@buildbase/sdk';
const serverUrl = () =>
process.env.BUILDBASE_SERVER_URL ?? 'https://api.console.buildbase.app';
export const newState = () => crypto.randomBytes(16).toString('hex');
/** The hosted sign-in URL. BuildBase returns to /auth/buildbase/callback. */
export async function getSignInUrl(request: Request, state: string) {
const callback = new URL('/auth/buildbase/callback', request.url).toString();
const { redirectUrl } = await new AuthApi({
serverUrl: serverUrl(),
version: ApiVersion.V1,
}).requestAuth({
orgId: process.env.BUILDBASE_ORG_ID!,
clientId: process.env.BUILDBASE_CLIENT_ID!,
redirect: { success: callback, error: callback },
state,
});
return redirectUrl;
}
/** Swap the one-time code for a BuildBase session ID, with the secret. */
export async function exchangeCode(code: string) {
const res = await fetch(`${serverUrl()}/api/v1/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
orgId: process.env.BUILDBASE_ORG_ID,
clientId: process.env.BUILDBASE_CLIENT_ID,
clientSecret: process.env.BUILDBASE_CLIENT_SECRET,
}),
});
if (!res.ok)
throw new Error(`BuildBase token exchange failed (${res.status})`);
return ((await res.json()) as { data: { sessionId: string } }).data.sessionId;
}
let client: ReturnType<typeof BuildBase> | undefined;
/** The server SDK, bound to one session per call. */
export function buildbaseFor(sessionId: string) {
client ??= BuildBase({
serverUrl: serverUrl(),
orgId: process.env.BUILDBASE_ORG_ID!,
});
return client.withSession(sessionId);
}Why withSession() rather than a getSessionId callback like the
Next.js setup? Next.js can read cookies from
anywhere in a request. A React Router loader gets its request as an argument
and nothing else does, so each loader binds the session it already has.
Express works the same way, with req in
place of request, and so does Hono, where the
session comes out of a signed cookie on each request.
3. The login action: remember two things, then redirect
// app/routes/_auth/login.tsx
import { getSignInUrl, newState } from '#app/utils/buildbase.server.ts';
import { authSessionStorage } from '#app/utils/session.server.ts';
import { redirect } from 'react-router';
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const redirectTo = formData.get('redirectTo');
const state = newState();
const authSession = await authSessionStorage.getSession(
request.headers.get('cookie')
);
authSession.set('buildbaseState', state);
authSession.set(
'buildbaseRedirectTo',
typeof redirectTo === 'string' ? redirectTo : ''
);
return redirect(await getSignInUrl(request, state), {
headers: {
'set-cookie': await authSessionStorage.commitSession(authSession),
},
});
}The login page shrinks to one form with a hidden redirectTo and a
Continue to sign in button. state guards the round trip against forged
callbacks. redirectTo is the part people lose: the hosted page comes back to
one fixed URL, so the page the user wanted has to ride along in your own
cookie.
Submit the form and you should land on BuildBase's hosted page, offering whichever of the eight sign-in methods your org has switched on.
4. The callback: check the state, swap the code, open your session
A resource route, so it has a loader and no UI:
// app/routes/_auth/auth.buildbase.callback.ts
import { createSessionForBuildBaseUser } from '#app/utils/auth.server.ts';
import { buildbaseFor, exchangeCode } from '#app/utils/buildbase.server.ts';
import { authSessionStorage } from '#app/utils/session.server.ts';
import { redirect } from 'react-router';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const authSession = await authSessionStorage.getSession(
request.headers.get('cookie')
);
if (!code || !state || state !== authSession.get('buildbaseState')) {
throw redirect('/login');
}
const buildbaseSessionId = await exchangeCode(code);
const profile = await buildbaseFor(buildbaseSessionId).users.getProfile();
// The API returns `id`; the SDK type marks it optional beside `_id`.
const id = profile.id ?? profile._id;
if (!id || !profile.email) throw redirect('/login');
return createSessionForBuildBaseUser({
request,
buildbaseSessionId,
profile: { id: String(id), email: profile.email, name: profile.name },
redirectTo: authSession.get('buildbaseRedirectTo') || '/',
});
}Require the state, not just compare it when present. A check written as
state && state !== expected lets a callback with no state straight through.
We shipped exactly that in three examples and caught it while writing this.
createSessionForBuildBaseUser is where your app stays your app. It finds the
user by buildbaseId, or adopts an existing user with the same email, or
creates one with your default role. Then it creates your usual Session row and
sets your usual cookie, plus the BuildBase session ID beside it:
// app/utils/auth.server.ts (the new part)
let user =
(await prisma.user.findUnique({ where: { buildbaseId: profile.id } })) ??
(await prisma.user.findUnique({
where: { email: profile.email.toLowerCase() },
}));
// ...update or create, then:
const session = await prisma.session.create({
data: { expirationDate: getSessionExpirationDate(), userId: user.id },
});
authSession.set(sessionKey, session.id);
authSession.set('buildbaseSessionId', buildbaseSessionId);Adopting by email is what lets you move an app that already has users. Nobody
re-registers. The first time someone signs in through BuildBase with the email
they already had, their old row gets a buildbaseId and keeps its notes and
roles.
To check it, open a protected page while signed out. You go to /login,
through the hosted page, and land back on the page you asked for.
5. Logout ends both sessions
// app/utils/buildbase.server.ts
export async function revokeBuildBaseSession(sessionId: string | undefined) {
if (!sessionId) return;
await new AuthApi({ serverUrl: serverUrl(), version: ApiVersion.V1 })
.logout(sessionId)
.catch(() => {});
}
// in your logout(), before destroying the cookie
await revokeBuildBaseSession(authSession.get('buildbaseSessionId'));Skip this and you are signed out of your app, but the BuildBase session stays alive: anything else holding it keeps working until it expires.
Key takeaway
In React Router, BuildBase proves who someone is. Your loaders keep trusting
your own session, so requireUserId() and every route built on it stay as
they are.
6. Optional: the React SDK, for screens you do not want to build
Sign-in does not need the React SDK at all. The Epic Stack example uses it for something else: its five settings pages (email, password, two-factor codes, connections and passkeys) are gone, and three buttons open the SDK's security, devices and account screens in their place. The two-factor page is simply removed, not moved: those screens cover passkeys, sessions, devices and the profile.
Two things make it behave in a server-rendered app:
- Hand it the session through a resource route, not by exposing the cookie.
/resources/buildbase-sessionreturns the BuildBase session ID only to a signed-in user, withCache-Control: no-store, and the provider'sgetSessionfetches it. - Call
clearAuthIntent()before the provider renders, in the browser. The SDK remembers any page it sees while signed out as the place to return after sign-in, and jumps there the next time it finds a session. In this setup the server already returns people to the right page, so the SDK's memory only fights it. A TanStack Start app on better-auth needs the same call.
The screens are scoped to a workspace. The SDK fetches the workspace list only when asked, so the example loads it once per sign-in and picks the first one.
What breaks, and how you can tell
After sign-in you land on the wrong page, or bounce once. The React SDK's
remembered page is winning over your redirectTo. Call clearAuthIntent()
before SaaSOSProvider renders.
Every sign-in lands back on /login. The state cookie is not surviving
the round trip. The cookie session must be SameSite=Lax, not
Strict: the hosted page returns with a top-level navigation from another
site, and a strict cookie is not sent on it.
Seeded users cannot sign in. They have no BuildBase account yet. Adoption by email only happens when someone signs in through BuildBase with that email, so sign up once with the seeded address.
The hosted page refuses to send you back. The callback URL must match one
registered on the auth client exactly, path included; the port must match
too, except on localhost, where it is ignored. A wildcard can stand in for one leftmost subdomain on a domain you own
(https://*.app.example.com/...), but not for a public suffix, so
*.vercel.app is refused: register each preview domain you use.
Account screens open to nothing. No workspace is selected, or none exists. In the console's workspace settings, check that Auto-Create First Workspace (under Advanced Overrides) is on; it is by default.
Install
Add the SDK to your own app, or start from the example with
npx degit buildbase-app/examples/epic-stack my-app.
npm i @buildbase/sdk