Guides
Build guide

BuildBase on TanStack Start: hosted sign-in without replacing better-auth

TanStack Start authentication for apps already on better-auth: add BuildBase as a better-auth plugin and keep your sessions, roles and server-function checks.

Dharmendra Jagodana5 min read

In short

To add hosted sign-in to a TanStack Start app on better-auth, add BuildBase as a better-auth plugin with two endpoints: one redirects to the hosted page, one handles the callback, adopts existing users by email and opens a normal better-auth session. Roles and checks stay put, and sign-out ends both sessions.

Most TanStack Start authentication guides start from nothing: pick a provider, wire it in. But a lot of Start apps already have better-auth, and with it a session table, an admin plugin, roles, and a permission check in every server function. Tearing that out to get hosted sign-in would be a bad trade.

You do not have to. better-auth is built from plugins, and a sign-in method is just a plugin. So replace the front door, not the house: BuildBase becomes a better-auth plugin with two endpoints. It proves who someone is, and better-auth opens its own session for them exactly as it would after a GitHub login. Nothing that reads a session can tell the difference.

We did this to Start UI, BearStudio's TanStack Start template. Its email-code sign-in, GitHub login and login emails came out, the rest stayed, and upstream's 103 unit tests still pass.

Before you start

A TanStack Start app using better-auth, 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/api/auth/buildbase/callback on the client as a redirect URL.

Tip

Start from the working example. start-ui-web in the examples repo runs everything on this page in Start UI. Watch it working, or copy it with npx degit buildbase-app/examples/start-ui-web my-app.

1. Four variables, one of them secret

npm i @buildbase/sdk
# .env
VITE_BUILDBASE_ORG_ID=demo-org-id
VITE_BUILDBASE_CLIENT_ID=demo-client-id
BUILDBASE_CLIENT_SECRET=demo-client-secret   # server only
# VITE_BUILDBASE_SERVER_URL defaults to https://api.console.buildbase.app

The org and client IDs carry VITE_ because the React SDK's account screens use them in the browser (step 5). The secret does not: Start builds with Vite, which inlines every VITE_ variable into the bundle, and only the plugin reads the secret, on the server.

2. The plugin: a session field and two endpoints

// src/server/buildbase-auth.ts
import { ApiVersion, AuthApi, BuildBase } from '@buildbase/sdk';
import type { BetterAuthPlugin } from 'better-auth';
import { APIError, createAuthEndpoint } from 'better-auth/api';
import { setSessionCookie } from 'better-auth/cookies';
import { z } from 'zod';

export const buildbaseAuth = (options: BuildBaseAuthOptions) => {
  const authApi = () => new AuthApi({ serverUrl: options.serverUrl, version: ApiVersion.V1 });
  // No round trip it cannot finish: without these, sign-in answers 503.
  const configured = () => {
    const { orgId, clientId, clientSecret } = options;
    if (!orgId || !clientId || !clientSecret) throw new APIError('SERVICE_UNAVAILABLE');
    return { orgId, clientId, clientSecret };
  };

  return {
    id: 'buildbase',
    schema: {
      // The BuildBase session behind each better-auth session.
      session: { fields: { buildbaseSessionId: { type: 'string', required: false } } },
    },
    endpoints: {
      buildbaseSignIn: createAuthEndpoint(
        '/buildbase/sign-in',
        { method: 'GET', query: z.object({ redirectTo: z.string().optional() }) },
        async (ctx) => {
          const { orgId, clientId } = configured();
          const state = crypto.randomUUID();
          const redirectTo = sameOriginPath(ctx.query.redirectTo, ctx.context.baseURL);
          await ctx.setSignedCookie('buildbase_state', JSON.stringify({ state, redirectTo }),
            ctx.context.secret, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 600,
              secure: ctx.context.baseURL.startsWith('https') });
          const callback = `${ctx.context.baseURL}/buildbase/callback`;
          const { redirectUrl } = await authApi().requestAuth({
            orgId,
            clientId,
            redirect: { success: callback, error: callback },
            state,
          });
          throw ctx.redirect(redirectUrl);
        },
      ),
      buildbaseCallback: /* step 3 */,
    },
    hooks: { before: [/* step 4 */] },
  } satisfies BetterAuthPlugin;
};

The new buildbaseSessionId field is why this is a plugin and not just a pair of routes. better-auth adds the column to its session table, so each better-auth session remembers the BuildBase session behind it. Sign-out and the React SDK both need that.

sameOriginPath, a dozen lines in the example's file, keeps only a path on your own site, so the redirectTo cannot be turned into an open redirect. Register the plugin next to the ones you have:

// src/server/auth.tsx
plugins: [
  admin({ /* your roles and permissions, unchanged */ }),
  buildbaseAuth({
    serverUrl: envClient.VITE_BUILDBASE_SERVER_URL,
    orgId: envClient.VITE_BUILDBASE_ORG_ID,
    clientId: envClient.VITE_BUILDBASE_CLIENT_ID,
    clientSecret: envServer.BUILDBASE_CLIENT_SECRET,
  }),
],

Point your login button at /api/auth/buildbase/sign-in?redirectTo=... and you land on the hosted page, offering whichever of the eight sign-in methods your org has turned on.

3. The callback: state, code, then better-auth's own adapter

async (ctx) => {
  const { orgId, clientId, clientSecret } = configured();
  const raw = await ctx.getSignedCookie('buildbase_state', ctx.context.secret);
  const saved = raw ? JSON.parse(raw) : null;
  const { code, state } = ctx.query;
  if (!code || !saved || !state || state !== saved.state) {
    throw ctx.redirect('/login/error?error=STATE_MISMATCH');
  }

  const res = await fetch(`${options.serverUrl}/api/v1/auth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, orgId, clientId, clientSecret }),
  });
  if (!res.ok) throw ctx.redirect('/login/error?error=INVALID_CODE');
  const buildbaseSessionId = (await res.json()).data.sessionId;

  const profile = await BuildBase({ serverUrl: options.serverUrl, orgId })
    .withSession(buildbaseSessionId)
    .users.getProfile();
  const rawId = profile.id ?? profile._id;
  if (!rawId || !profile.email)
    throw new Error('BuildBase profile has no ID or email.');
  const accountId = String(rawId);
  const email = profile.email.toLowerCase();
  const adapter = ctx.context.internalAdapter;

  const owner = await adapter.findAccountOwnerByKey({
    providerId: 'buildbase',
    accountId,
  });
  let user = owner?.kind === 'owned' ? owner.user : null;
  if (!user) {
    const existing = await adapter.findUserByEmail(email);
    if (existing) {
      // A user from before BuildBase, with the same email, is adopted.
      await adapter.linkAccount({
        userId: existing.user.id,
        providerId: 'buildbase',
        accountId,
      });
      user = existing.user;
    } else {
      ({ user } = await adapter.createOAuthUser(
        // The example also sets Start UI's onboardedAt, so new users skip onboarding.
        {
          email,
          name: profile.name || email.split('@')[0],
          emailVerified: true,
        },
        { providerId: 'buildbase', accountId }
      ));
    }
  }

  const session = await adapter.createSession(user.id, false, {
    buildbaseSessionId,
  });
  await setSessionCookie(ctx, { session, user });
  throw ctx.redirect(saved.redirectTo);
};

Everything after the code exchange is better-auth's own machinery. BuildBase users live in better-auth's account table under the buildbase provider, the same way GitHub users did, and createSession issues an ordinary better-auth session. Your admin() plugin's roles and the permission checks in each server function (in Start UI, its oRPC procedures) see a normal signed-in user.

To check it, open a protected page while signed out and sign in on the hosted page. You land back where you started. Then sign in as an existing user with their old email: they keep their role.

4. Sign-out revokes the BuildBase session

import { createAuthMiddleware, getSessionFromCtx } from 'better-auth/api';

hooks: {
  before: [{
    matcher: (ctx) => ctx.path === '/sign-out',
    handler: createAuthMiddleware(async (ctx) => {
      const current = await getSessionFromCtx(ctx).catch(() => null);
      const id = (current?.session as { buildbaseSessionId?: string } | undefined)
        ?.buildbaseSessionId;
      if (id) await authApi().logout(id).catch(() => undefined);
    }),
  }],
},

A before hook runs while the better-auth session still exists, so it can read the BuildBase session ID off it. Without this, better-auth signs you out, but the BuildBase session stays alive: anything else holding it keeps working until it expires.

Key takeaway

On TanStack Start with better-auth, BuildBase is a plugin: a session field, two endpoints and one hook. better-auth still owns the session, so roles and server-function checks do not change.

5. The React SDK for account screens

Start UI's account page gains a sign-in and security card, next to upstream's own cards, whose rows open the SDK's security, devices and account screens. Two details matter:

  • The provider's getSession reads buildbaseSessionId from authClient.getSession(), the field the plugin added. No second cookie.
  • Call clearAuthIntent() before the provider renders. The SDK remembers pages it saw while signed out and jumps back to them when it finds a session; here better-auth already handles the return trip, so that memory only fights it. The React Router setup makes the same call for the same reason.

And guard where the data is. TanStack Start's own docs are blunt that beforeLoad is for route UX and "not the security boundary for the data". A better-auth app already checks the session in each server function; this swap leaves those checks exactly as they were.

Where Generic OAuth is enough

better-auth's own Generic OAuth plugin connects any OAuth or OIDC provider without writing a plugin. Discovery, PKCE, token refresh and provider logout are all configuration, not code. For a standards-compliant provider, use it.

This example needed its own plugin for three reasons. First, BuildBase's round trip is not OAuth-shaped: the hosted page comes from an API call (requestAuth()) rather than a static authorize URL, and the token exchange returns a BuildBase session, not an OAuth token response. Second, sign-out has to revoke that session on the server, not send the browser to a logout page. And the React SDK needs the BuildBase session ID on the better-auth session, which Generic OAuth's docs do not offer.

What breaks, and how you can tell

/login/error?error=STATE_MISMATCH on every attempt. The state cookie is not surviving the round trip: it must be SameSite=Lax, and secure only when the site is served over https.

Sign-in answers 503. VITE_BUILDBASE_ORG_ID, VITE_BUILDBASE_CLIENT_ID or BUILDBASE_CLIENT_SECRET is missing; the plugin refuses to start a round trip it cannot finish.

The account screen buttons stay disabled. No workspace is selected, and the screens are scoped to a workspace. The example loads the user's workspaces once per sign-in and selects the first; in the console, check that Auto-Create First Workspace (under Advanced Overrides) is on, which it is by default.

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, but not for a public suffix like *.vercel.app.

Install

Add the SDK to your own app, or start from the example with npx degit buildbase-app/examples/start-ui-web my-app.

npm i @buildbase/sdk
tanstack-start
better-auth
auth

Frequently Asked Questions

Do I have to remove better-auth to use BuildBase with TanStack Start?

No. BuildBase becomes one sign-in method inside better-auth. better-auth keeps issuing its own sessions, and the admin plugin, roles and every permission check in your server functions keep working unchanged.

What happens to users already in my database?

The first time someone signs in through BuildBase with an email your user table already has, the plugin links a BuildBase account to that user instead of creating a new one. They keep their role and data.

Does signing out end the BuildBase session too?

Yes. A before hook on better-auth's /sign-out reads the BuildBase session ID stored on the better-auth session and revokes it, so the next sign-in goes back through the hosted page.

Does a beforeLoad route guard protect my server functions?

No. TanStack Start's own docs say beforeLoad is for route UX and "not the security boundary for the data". Every server function is its own endpoint, so it has to check the session itself, which a better-auth app already does.

Ship it

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

7-day free trialNo credit card requiredCancel anytime