To run Better Auth on TanStack Start in production, make four decisions: register tanstackStartCookies() as the last plugin so session cookies get written on the response, read the session with auth.api.getSession() inside a server function, redirect anonymous users with a beforeLoad guard, and check the session inside every server function that touches private data. The last step is the one quickstarts leave out, and without it an app that redirects correctly can still return private data to anonymous callers.
This guide covers the full Better Auth TanStack Start setup as it runs in our TanStack Start SaaS kit, which uses Better Auth in its Drizzle and Prisma variants. Every excerpt below comes from that codebase, with the file path it lives in. It targets TanStack Start 1.x on Vite and Nitro (@tanstack/react-start), current Better Auth, and React 19. TanStack Start moved from Vinxi to Vite + Nitro during 2025, so check the date on any tutorial you copy from, this one included.
If you're new to the framework, read What Is TanStack Start? or TanStack Start vs Next.js first. If you haven't picked an auth provider yet, Better Auth vs Clerk vs NextAuth vs Supabase Auth compares them. This post assumes you've chosen Better Auth.
What existing Better Auth + TanStack Start guides leave out
Each existing source covers one part of the setup:
- Clerk's TanStack Start guide is official and well written, but Clerk is a hosted service. Users are stored in Clerk's database, pricing is per monthly active user, and you don't own the session or organization model. That works for a prototype; a SaaS that wants its users in its own Postgres has different requirements.
- Better Auth's docs are the reference for the library API (plugins, sessions, organizations, 2FA) and include a TanStack Start integration page for the cookie plugin. They don't walk through the rest of the SSR path (reading the session during client navigation,
beforeLoad, authorizing server functions) in one place. - Generic "auth on TanStack Start" posts usually show one protected route and stop. Few mention that a route guard controls navigation and does nothing for the data endpoints.
- The Convex quickstart ranks well, but it hands authentication to a third party, so it doesn't apply if you want to own your users.
The sections below combine the library API with the TanStack Start plumbing, then add the multi-tenant and 2FA pieces the kit ships.
The server function is the security boundary
In TanStack Start, beforeLoad controls which screens a user can open, and the server function controls who can read or write data. Only the second one is a security control.
TanStack Start has no middleware.ts and no Next.js-style proxy in front of every route. It gives you three places to run code: a global request middleware you register yourself, route-level beforeLoad guards, and server functions. A beforeLoad guard decides whether a user sees a screen. It does not protect the endpoints that screen calls.
Server functions (createServerFn) are HTTP endpoints. Anyone who can reach your app can call one directly, without loading the route that normally calls it. They go through the request pipeline like any other request, so each one needs its own authorization check.
That gives you two checks with different purposes:
beforeLoadguard (user experience): redirects anonymous users to sign-in before they see a protected screen. It is not a security control.- Server function authorization (security): every server function that reads or writes private data checks the session itself.
The kit documents this split at the top of its guard file (apps/web/src/lib/auth/guards.ts):
/** * Route guards for `beforeLoad`. Attach to a layout/route to gate access. * * These guards control navigation only. Every server function and loader that * reads or writes private data must also authorize itself (via * `@kit/action-middleware` or an in-handler session check) โ a guard keeps a * user out of a screen, it does not protect the endpoints the screen calls. */Every section below applies this model.
Configuring the Better Auth instance
Better Auth is configured once, in packages/better-auth/src/auth.server.ts, and the rest of the kit imports from there. Two details in that file are specific to TanStack Start.
The first is the imports. The kit imports betterAuth from better-auth/minimal and the cookie plugin from better-auth/tanstack-start:
import { drizzleAdapter } from '@better-auth/drizzle-adapter';import { betterAuth } from 'better-auth/minimal';import { tanstackStartCookies } from 'better-auth/tanstack-start';The second is plugin order: tanstackStartCookies() must be the last plugin in the array. It sets the auth cookies on outgoing responses, and a plugin registered after it can overwrite those cookie writes. Better Auth's TanStack Start integration docs state the same requirement. Here is the shape of the instance (packages/better-auth/src/auth.server.ts):
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg' }), advanced: { useSecureCookies: IS_PRODUCTION, }, trustedOrigins: authConfig.baseURL ? [authConfig.baseURL] : [], secret: authConfig.secret, baseURL: authConfig.baseURL, plugins: [ adminPlugin, organizationPlugin, otpPlugin, createTwoFactorPlugin(), // ...conditional sign-in methods // MUST be last: sets auth cookies on outgoing responses. tanstackStartCookies(), ], // ...});export type Session = typeof auth.$Infer.Session;Three settings in this config are security decisions:
baseURLmust be a fully qualified URL. When it's unset, Better Auth derives the URL from the request'sHostheader, which a client can set to any value if your proxy passes it through. The kit refuses to boot withoutbaseURLinstead of trusting the header.useSecureCookies: IS_PRODUCTIONadds theSecureflag in production, so browsers send the cookies only over HTTPS.trustedOriginslists the origins Better Auth accepts requests from, which its CSRF protection relies on.
The kit validates the secret with Zod at boot: BETTER_AUTH_SECRET must be at least 32 characters, and in production it cannot equal the development test secret. Generate one with openssl rand -base64 32. Local development uses a default test secret automatically; production refuses to start without a real one.
How to read the Better Auth session in TanStack Start
Call auth.api.getSession({ headers: getRequestHeaders() }) on the server, and wrap it in a createServerFn so the router can call it during client-side navigation too.
The kit's getSession() reads the session from the request headers and memoizes the result per request, so several reads in one request call Better Auth once (packages/better-auth/src/context.ts):
export function getSession() { return requestMemo('session', () => auth.api.getSession({ headers: getRequestHeaders() }), );}getRequestHeaders() comes from @tanstack/react-start/server. Older tutorials import from vinxi/h3 or call getWebRequest(); those APIs predate the move to Vite and Nitro. On current TanStack Start, use @tanstack/react-start/server.
Request headers exist during a server-side render and inside a server function, but not during a client-side navigation. Wrapping the session read in a server function lets the router call it in both cases. That wrapper is fetchSession (apps/web/src/lib/auth/session.functions.ts):
export const fetchSession = createServerFn({ method: 'GET' }).handler(() => getSession(),);beforeLoad, loaders, and components can now await fetchSession() and get the server's view of the session, whether the page was rendered on the server or reached by client navigation.
Protecting routes with beforeLoad
A beforeLoad guard runs before a route and its children load, so it's where you redirect anonymous users to sign-in.
The kit's requireAuth awaits fetchSession(), redirects when there is no session, and returns the session into route context (apps/web/src/lib/auth/guards.ts):
export async function requireAuth({ location, context,}: { location: { href: string }; context: { locale: string };}) { const session = await fetchSession(); if (!session) { throw redirect({ href: localizeUrl( `/auth/sign-in?redirect=${encodeURIComponent(location.href)}`, context.locale, ), }); } return { session };}Attach it to a layout route so every child route inherits the check:
export const Route = createFileRoute('/_authenticated')({ beforeLoad: requireAuth,});The returned session is added to route context, so child routes and loaders can read it without calling fetchSession again during the same navigation.
If you wrap guard logic in try/catch, watch for this: TanStack Router signals a redirect by throwing. A catch block that logs and swallows errors around a server-function call will also swallow the redirect. Rethrow redirects with isRedirect():
import { isRedirect } from '@tanstack/react-router';try { const session = await fetchSession(); // ...} catch (error) { if (isRedirect(error)) { throw error; // let the router handle the redirect } // genuine failure: log, show an error boundary, etc.}A guard as short as requireAuth doesn't need try/catch. Use isRedirect() only when a call can both redirect and fail for other reasons. To send signed-in users away from the sign-in and sign-up pages, the kit uses the inverse guard, redirectIfAuthenticated, with the condition reversed.
Authorizing server functions
Every server function that reads or writes private data must check the session inside its own handler or middleware. The beforeLoad guard does nothing for these endpoints, because a caller can invoke them directly. Quickstarts skip this step because their examples have no server functions.
Instead of repeating a session check in every handler, put it in function middleware and bind it once. The kit's @kit/action-middleware package exports authAction(), organizationAction(), and adminAction() factories that attach authorization middleware to createServerFn before you write .handler (packages/action-middleware/src/client.ts):
/** Requires an authenticated user โ `context.user`, `context.session`. */export const authAction = (opts: ActionOptions = {}) => createServerFn({ method: 'POST', ...opts }).middleware([ errorMiddleware, authMiddleware, ]);/** Requires an active organization โ `context.organizationId`, `context.role`. */export const organizationAction = (opts: ActionOptions = {}) => createServerFn({ method: 'POST', ...opts }).middleware([ errorMiddleware, organizationMiddleware, ]);The authMiddleware they use resolves the session, throws Unauthorized when there isn't one, and passes a typed user and session to the handler's context (packages/action-middleware/src/middleware.server.ts):
export const authMiddleware = createMiddleware({ type: 'function' }).server( async ({ next }) => { const result = await getSession(); if (!result) { throw new Error('Unauthorized'); } return next({ context: { user: result.user, session: result.session, }, }); },);A protected mutation then gets the authorization check by default, and context.user is typed in the handler:
export const updateProfile = authAction() .validator(UpdateProfileSchema) .handler(async ({ data, context }) => { // context.user is guaranteed here, the middleware enforced it });The kit also exports pre-composed middleware tuples alongside the factories, because the way you write the createServerFn call affects the client bundle. When createServerFn(...) appears literally at the definition site, the TanStack Start compiler recognizes the server function and removes its handler, along with the database and auth code it imports, from the client bundle.
Multi-tenant organizations and RBAC with Better Auth
Better Auth's organization plugin stores the active organization on the session; the kit's organizationMiddleware reads it, looks up the caller's role, and rejects the call if there is no active organization.
Most SaaS apps need more than a signed-in check: they have team workspaces with roles. The organization plugin is registered server-side, and the active organization is stored in the session as activeOrganizationId. organizationMiddleware reads it, resolves the caller's role in that organization through Better Auth, and adds both to context (packages/action-middleware/src/middleware.server.ts):
export const organizationMiddleware = createMiddleware({ type: 'function' }) .middleware([authMiddleware]) .server(async ({ next }) => { const organizationId = await getActiveOrganizationId(); if (!organizationId) { throw new Error('No active organization'); } const { role } = await auth.api.getActiveMemberRole({ headers: getRequestHeaders(), }); return next({ context: { organizationId, role } }); });It runs authMiddleware first, so an organization action is both authenticated and scoped to one organization. For finer checks, the kit adds two more middleware: withMinRole('owner') requires a minimum role, and withFeaturePermission() checks a specific permission through Better Auth's access-control API (packages/action-middleware/src/middleware.server.ts):
const { success, error } = await auth.api.hasPermission({ headers: getRequestHeaders(), body: { organizationId, permissions },});if (error) { throw new Error('Permission check failed in authorization middleware');}if (!success) { throw new Error('Unauthorized');}Add these to an action like any other middleware. A destructive organization mutation can require the owner role while a read requires only membership. Neither Clerk's guide nor Better Auth's framework-agnostic docs show role and permission checks applied at the server-function level like this.
Two-factor authentication and email OTP
The kit enables two-factor auth and email OTP through Better Auth plugins instead of implementing them itself.
Two-factor on the server is a single plugin call (packages/better-auth/src/plugins/two-factor.ts):
export function createTwoFactorPlugin() { return twoFactor({ issuer: process.env.VITE_PRODUCT_NAME, });}On the client, twoFactorClient redirects the user to the verification page when a sign-in requires a second factor (packages/better-auth/src/auth-client.ts):
twoFactorClient({ onTwoFactorRedirect() { const redirect = new URLSearchParams(location.search).get('redirect'); window.location.href = '/auth/verify' + (redirect ? `?redirect=${redirect}` : ''); },}),For email OTP, the kit stores codes with Better Auth's keyed 'encrypted' storage instead of the 'hashed' option (packages/better-auth/src/plugins/otp-auth.ts):
export const otpPlugin = emailOTP({ // Use BA's keyed `'encrypted'` storage (XChaCha20-Poly1305 keyed by // BETTER_AUTH_SECRET) โ NOT `'hashed'`, which is unkeyed SHA-256 over the // 6-digit codespace and reversible by rainbow table on any DB read. storeOTP: 'encrypted', // ...sendVerificationOTP});A 6-digit code has one million possible values. An unkeyed SHA-256 hash of it can be reversed by hashing all one million values, so anyone who reads the database can recover live codes. With keyed encryption, recovering a code also requires BETTER_AUTH_SECRET, so a database leak alone doesn't expose them. It's a one-line setting that's easy to miss when you configure OTP yourself.
Every server plugin with a client-facing surface needs its client counterpart in auth-client.ts. Enable twoFactor() on the server and you need twoFactorClient() on the client; the same holds for emailOTP() / emailOTPClient() and the organization plugin. Mismatch them and the calls exist on one side only, which fails in ways that are annoying to trace.
Where CSRF and security headers go without middleware.ts
In TanStack Start, request-wide policy goes in the global request middleware registered in start.ts; session cookies are handled separately by tanstackStartCookies().
In Next.js, middleware.ts handles global request concerns. TanStack Start has no such file, so that work moves to the request middleware you register with createStart in start.ts. The kit uses it to apply CSRF protection to server functions and to set security headers on every response (apps/web/src/start.ts):
const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn',});export const startInstance = createStart(() => ({ requestMiddleware: [csrfMiddleware, securityHeadersMiddleware],}));CSRF protection is limited to serverFn requests on purpose. The Better Auth catch-all route (/api/auth/*) receives requests from other origins, such as OAuth callbacks and billing webhooks, and Better Auth applies its own trusted-origin and signature checks there. securityHeadersMiddleware sets X-Frame-Options: DENY and X-Content-Type-Options: nosniff.
To summarize the three layers: tanstackStartCookies() writes and refreshes the session cookie, beforeLoad controls navigation, and function middleware controls data access.
Using the MakerKit TanStack Start kit instead
You can build everything above yourself; this post lists each piece. If you'd rather start from a working baseline, that's what we sell.
Our TanStack Start SaaS kit ships this architecture: the Better Auth instance with the cookie plugin in the right position, the beforeLoad guards, the start.ts request middleware, and the authAction() / organizationAction() / adminAction() factories that make server-function authorization the default. It also includes organizations, RBAC, 2FA, email OTP, magic links, passkeys, and social login, all on Better Auth. The Drizzle and Prisma variants differ only in packages/database (see Drizzle or Prisma for TanStack Start for how to choose). A Supabase variant also exists, but it uses Supabase Auth instead of Better Auth; that setup is covered in TanStack Start + Supabase Auth.
Quick Recommendation
This production setup is best for:
- Teams building a SaaS on TanStack Start who want users stored in their own Postgres, with auth checks that hold when endpoints are called directly.
- Apps that need multi-tenant organizations, roles, and permissions rather than a signed-in check.
Skip the full setup if:
- You're building a throwaway prototype where a hosted quickstart is fine and per-user pricing never matters.
- You haven't picked the framework yet; read the TanStack Start vs Next.js comparison first.
Our pick: Register tanstackStartCookies() as the last Better Auth plugin, read the session through a createServerFn wrapper, redirect with beforeLoad, and authorize every server function with function middleware.
Frequently Asked Questions
How do I read the Better Auth session server-side in TanStack Start?
Do I need a middleware.ts file for Better Auth on TanStack Start?
Is a beforeLoad guard enough to secure my data?
Why must tanstackStartCookies() be the last Better Auth plugin?
How do I add multi-tenant organizations and roles with Better Auth in TanStack Start?
Does the MakerKit TanStack Start kit use Better Auth?
Is TanStack Start production-ready for a SaaS in 2026?
Next steps
For the framework tradeoff, read TanStack Start vs Next.js. For the auth provider decision, read Better Auth vs Clerk. To start from a codebase that already has this setup, see the TanStack Start SaaS kit.