BuildBase on Vite: add auth, teams and billing to a React SPA with no server
Add authentication to a Vite React app with no server of its own: three small functions for the code exchange, an httpOnly cookie, then teams and billing.
In short
To add authentication to a Vite React app with BuildBase, send users to hosted sign-in, then swap the returned code for a session in a small serverless function, since the exchange needs a client secret the browser must never see. It sets an httpOnly cookie. After that, teams, roles and billing are SDK calls.
The shadcn-admin repo has a discussion thread asking how to add real authentication. The maintainer's answer is fair: you need a backend. The thread ends there, and every Vite auth tutorial takes one of two exits. Keep the token in the browser, or go build that backend.
There is a smaller answer. A Vite app does not need a backend to hold a session
safely. It needs three functions, each a plain Request -> Response
handler, the longest under twenty-five lines: exchange the code, report the
session, sign out. The same three run inside vite dev and as serverless functions in
production. After that, the teams, roles and plans you actually wanted are
hooks.
We know it holds because we did it to shadcn-admin itself: a 14k-star template whose sign-in and team pages were all mock-ups until then.
Before you start
A Vite + React app, a BuildBase organization, and an auth client created in
the console under User Management → Authentication, with its client ID
and secret. Register http://localhost:5173/sign-in on the client as a
redirect URL, and your deployed https://<domain>/sign-in later.
Tip
Start from the working example.
admin-dashboard
in the examples repo runs everything on this page in shadcn-admin. Watch it
working,
or copy it with npx degit buildbase-app/examples/admin-dashboard my-app.
1. Split the variables: four for the browser, one for the server
npm i @buildbase/sdk# .env.local
VITE_BUILDBASE_ORG_ID=demo-org-id
VITE_BUILDBASE_CLIENT_ID=demo-client-id
VITE_BUILDBASE_REDIRECT_URL=http://localhost:5173/sign-in
BUILDBASE_CLIENT_SECRET=demo-client-secret # no VITE_ prefix, ever
# VITE_BUILDBASE_SERVER_URL defaults to https://api.console.buildbase.appThis split is the whole design. Vite inlines anything prefixed VITE_ into the
bundle, which is fine for an org ID and a client ID and fatal for a secret. So
the secret goes without the prefix, and only server code reads it.
2. Write the three handlers
One file, no framework. Each export takes a Request and returns a Response,
a shape most serverless platforms accept and the Vite dev server can be made to
speak.
// server/auth.ts
const SESSION_COOKIE = 'bb-session';
const env = (name: string) => process.env[name] ?? '';
const serverUrl = () =>
env('VITE_BUILDBASE_SERVER_URL') || 'https://api.console.buildbase.app';
function cookie(value: string, maxAge: number) {
const secure = env('NODE_ENV') === 'production' ? '; Secure' : '';
return `${SESSION_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
}
/** POST /api/auth/verify: swap the hosted page's one-time code for a session. */
export async function verify(request: Request): Promise<Response> {
const { code } = await request.json().catch(() => ({}));
if (!code) return Response.json({ error: 'Missing code' }, { status: 400 });
const res = await fetch(`${serverUrl()}/api/v1/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
orgId: env('VITE_BUILDBASE_ORG_ID'),
clientId: env('VITE_BUILDBASE_CLIENT_ID'),
clientSecret: env('BUILDBASE_CLIENT_SECRET'),
}),
});
if (!res.ok)
return Response.json({ error: 'Sign-in failed' }, { status: 401 });
const { data } = await res.json();
return Response.json(
{ sessionId: data.sessionId },
{ headers: { 'Set-Cookie': cookie(data.sessionId, 60 * 60 * 24 * 30) } }
);
}
/** GET /api/auth/session: lets the app find its session after a reload. */
export function session(request: Request): Response {
const match = (request.headers.get('cookie') ?? '').match(
/(?:^|;\s*)bb-session=([^;]+)/
);
return Response.json({
sessionId: match ? decodeURIComponent(match[1]) : null,
});
}
/** POST /api/auth/signout */
export function signout(): Response {
return Response.json(
{ ok: true },
{ headers: { 'Set-Cookie': cookie('', 0) } }
);
}The 30-day cookie matches BuildBase's default session length. The example parses the cookie with a small helper rather than a regex; either works.
3. Serve them from vite dev and from production
In production, on Vercel, each handler is a one-line function file:
// api/auth/verify.ts
import { verify } from '../../server/auth';
export const POST = verify;api/auth/session.ts exports GET = session and api/auth/signout.ts
exports POST = signout.
Locally, a Vite plugin answers the same three paths, so npm run dev needs
nothing else running:
// vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv, type Connect, type Plugin } from 'vite';
import * as auth from './server/auth';
const routes = {
'POST /api/auth/verify': 'verify',
'GET /api/auth/session': 'session',
'POST /api/auth/signout': 'signout',
} as const;
const authMiddleware: Connect.NextHandleFunction = async (req, res, next) => {
const name =
routes[`${req.method} ${req.url?.split('?')[0]}` as keyof typeof routes];
if (!name) return next();
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const response = await auth[name](
new Request(`http://localhost${req.url}`, {
method: req.method,
headers: req.headers as Record<string, string>,
body: req.method === 'GET' ? undefined : Buffer.concat(chunks),
})
);
res.statusCode = response.status;
response.headers.forEach((value, header) => res.setHeader(header, value));
res.end(await response.text());
};
const buildbaseAuth = (): Plugin => ({
name: 'buildbase-auth',
configureServer: (server) => void server.middlewares.use(authMiddleware),
configurePreviewServer: (server) =>
void server.middlewares.use(authMiddleware),
});
export default defineConfig(({ mode }) => {
// Let the handlers read the secret from process.env, as a function would.
Object.assign(process.env, loadEnv(mode, process.cwd(), ''));
return { plugins: [buildbaseAuth(), react()] };
});Run npm run dev and open http://localhost:5173/api/auth/session. You
should see {"sessionId":null}: the server half is alive before any UI exists.
What we have run end to end is the vite dev path, against a real BuildBase
org on a local stack. The Vercel function files follow Vercel's docs, but we have not yet tested
a fresh Vercel deploy, or Netlify, or Cloudflare. Since the handlers are plain
Request -> Response, we expect each host to need a similar one-line wrapper.
The snippets here are trimmed from the example's files. The example is the tested version, so copy from it when the two disagree.
4. Wrap the app in the provider
// src/context/buildbase-provider.tsx (getSession lives in src/lib/buildbase.ts in the example)
import '@buildbase/sdk/css';
import { ApiVersion } from '@buildbase/sdk';
import { SaaSOSProvider } from '@buildbase/sdk/react';
let cached: string | null | undefined;
export async function getSession() {
if (cached === undefined) {
cached = (await (await fetch('/api/auth/session')).json()).sessionId;
}
return cached ?? null;
}
export function BuildBaseProvider({ children }: { children: React.ReactNode }) {
return (
<SaaSOSProvider
serverUrl={
import.meta.env.VITE_BUILDBASE_SERVER_URL ||
'https://api.console.buildbase.app'
}
version={ApiVersion.V1}
orgId={import.meta.env.VITE_BUILDBASE_ORG_ID}
auth={{
clientId: import.meta.env.VITE_BUILDBASE_CLIENT_ID,
redirectUrl: import.meta.env.VITE_BUILDBASE_REDIRECT_URL,
callbacks: {
getSession,
handleAuthentication: async (code: string) => {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
cached = (await res.json()).sessionId;
return { sessionId: cached! };
},
onSignOut: async () => {
await fetch('/api/auth/signout', { method: 'POST' });
cached = null;
},
},
}}
>
{children}
</SaaSOSProvider>
);
}getSession asks the server once per page load and caches the answer, so
route guards can call it freely. A button calling signIn() from
useSaaSAuth() now goes to the hosted page, which offers whichever of the
eight sign-in methods your org has on, and comes back to /sign-in signed in.
5. Guard routes, and remember where people were going
The hosted page always returns to the one URL you registered, never to the page someone originally asked for. So remember it yourself. The example uses TanStack Router, and its guard is five lines:
// src/routes/_authenticated/route.tsx
beforeLoad: async ({ location }) => {
if (!(await getSession())) {
throw redirect({ to: '/sign-in', search: { redirect: location.href } });
}
},On /sign-in, save that redirect value to sessionStorage right before
calling signIn(), and navigate to it once isAuthenticated turns true. Open
/users signed out, sign in, and you should land back on /users, not the
home page. Any router works the same way; only the guard's syntax changes.
6. Then teams, roles and plans are hooks
This is where the mock pages became real, and none of it needed another server function:
- The team switcher lists the user's workspaces from
useSaaSWorkspaces(), and Add team creates one. The SDK only fetches that list when asked, so the example callsfetchWorkspaces()once per sign-in and selects the first. - The Users table of 500 generated people became the workspace's real members: admins add people, move them between the admin, editor and viewer roles, and remove them. Everyone else sees it read-only.
- Profile, Account and Notifications forms that saved nowhere became one Account page opening the SDK's own screens: profile, security, devices, notifications, workspace, plan, usage and feature flags.
For the depth on each, the guides are
workspaces in an afternoon,
enforcing quotas in React and
why an invited member cannot see the workspace.
If your app has a server of its own after all,
BuildBase on Next.js covers reading the session
in server components,
BuildBase on React Router keeps your own
cookie session and loaders,
BuildBase on Express keeps Passport and
req.user, BuildBase on NestJS swaps Passport
and JWTs for one guard that RolesGuard never notices,
BuildBase on Fastify puts sign-in behind a
decorator your tests can fake,
BuildBase on Hono runs one server on Workers, Bun,
Deno and Node,
BuildBase on TanStack Start plugs into
better-auth without replacing it, and
BuildBase on Astro gates every page and endpoint
on a workspace role from one middleware.
Key takeaway
A Vite app needs three small functions, not a backend. Once the session is an httpOnly cookie, everything else is a hook.
What breaks, and how you can tell
Sign-in works, then every reload signs you out. The session route is not
being served. In dev the plugin is missing from plugins; in production the
function files are not where your host looks for them. Open
/api/auth/session directly: you should get JSON, not index.html.
You get index.html back from /api/auth/verify in production. A
catch-all rewrite for client-side routing is swallowing the API. Exclude
/api/ from it; the example's vercel.json rewrites only non-API paths.
The hosted page will not return to your app. The redirect URL must match a
registered one exactly, /sign-in path included. 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.
Adding a teammate fails for someone new. Adding a member needs an existing BuildBase account in your org. Email invitations for people who have never signed up are not in the published SDK yet.
The team switcher is empty, or Add team does nothing. An override turned off auto-creation, or the org is in Personal mode, where Add team is disabled. In the console's workspace settings, choose Platform mode; under Advanced Overrides, check that Auto-Create First Workspace is on and Can Invite Members is not Disabled.
Install
Add the SDK to your own app, or start from the example with
npx degit buildbase-app/examples/admin-dashboard my-app.
npm i @buildbase/sdk