React
|stacknotice.com
13 min left|
0%
|2,600 words
React

Better Auth in Next.js 2026: The Complete Setup Guide

Better Auth reached 100K weekly downloads in 2026. Complete guide to setting it up in Next.js 15 with Drizzle ORM, email/password, OAuth, and session management.

C
Carlos Oliva
Software Developer
June 5, 202613 min read
Share:
Better Auth in Next.js 2026: The Complete Setup Guide

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 needed

Installation

npm install better-auth
npm install @better-auth/cli -D

Set 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 generate

Or 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 AuthClerkAuth.js v5
CostFree (self-hosted)$25/month at 10k MAUFree
Type safetyExcellent (inferred)GoodRequires manual augmentation
Setup time~30 min~10 min~45 min
Built-in UI❌ (better-auth-ui exists)
ControlFullLimitedFull
OrganizationsPluginBuilt-inPlugin
SAML/SSOPlugin (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.

Use Better Auth + better-auth-ui

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 32

Mistake 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

#nextjs#typescript#authentication#webdev#react
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.