TanStack Start Auth Setup: The Right Way (Using Better-Auth)
Setting up auth in a new framework always takes longer than it should. I wasted two weekends the first time I tried wiring authentication into a TanStack Start app — wrong session handling, SSR edge cases, and route guards that silently broke. Here's the exact setup I use now, with Better-Auth as the auth layer.
Why Better-Auth — Not Clerk, Not NextAuth
Clerk is $25/month before you've made a dollar. NextAuth is great for Next.js, but it's half-baked in TanStack Start — the session handling doesn't play nicely with TanStack Router's loader pattern. Better-Auth vs NextAuth vs Clerk covers this in depth, but the short version: Better-Auth is open-source, TypeScript-first, and works natively with Drizzle ORM. It's also free.
For a framework like TanStack Start — where SSR and server functions are first-class citizens — Better-Auth fits the architecture correctly. Clerk doesn't even have a TanStack Start adapter.
Setting Up Better-Auth with TanStack Start
Here's the real code, not pseudocode.
1. Install dependencies:
bun add better-auth drizzle-orm @libsql/client
2. Create your auth instance (src/lib/auth.ts):
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "sqlite", // or "pg", "mysql"
}),
emailAndPassword: {
enabled: true,
},
session: {
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes cache
},
},
trustedOrigins: [process.env.BETTER_AUTH_URL!],
});
export type Session = typeof auth.$Infer.Session;
3. Add the API handler (src/routes/api/auth/$.ts):
import { auth } from "~/lib/auth";
import { createAPIFileRoute } from "@tanstack/start/api";
export const APIRoute = createAPIFileRoute("/api/auth/$")({
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
});
4. Set up the Drizzle schema (add to your existing schema file):
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull(),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
token: text("token").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
});
Run bun drizzle-kit push and your auth tables are live.
Protecting Routes with TanStack Router
This is where most tutorials fall short. You don't want to check auth in components — you want it in the loader so unauthenticated users never see a flash of content.
// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from "@tanstack/react-router";
import { getSession } from "~/lib/auth-server";
export const Route = createFileRoute("/_authenticated")({
beforeLoad: async ({ context }) => {
const session = await getSession();
if (!session) {
throw redirect({ to: "/login" });
}
return { session };
},
});
// src/lib/auth-server.ts
import { auth } from "./auth";
import { getWebRequest } from "@tanstack/start/server";
export async function getSession() {
const request = getWebRequest();
const session = await auth.api.getSession({ headers: request.headers });
return session;
}
Any route nested under _authenticated is now protected at the loader level — no flash, no client-side redirects.
Architecture Overview
Here's how all the pieces connect in a TanStack Start + Better-Auth setup:
graph TD
A[Browser Request] --> B[TanStack Router]
B --> C{Route Loader}
C -->|beforeLoad check| D[getSession]
D --> E[Better-Auth API]
E --> F[Drizzle ORM]
F --> G[(SQLite / Turso)]
C -->|Authenticated| H[Render Page]
C -->|No session| I[Redirect to /login]
J[/api/auth/*] --> E
Client-Side Auth (Sign In, Sign Out)
import { authClient } from "~/lib/auth-client";
// Sign in
const { data, error } = await authClient.signIn.email({
email: "user@example.com",
password: "password123",
});
// Sign out
await authClient.signOut();
// Get current session on client
const { data: session } = authClient.useSession();
Your auth client lives at src/lib/auth-client.ts:
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.VITE_BETTER_AUTH_URL,
});
Common Gotchas
CORS on dev vs prod: Set BETTER_AUTH_URL to your actual origin — not localhost in production. Better-Auth uses this to validate session cookies.
SSR cookie leakage: When doing SSR in TanStack Start, always read session from getWebRequest().headers server-side, not from a client store. The client store hasn't hydrated yet during SSR.
Missing trustedOrigins: If you see CSRF errors, make sure your production URL is in the trustedOrigins array in your auth config.
FAQ
Does Better-Auth work with TanStack Start out of the box?
Yes. Better-Auth is framework-agnostic — it works via a standard Request/Response handler, which TanStack Start's API routes support natively. No adapter needed beyond the database one.
Can I add OAuth (Google, GitHub) later?
Absolutely. Better-Auth has social provider plugins you can add to your betterAuth() config. Adding Google OAuth is about 10 lines of config changes.
Is Better-Auth production-ready? Yes. It's actively maintained, TypeScript-first, and used in production by thousands of projects. The Auth.js team publicly recommended Better-Auth as the successor to NextAuth for new projects.
What database does Better-Auth support with Drizzle? SQLite (including Turso), PostgreSQL, and MySQL. The adapter auto-detects your schema format.
Do I need to manually run migrations for auth tables?
No. Run bun drizzle-kit push after adding the schema and Drizzle handles it. Or use bun drizzle-kit generate + migrate if you prefer migration files.
Auth setup in TanStack Start is genuinely straightforward once you know the pattern — loader-level guards, server-side session reads via getWebRequest(), and Better-Auth as the engine. The reason most tutorials make it look hard is they're copying Next.js patterns that don't map cleanly to TanStack Router's architecture.
If you're starting a new project and don't want to wire all of this yourself, BetterStarter ships with Better-Auth fully integrated — protected routes, session handling, Drizzle schema, and all. It also includes Stripe, Plunk email, and shadcn/ui pre-wired. $99 one-time, no subscription.
For context on why I chose TanStack Start in the first place, see Why I Switched from Next.js to TanStack Start. And if you're still weighing auth libraries, Better-Auth vs NextAuth vs Clerk is the most thorough comparison I've written.