· nextjs / better-auth / authentication
How to add Better Auth to a Next.js app — step by step
A practical walkthrough of installing Better Auth v1.6.14 with Drizzle ORM, Neon Postgres, and GitHub OAuth on Next.js 15 App Router — including every failure mode worth knowing.
By Ethan
1,803 words · 10 min read
Better Auth is the library you reach for when you’ve outgrown NextAuth’s magic string config but don’t want to write your own session management. This tutorial wires it up end-to-end: GitHub OAuth, email/password, Drizzle + Neon for storage, and middleware-based route protection. All on Next.js 15 App Router. If you’re still deciding between Better Auth and Clerk, see our Better Auth vs Clerk comparison first.
Stack: Better Auth v1.6.14 · Next.js 15 · Drizzle ORM · Neon (serverless Postgres) · GitHub OAuth
Who this is for
Developers building a Next.js 15 App Router project who want session-based auth with a real database and at least one OAuth provider. If you’re on the Pages Router or happy with a third-party auth SaaS, this tutorial isn’t the right fit.
Prerequisites
- Node.js 20+
- An existing Next.js 15 App Router project (
npx create-next-app@latestis fine) - A Neon database (free tier works — you get a
DATABASE_URLfrom the dashboard; see best Postgres hosts for small SaaS if you haven’t picked one yet) - A GitHub account to register an OAuth App
Step 1: Install packages
npm install better-auth@^1.6.14 @better-auth/drizzle-adapter drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit
What each package does:
better-auth— core auth library@better-auth/drizzle-adapter— connects Better Auth’s session/user tables to Drizzledrizzle-orm+@neondatabase/serverless— Drizzle + Neon driver for serverless Postgresdrizzle-kit— CLI for generating and running migrations
Failure mode: Installing
better-authwithout the adapter and trying to pass a raw Drizzle client gives a cryptic “adapter not found” error at runtime. Always pair them.
Step 2: Set environment variables
Create or update .env.local:
# Required by Better Auth
BETTER_AUTH_SECRET="your-random-secret-at-least-32-chars"
BETTER_AUTH_URL="http://localhost:3000" # production: your actual URL
# Neon connection string (copy from Neon dashboard → Connection details)
DATABASE_URL="postgresql://user:[email protected]/neondb?sslmode=require"
# GitHub OAuth App credentials (see Step 7)
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"
BETTER_AUTH_SECRET is used to sign sessions. Generate one with:
openssl rand -base64 32
Failure mode: Forgetting to update
BETTER_AUTH_URLin production is the most common deployment bug. Better Auth uses it to construct OAuth callback URLs — if it still points tolocalhost, GitHub’s callback will redirect there and silently fail.
Step 3: Create lib/auth.ts (server-side instance)
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from '@better-auth/drizzle-adapter';
import { nextCookies } from 'better-auth/next-js';
import { db } from '@/lib/db'; // your Drizzle db instance
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
}),
plugins: [nextCookies()],
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
emailAndPassword: {
enabled: true,
},
});
The nextCookies() plugin is what makes cookies work in Server Actions and Route Handlers. Without it, auth.api.getSession() returns null inside actions even when the user is logged in.
Failure mode: Importing
authon the client side — in a component oruse clientfile — will throw because it imports Node.js-only modules. Keep this file server-only. If you need to share types, import them frombetter-authdirectly, not fromlib/auth.ts.
Step 4: Create lib/auth-client.ts (client-side instance)
// 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;
Add NEXT_PUBLIC_APP_URL to .env.local if you want the base URL configurable per environment:
NEXT_PUBLIC_APP_URL="http://localhost:3000"
Failure mode: Using
process.env.BETTER_AUTH_URLhere instead of aNEXT_PUBLIC_variable. Next.js strips non-public env vars at build time for client bundles. The client will silently fall back to a wrong URL.
Step 5: Add the route handler
Better Auth needs a single catch-all route to handle all auth flows (OAuth callbacks, session APIs, credential endpoints).
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);
That’s the entire file. toNextJsHandler wraps Better Auth’s fetch handler to match Next.js’s Route Handler interface.
Failure mode: Placing this file at
app/api/auth/route.ts(without[...all]) captures only/api/auth— every OAuth callback and sub-route 404s. The[...all]catch-all is required.
Step 6: Generate the database schema and run migrations
Better Auth generates its own Drizzle schema for users, sessions, and accounts. If Drizzle is new to you, our Drizzle ORM + Postgres setup guide covers the configuration in more depth. Run:
npx auth@latest generate
This writes a schema file (typically lib/schema.ts or similar — the CLI tells you). Then run the migration:
npx drizzle-kit push
Or, if you prefer migration files over push:
npx drizzle-kit generate
npx drizzle-kit migrate
Failure mode: Running your app before the migration. Better Auth will try to write to tables that don’t exist and throw a Postgres error that mentions
"session"or"user"table not found. Always migrate first.
Failure mode: If you’ve customized your Drizzle schema and the generated Better Auth schema overlaps table names, you’ll get a conflict. Rename conflicting tables in your existing schema, not in Better Auth’s generated output — regenerating overwrites Better Auth’s file.
Step 7: Configure the GitHub OAuth App
- Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
- Fill in:
- Application name: anything
- Homepage URL:
http://localhost:3000 - Authorization callback URL:
http://localhost:3000/api/auth/callback/github
- Click Register application, then copy the Client ID and generate a Client Secret
- Paste both into
.env.local(GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET)
For production, register a separate OAuth App (or update the existing one) with your real domain.
Failure mode: Using a GitHub App instead of an OAuth App. GitHub Apps don’t return the user’s email by default — you have to add the
user:emailscope explicitly, orgithub.profile.emailisnulland the user can’t log in. If your existing GitHub integration is an App, either switch to an OAuth App or add the scope.
Failure mode: Forgetting to update the callback URL when you deploy. GitHub rejects the OAuth handshake if the callback URL in the request doesn’t match what’s registered.
Step 8: Build the client-side auth UI
Better Auth exposes React hooks for session state and methods for all auth flows.
Session state
// components/UserNav.tsx
'use client';
import { useSession, signOut } from '@/lib/auth-client';
export function UserNav() {
const { data: session, isPending } = useSession();
if (isPending) return <span>Loading…</span>;
if (!session) return null;
return (
<div>
<span>{session.user.email}</span>
<button onClick={() => signOut()}>Sign out</button>
</div>
);
}
Sign in with GitHub
'use client';
import { signIn } from '@/lib/auth-client';
export function GitHubSignIn() {
return (
<button
onClick={() => signIn.social({ provider: 'github', callbackURL: '/dashboard' })}
>
Continue with GitHub
</button>
);
}
Email/password sign-up and sign-in
'use client';
import { signIn, signUp } from '@/lib/auth-client';
import { useState } from 'react';
export function AuthForm({ mode }: { mode: 'signin' | 'signup' }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const fn = mode === 'signup' ? signUp.email : signIn.email;
const { error } = await fn({ email, password, callbackURL: '/dashboard' });
if (error) setError(error.message ?? 'Something went wrong');
};
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
{error && <p>{error}</p>}
<button type="submit">{mode === 'signup' ? 'Create account' : 'Sign in'}</button>
</form>
);
}
Failure mode: Not handling the
errorreturn fromsignIn.email/signUp.email. These functions don’t throw — they return{ data, error }. Ignoringerrormeans the UI never shows feedback when credentials are wrong.
Step 9: Read 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('/login');
return <h1>Hello, {session.user.email}</h1>;
}
headers() is async in Next.js 15 — you must await it. This is a breaking change from Next.js 14 where headers() was synchronous.
Failure mode: Calling
auth.api.getSession({ headers: headers() })withoutawait. TypeScript may not catch this if your tsconfig is loose — you’ll get aPromise<ReadonlyHeaders>instead ofReadonlyHeaders, and Better Auth can’t read the cookies. The session returnsnullevery time and users get redirect-looped to/login.
Failure mode: Calling
getSession()withoutnextCookies()in your auth plugins (Step 3). The session will always benullin Server Components and Server Actions even when cookies are present.
Step 10: Protect routes in middleware
// middleware.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
getSessionCookie reads the cookie without making a database call — safe for Edge Runtime. If you need full session validation (check session expiry in the database, verify user roles), use auth.api.getSession() instead, but switch the runtime to Node.js:
// middleware.ts (Node.js runtime, full validation)
export const runtime = 'nodejs';
Failure mode: Using
getCookieCache()from Better Auth in Edge Runtime. It isn’t edge-safe and throws at runtime. UsegetSessionCookie(request)frombetter-auth/cookiesfor Edge, or move to Node.js runtime for the full API.
Security: Keep your Next.js version at ≥15.2.3. CVE-2025-29927 describes a middleware bypass vulnerability in earlier versions — an attacker can skip your middleware entirely by crafting a request with
x-middleware-subrequestheaders. Better Auth’s session check in middleware is your first line of route protection; on a vulnerable Next.js version, it can be bypassed.
A note on the v1.6.0 freshAge change
Better Auth v1.6.0 changed how freshAge (session freshness for step-up auth) is calculated. Previously it measured from last activity; as of v1.6.0 it measures from session creation time. If you’re upgrading from v1.5.x and use step-up auth flows (re-prompting the user for their password before sensitive actions), review your freshAge value — a session started days ago may now appear stale even if the user was active recently.
Deploying to Vercel
Vercel picks up .env.local automatically in development. For production, set the same env vars in the Vercel dashboard under Project Settings → Environment Variables:
BETTER_AUTH_SECRETBETTER_AUTH_URL(set to your production domain)DATABASE_URLGITHUB_CLIENT_IDGITHUB_CLIENT_SECRETNEXT_PUBLIC_APP_URL
Neon’s serverless driver works with Vercel’s Edge Functions and Node.js runtimes without any extra config.