| name | ref-betterauth |
| description | Reference for BetterAuth with Next.js App Router. Covers server instance setup, client hooks, social sign-on, and database adapter configuration. Consult when implementing auth flows, debugging login/signup, or configuring session management. |
BetterAuth + Next.js Reference
Packages
bun add better-auth
Single package — includes server, client, and all plugins.
Environment Variables
BETTER_AUTH_SECRET=your_random_secret_here # generate with: openssl rand -hex 32
BETTER_AUTH_URL=http://localhost:3000 # your app URL
DATABASE_URL=postgresql://... # same DB as Supabase or separate
Setup
1. Server Instance (lib/auth/server.ts)
CRITICAL: Must lazy-init to avoid build-time failures.
The betterAuth() constructor accesses env vars. Next.js evaluates module-scope code
during bun run build ("Collecting page data" phase). If env vars aren't set, it crashes.
import { betterAuth } from "better-auth";
let _auth: any;
export function getAuth() {
if (!_auth) {
_auth = betterAuth({
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL!,
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
});
}
return _auth;
}
Why any? The ReturnType<typeof betterAuth> is generic (Auth<BetterAuthOptions>).
Storing it in a let variable with explicit type causes TS errors because the concrete
config has narrower types (e.g., secret: string vs string | undefined). Using any
is the pragmatic solution for lazy init.
2. API Route Handler (app/api/auth/[...all]/route.ts)
CRITICAL: Cannot use export const { GET, POST } = toNextJsHandler(auth) pattern
because the auth instance must be lazy-initialized. The destructuring forces
module-scope evaluation, which crashes at build time.
import { getAuth } from "@/lib/auth/server";
import { toNextJsHandler } from "better-auth/next-js";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const { GET: handler } = toNextJsHandler(getAuth());
return handler(request);
}
export async function POST(request: Request) {
const { POST: handler } = toNextJsHandler(getAuth());
return handler(request);
}
3. Client (lib/auth-client.ts)
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000",
});
4. Using in Components
"use client";
import { authClient } from "@/lib/auth-client";
export function LoginButton() {
const { data: session, isPending } = authClient.useSession();
if (isPending) return <div>Loading...</div>;
if (session) return <div>Welcome, {session.user.name}</div>;
return (
<button onClick={() => authClient.signIn.email({
email: "user@example.com",
password: "password123",
})}>
Sign In
</button>
);
}
5. Sign Up
await authClient.signUp.email({
email: "user@example.com",
password: "password123",
name: "User Name",
});
6. Social Sign In
await authClient.signIn.social({ provider: "github" });
await authClient.signIn.social({ provider: "google" });
7. Sign Out
await authClient.signOut();
8. Server-Side Session (in Server Components / Route Handlers)
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export default async function Page() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) redirect("/login");
return <div>Hello {session.user.name}</div>;
}
Database Tables (auto-created)
BetterAuth automatically creates these tables on first run:
user — id, name, email, emailVerified, image, createdAt, updatedAt
session — id, expiresAt, token, createdAt, updatedAt, ipAddress, userAgent, userId
account — id, accountId, providerId, userId, accessToken, refreshToken, etc.
verification — id, identifier, value, expiresAt, createdAt, updatedAt
Using with Supabase DB
BetterAuth can share the same PostgreSQL database as Supabase. Just use the Supabase connection string as DATABASE_URL. The auth tables will be created in the public schema alongside your app tables.
Important: If using BetterAuth for auth, DON'T also use Supabase Auth. Pick one. We picked BetterAuth.
Gotchas
- LAZY INIT IS MANDATORY —
betterAuth() and toNextJsHandler() both access env vars at call time. Module-scope init crashes bun run build during "Collecting page data". Always use a getAuth() lazy getter.
any type for cached instance — ReturnType<typeof betterAuth> with explicit generic is incompatible with the concrete config's narrower types. Use any for the caching variable.
export const dynamic = "force-dynamic" — Required on the auth catch-all route to prevent static optimization.
- Cannot use
export const { GET, POST } = toNextJsHandler(auth) — This destructuring evaluates at module scope. Must wrap in async handler functions instead.
toNextJsHandler — wraps BetterAuth for Next.js App Router catch-all route
- Catch-all route must be
[...all] — not [...nextauth] or anything else
BETTER_AUTH_SECRET is required — generate a strong random string
BETTER_AUTH_URL — must match your actual app URL (including port in dev)
- Auto-migration — BetterAuth creates its tables on first API call. No manual migration needed.
- Session cookies — BetterAuth uses httpOnly cookies by default, secure in production
NEXT_PUBLIC_BETTER_AUTH_URL — needed client-side for the auth client baseURL