TanStack Start Auth Setup: The Right Way (Using Better-Auth)

By Aziz Ali
tanstack-startbetter-authauthenticationtypescriptdrizzle-orm

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.