Guides
Build guide

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.

Dharmendra Jagodana5 min read

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

This 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 calls fetchWorkspaces() 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
vite
react
auth

Frequently Asked Questions

Can a Vite app keep an OAuth client secret?

No. Vite inlines every VITE_ variable into the browser bundle, so anyone can read it. The secret has to be read by something that runs on a server: a serverless function in production, and the Vite dev server locally.

Do I need a backend to add auth to shadcn-admin?

You need three small functions, not a backend service: one to exchange the sign-in code, one to report the session, one to sign out. In the examples repo they are plain Request to Response handlers that run inside vite dev and as Vercel Functions.

How do I send users back to the page they asked for after sign-in?

The hosted page always returns to the one redirect URL you registered, so remember the path yourself. The admin-dashboard example saves it to sessionStorage just before redirecting to sign-in and navigates to it once the session is back.

Ship it

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

7-day free trialNo credit card requiredCancel anytime