Better Auth launched in late 2024 and hit 100K weekly downloads by early 2026 — one of the fastest-growing auth libraries in the Node.js ecosystem. It's TypeScript-first, framework-agnostic, self-hosted, and doesn't charge per user.
This guide covers a complete production setup: email/password auth, Google OAuth, session management with Drizzle ORM, and middleware for protected routes.
Why Better Auth is gaining traction
The auth library landscape in 2026 has three main options:
- Clerk — fully managed, excellent DX, $25/month at 10k MAU, scales to $825/month at 50k MAU
- Auth.js (NextAuth v5) — free, open source, but requires manual type augmentation for custom session fields and has a complex configuration model
- Better Auth — free, self-hosted, TypeScript-first with automatic type inference, code-first plugin model
Better Auth's main differentiator is type safety. Where Auth.js requires you to manually augment the Session type to add custom fields, Better Auth infers everything from your configuration:
// Auth.js — manual type augmentation required
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: 'admin' | 'user';
}
}
}
// Better Auth — types inferred automatically from your config
// No manual augmentation neededInstallation
npm install better-auth
npm install @better-auth/cli -DSet up environment variables:
BETTER_AUTH_SECRET=your-secret-at-least-32-chars
BETTER_AUTH_URL=http://localhost:3000 # your site URL
GOOGLE_CLIENT_ID=... # if using Google OAuth
GOOGLE_CLIENT_SECRET=...Auth configuration
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '@/lib/db';
import * as schema from '@/lib/schema';
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
user: schema.user,
session: schema.session,
account: schema.account,
verification: schema.verification,
},
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // set true in production with email provider
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;Database schema
Better Auth needs specific tables. You can generate them or define them manually.
# Auto-generate the schema additions
npx @better-auth/cli generateOr define them manually with Drizzle:
// lib/schema.ts
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').notNull().default(false),
image: text('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const session = pgTable('session', {
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
});
export const account = pgTable('account', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const verification = pgTable('verification', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at'),
updatedAt: timestamp('updated_at'),
});API route handler
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { POST, GET } = toNextJsHandler(auth);One file handles all auth routes: /api/auth/sign-in/email, /api/auth/sign-up/email, /api/auth/callback/google, /api/auth/sign-out, etc.
Auth client
// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
});
export const { signIn, signUp, signOut, useSession } = authClient;Sign up and sign in components
Sign up form
// app/(auth)/sign-up/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signUp } from '@/lib/auth-client';
export default function SignUpPage() {
const [error, setError] = useState('');
const router = useRouter();
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const { error } = await signUp.email({
email: formData.get('email') as string,
password: formData.get('password') as string,
name: formData.get('name') as string,
callbackURL: '/dashboard',
});
if (error) {
setError(error.message || 'Something went wrong');
} else {
router.push('/dashboard');
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input name="name" type="text" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
{error && <p className="text-red-500 text-sm">{error}</p>}
<button type="submit">Create account</button>
</form>
);
}Sign in form
// app/(auth)/sign-in/page.tsx
'use client';
import { useState } from 'react';
import { signIn } from '@/lib/auth-client';
export default function SignInPage() {
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const { error } = await signIn.email({
email: formData.get('email') as string,
password: formData.get('password') as string,
callbackURL: '/dashboard',
});
if (error) setError(error.message || 'Invalid credentials');
}
async function handleGoogleSignIn() {
await signIn.social({ provider: 'google', callbackURL: '/dashboard' });
}
return (
<div className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
{error && <p className="text-red-500 text-sm">{error}</p>}
<button type="submit">Sign in</button>
</form>
<button onClick={handleGoogleSignIn}>
Continue with Google
</button>
</div>
);
}Reading the session
In Server Components
// app/dashboard/page.tsx
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) redirect('/sign-in');
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>{session.user.email}</p>
</div>
);
}In Client Components
'use client';
import { useSession } from '@/lib/auth-client';
export function UserNav() {
const { data: session, isPending } = useSession();
if (isPending) return <div>Loading...</div>;
if (!session) return <a href="/sign-in">Sign in</a>;
return (
<div>
<span>{session.user.name}</span>
<button onClick={() => signOut()}>Sign out</button>
</div>
);
}Middleware for protected routes
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSessionFromRequest } from 'better-auth/next-js';
const protectedPaths = ['/dashboard', '/settings', '/api/user'];
const authPaths = ['/sign-in', '/sign-up'];
export async function middleware(request: NextRequest) {
const session = await getSessionFromRequest(request);
const { pathname } = request.nextUrl;
const isProtected = protectedPaths.some(path => pathname.startsWith(path));
const isAuthPath = authPaths.some(path => pathname.startsWith(path));
if (isProtected && !session) {
return NextResponse.redirect(new URL('/sign-in', request.url));
}
if (isAuthPath && session) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next|api/auth|favicon.ico|.*\\.).*)'],
};Better Auth vs Clerk vs Auth.js in 2026
| Better Auth | Clerk | Auth.js v5 | |
|---|---|---|---|
| Cost | Free (self-hosted) | $25/month at 10k MAU | Free |
| Type safety | Excellent (inferred) | Good | Requires manual augmentation |
| Setup time | ~30 min | ~10 min | ~45 min |
| Built-in UI | ❌ (better-auth-ui exists) | ✅ | ❌ |
| Control | Full | Limited | Full |
| Organizations | Plugin | Built-in | Plugin |
| SAML/SSO | Plugin (paid) | Enterprise plan | ❌ |
Choose Better Auth if you want full control, TypeScript-first DX, and don't want to pay per user.
Choose Clerk if you want the fastest setup and are okay with the cost at scale.
Choose Auth.js if you're maintaining an existing NextAuth v4 project or need maximum community resources.
The better-auth-ui package gives you pre-built sign-in, sign-up, and settings UI components that integrate directly with Better Auth. It's like Clerk's UI components but for self-hosted auth.
Common mistakes
Mistake 1: Not setting BETTER_AUTH_SECRET correctly The secret must be at least 32 characters and should be randomly generated:
openssl rand -base64 32Mistake 2: Using the wrong session reading method
In Server Components, use auth.api.getSession({ headers }). In Client Components, use useSession(). They're not interchangeable.
Mistake 3: Forgetting to run migrations after schema changes
Better Auth schema changes require Drizzle migrations. After any betterAuth() config change that affects the DB, run drizzle-kit generate && drizzle-kit migrate.
Mistake 4: Exposing the API route in middleware matcher
Don't match /api/auth/* in your middleware — Better Auth handles those routes itself. Add them to your exclusion list.
Related: Clerk vs Better Auth — Which to Choose in Next.js 2026 · Drizzle ORM Complete Guide · Clerk Authentication in Next.js 2026