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.
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.appThe 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
getSessionreadsbuildbaseSessionIdfromauthClient.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