· 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

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@latest is fine)
  • A Neon database (free tier works — you get a DATABASE_URL from 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 Drizzle
  • drizzle-orm + @neondatabase/serverless — Drizzle + Neon driver for serverless Postgres
  • drizzle-kit — CLI for generating and running migrations

Failure mode: Installing better-auth without 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_URL in production is the most common deployment bug. Better Auth uses it to construct OAuth callback URLs — if it still points to localhost, 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 auth on the client side — in a component or use client file — will throw because it imports Node.js-only modules. Keep this file server-only. If you need to share types, import them from better-auth directly, not from lib/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_URL here instead of a NEXT_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

  1. Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
  2. Fill in:
    • Application name: anything
    • Homepage URL: http://localhost:3000
    • Authorization callback URL: http://localhost:3000/api/auth/callback/github
  3. Click Register application, then copy the Client ID and generate a Client Secret
  4. 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:email scope explicitly, or github.profile.email is null and 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 error return from signIn.email / signUp.email. These functions don’t throw — they return { data, error }. Ignoring error means 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() }) without await. TypeScript may not catch this if your tsconfig is loose — you’ll get a Promise<ReadonlyHeaders> instead of ReadonlyHeaders, and Better Auth can’t read the cookies. The session returns null every time and users get redirect-looped to /login.

Failure mode: Calling getSession() without nextCookies() in your auth plugins (Step 3). The session will always be null in 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. Use getSessionCookie(request) from better-auth/cookies for 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-subrequest headers. 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_SECRET
  • BETTER_AUTH_URL (set to your production domain)
  • DATABASE_URL
  • GITHUB_CLIENT_ID
  • GITHUB_CLIENT_SECRET
  • NEXT_PUBLIC_APP_URL

Neon’s serverless driver works with Vercel’s Edge Functions and Node.js runtimes without any extra config.


References