Guides
Build guide

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.

Dharmendra Jagodana5 min read

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.app

No 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-session returns the BuildBase session ID only to a signed-in user, with Cache-Control: no-store, and the provider's getSession fetches 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
react-router
remix
auth

Frequently Asked Questions

Is Remix authentication the same as React Router v7 authentication?

For framework mode, yes. Remix v2 became React Router v7, so authentication still lives in loaders, actions and a cookie session. Everything on this page applies to a Remix v2 app, with the imports and route types renamed.

Do I have to throw away my users table?

No. The callback matches users by a new buildbaseId column, and on their first sign-in adopts an existing user with the same email. In the Epic Stack swap only the Password, Verification, Connection and Passkey tables went.

How do users get back to the page they asked for?

The login action saves redirectTo in the cookie session before redirecting to the hosted page, and the callback sends people there once their session is open. If you also mount the React SDK, call clearAuthIntent() first, or its own remembered page can override yours.

Can I use the React SDK in a server-rendered React Router app?

Yes. It renders on the server and resolves the signed-in state in the browser. The Epic Stack example uses it only for the prebuilt security, devices and account screens, and does sign-in entirely on the server.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime