writing/tutorial/2026/07
TutorialJul 8, 2026·30 min read

Better Auth with Next.js 15 App Router: Complete Authentication Tutorial

Build a complete authentication system in Next.js 15 using Better Auth — the framework-agnostic TypeScript auth library now backed by Vercel. Covers email/password, social OAuth, protected routes, and session management.

Better Auth is the authentication library that Vercel quietly acquired in July 2026, and for good reason. It is framework-agnostic, TypeScript-first, and ships with more features out of the box than any competitor — email/password, OAuth, two-factor auth, session management, and a growing plugin ecosystem. This tutorial walks you through building a production-ready authentication system for a Next.js 15 App Router project from scratch.

Prerequisites

Before starting, make sure you have:

  • Node.js 20 or newer installed
  • A PostgreSQL database (local via Docker or a hosted instance like Neon or Supabase)
  • Basic familiarity with Next.js App Router and TypeScript
  • A GitHub OAuth app for social login (we will create it during the tutorial)
  • pnpm installed globally (npm install -g pnpm)

What You'll Build

By the end of this tutorial you will have a Next.js 15 application with:

  • Email and password sign-up and sign-in
  • GitHub OAuth social login
  • JWT-based session management with automatic refresh
  • Server-side session access in React Server Components
  • Middleware-based route protection
  • A sign-out flow with redirect

Step 1: Create the Next.js 15 Project

Start a fresh Next.js 15 project with the App Router and TypeScript:

pnpm create next-app@latest my-auth-app --typescript --tailwind --eslint --app --src-dir=false --import-alias="@/*"
cd my-auth-app

When the scaffolding completes, open the project in your editor.

Step 2: Install Better Auth and Dependencies

Install Better Auth along with Drizzle ORM (for the database schema) and the PostgreSQL client:

pnpm add better-auth drizzle-orm @auth/drizzle-adapter
pnpm add pg
pnpm add -D drizzle-kit @types/pg

Also create your .env.local with the required environment variables. Better Auth needs a secret for signing sessions:

# Generate a strong secret
openssl rand -base64 32

Then add the following to .env.local:

# Database
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
 
# Better Auth
BETTER_AUTH_SECRET=your-generated-secret-here
BETTER_AUTH_URL=http://localhost:3000
 
# GitHub OAuth (Step 11)
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

Step 3: Set Up the Database with Drizzle ORM

Create the Drizzle configuration file at the root of your project:

// drizzle.config.ts
import type { Config } from "drizzle-kit";
 
export default {
  schema: "./lib/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
} satisfies Config;

Next, create the database connection:

// lib/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL!,
});
 
export const db = drizzle(pool);

Better Auth can generate its own schema automatically. Run the CLI to produce the migration files:

pnpm dlx better-auth generate --output ./lib/db/schema.ts

This creates the user, session, account, and verification tables. Then apply the migration:

pnpm drizzle-kit push

Step 4: Configure the Better Auth Server

Create the central auth configuration file. This is where you wire up the database, enable providers, and configure options:

// lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/lib/db";
 
export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: false, // set to true in production
    minPasswordLength: 8,
  },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days in seconds
    updateAge: 60 * 60 * 24,      // refresh if older than 1 day
  },
});

The betterAuth function returns a fully configured auth instance that handles all cryptography, token rotation, and database interactions for you.

Step 5: Create the API Route Handler

Better Auth uses a catch-all API route to handle all authentication endpoints. Create this file:

// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
 
export const { GET, POST } = toNextJsHandler(auth);

This single file handles all of the following endpoints automatically:

  • POST /api/auth/sign-up/email
  • POST /api/auth/sign-in/email
  • POST /api/auth/sign-out
  • GET /api/auth/session
  • GET /api/auth/callback/github
  • And more

Step 6: Initialize the Auth Client

The client instance wraps all authentication actions for use in React components. Create it once and import it wherever you need:

// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
 
export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
});
 
// Named exports for convenience
export const {
  signIn,
  signUp,
  signOut,
  useSession,
  getSession,
} = authClient;

Also add NEXT_PUBLIC_APP_URL=http://localhost:3000 to .env.local.

Step 7: Build the Sign-Up Page

Create a sign-up page that collects the user's name, email, and password:

// app/(auth)/sign-up/page.tsx
"use client";
 
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { signUp } from "@/lib/auth-client";
 
export default function SignUpPage() {
  const router = useRouter();
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
 
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setLoading(true);
    setError(null);
 
    const data = new FormData(e.currentTarget);
    const name = data.get("name") as string;
    const email = data.get("email") as string;
    const password = data.get("password") as string;
 
    const { error: signUpError } = await signUp.email({
      name,
      email,
      password,
      callbackURL: "/dashboard",
    });
 
    if (signUpError) {
      setError(signUpError.message ?? "Sign-up failed. Please try again.");
      setLoading(false);
      return;
    }
 
    router.push("/dashboard");
  }
 
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="w-full max-w-md bg-white rounded-2xl shadow p-8">
        <h1 className="text-2xl font-bold mb-6">Create your account</h1>
 
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm font-medium mb-1">Name</label>
            <input
              name="name"
              type="text"
              required
              className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium mb-1">Email</label>
            <input
              name="email"
              type="email"
              required
              className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium mb-1">Password</label>
            <input
              name="password"
              type="password"
              required
              minLength={8}
              className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
          </div>
 
          {error && (
            <p className="text-red-600 text-sm">{error}</p>
          )}
 
          <button
            type="submit"
            disabled={loading}
            className="w-full bg-blue-600 text-white rounded-lg py-2 font-medium hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? "Creating account…" : "Sign Up"}
          </button>
        </form>
 
        <p className="mt-4 text-sm text-center text-gray-600">
          Already have an account?{" "}
          <Link href="/sign-in" className="text-blue-600 hover:underline">
            Sign in
          </Link>
        </p>
      </div>
    </div>
  );
}

Step 8: Build the Sign-In Page

The sign-in page follows the same pattern. It also includes the GitHub OAuth button:

// app/(auth)/sign-in/page.tsx
"use client";
 
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { signIn } from "@/lib/auth-client";
 
export default function SignInPage() {
  const router = useRouter();
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
 
  async function handleEmailSignIn(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setLoading(true);
    setError(null);
 
    const data = new FormData(e.currentTarget);
 
    const { error: signInError } = await signIn.email({
      email: data.get("email") as string,
      password: data.get("password") as string,
      callbackURL: "/dashboard",
    });
 
    if (signInError) {
      setError(signInError.message ?? "Invalid credentials.");
      setLoading(false);
      return;
    }
 
    router.push("/dashboard");
  }
 
  async function handleGitHubSignIn() {
    await signIn.social({
      provider: "github",
      callbackURL: "/dashboard",
    });
  }
 
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="w-full max-w-md bg-white rounded-2xl shadow p-8">
        <h1 className="text-2xl font-bold mb-6">Sign in to your account</h1>
 
        <button
          onClick={handleGitHubSignIn}
          className="w-full flex items-center justify-center gap-2 border rounded-lg py-2 mb-4 hover:bg-gray-50"
        >
          <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
            <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
          </svg>
          Continue with GitHub
        </button>
 
        <div className="relative my-4">
          <div className="absolute inset-0 flex items-center">
            <span className="w-full border-t" />
          </div>
          <div className="relative flex justify-center text-sm">
            <span className="bg-white px-2 text-gray-500">or</span>
          </div>
        </div>
 
        <form onSubmit={handleEmailSignIn} className="space-y-4">
          <div>
            <label className="block text-sm font-medium mb-1">Email</label>
            <input
              name="email"
              type="email"
              required
              className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium mb-1">Password</label>
            <input
              name="password"
              type="password"
              required
              className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
          </div>
 
          {error && (
            <p className="text-red-600 text-sm">{error}</p>
          )}
 
          <button
            type="submit"
            disabled={loading}
            className="w-full bg-blue-600 text-white rounded-lg py-2 font-medium hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? "Signing in…" : "Sign In"}
          </button>
        </form>
 
        <p className="mt-4 text-sm text-center text-gray-600">
          No account yet?{" "}
          <Link href="/sign-up" className="text-blue-600 hover:underline">
            Sign up
          </Link>
        </p>
      </div>
    </div>
  );
}

Step 9: Protect Routes with Middleware

Use Next.js middleware to redirect unauthenticated users away from protected pages. Better Auth ships a helper that reads the session cookie without a database round-trip:

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
 
export async function middleware(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);
  const { pathname } = request.nextUrl;
 
  // Redirect authenticated users away from auth pages
  if (sessionCookie && ["/sign-in", "/sign-up"].includes(pathname)) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }
 
  // Protect dashboard routes
  if (!sessionCookie && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/sign-in", "/sign-up"],
};

The getSessionCookie helper is intentionally lightweight — it reads the cookie value without calling the database, so the middleware runs in under 1ms at the edge.

Step 10: Access the Session in Server Components

In any React Server Component, you can fetch the full session (user + metadata) from the database:

// 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 (
    <main className="min-h-screen p-8">
      <h1 className="text-3xl font-bold">
        Welcome, {session.user.name}
      </h1>
      <p className="text-gray-500 mt-2">{session.user.email}</p>
    </main>
  );
}

The auth.api.getSession call verifies the session token against the database and returns the full user object, including any fields you added to the schema.

Step 11: Add GitHub OAuth

You already configured GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in Step 4. Now create the GitHub OAuth application:

  1. Go to GitHub SettingsDeveloper settingsOAuth AppsNew OAuth App
  2. Set Homepage URL to http://localhost:3000
  3. Set Authorization callback URL to http://localhost:3000/api/auth/callback/github
  4. Copy the Client ID and generate a Client secret
  5. Paste both values into your .env.local

The GitHub sign-in button you added in Step 8 already calls signIn.social({ provider: "github" }). Better Auth handles the entire OAuth redirect and token exchange flow for you.

Step 12: Add the Sign-Out Button

Add a sign-out button to your dashboard or navigation header:

// components/SignOutButton.tsx
"use client";
 
import { useRouter } from "next/navigation";
import { signOut } from "@/lib/auth-client";
 
export function SignOutButton() {
  const router = useRouter();
 
  async function handleSignOut() {
    await signOut({
      fetchOptions: {
        onSuccess: () => {
          router.push("/sign-in");
        },
      },
    });
  }
 
  return (
    <button
      onClick={handleSignOut}
      className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-red-600"
    >
      Sign out
    </button>
  );
}

Use it in your dashboard layout:

// app/dashboard/layout.tsx
import { SignOutButton } from "@/components/SignOutButton";
 
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      <nav className="border-b px-8 py-4 flex justify-between items-center">
        <span className="font-semibold">My App</span>
        <SignOutButton />
      </nav>
      {children}
    </div>
  );
}

Step 13: Access the Session in Client Components

For client-side components that need to react to auth state, use the useSession hook:

// components/UserAvatar.tsx
"use client";
 
import { useSession } from "@/lib/auth-client";
 
export function UserAvatar() {
  const { data: session, isPending } = useSession();
 
  if (isPending) {
    return <div className="w-8 h-8 rounded-full bg-gray-200 animate-pulse" />;
  }
 
  if (!session) {
    return null;
  }
 
  return (
    <div className="flex items-center gap-2">
      {session.user.image ? (
        <img
          src={session.user.image}
          alt={session.user.name}
          className="w-8 h-8 rounded-full"
        />
      ) : (
        <div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
          {session.user.name.charAt(0).toUpperCase()}
        </div>
      )}
      <span className="text-sm font-medium">{session.user.name}</span>
    </div>
  );
}

The useSession hook keeps the session in sync with the server — if the session expires or the user signs out in another tab, the hook updates automatically.

Testing Your Implementation

Start the development server and verify each flow:

pnpm dev

Email sign-up: Navigate to http://localhost:3000/sign-up, fill in the form, and confirm you are redirected to /dashboard after submission.

Sign-out: Click the sign-out button and verify you land on /sign-in.

Sign-in: Return to http://localhost:3000/sign-in and log in with the credentials you just created.

Route protection: While signed out, navigate directly to http://localhost:3000/dashboard — you should be redirected to /sign-in.

GitHub OAuth: Click Continue with GitHub, authorize the app, and confirm you are redirected back to /dashboard as a signed-in user.

Troubleshooting

BETTER_AUTH_SECRET is missing: The auth server throws on startup if the secret is not set. Ensure .env.local is loaded and the variable name matches exactly.

OAuth callback URL mismatch: GitHub returns a redirect_uri_mismatch error if the callback URL in your GitHub OAuth app settings does not match exactly. For local development use http://localhost:3000/api/auth/callback/github.

Session not refreshing: By default, Better Auth refreshes the session only if it is older than updateAge seconds. In development you can set updateAge: 0 to force a refresh on every request.

Drizzle push fails: Run pnpm drizzle-kit generate first to create the migration files, then pnpm drizzle-kit push. If the database already has conflicting tables, drop them first.

TypeScript errors on auth.api: Ensure you are importing auth from @/lib/auth (the server-side instance), not from @/lib/auth-client (the client instance). The two have different type signatures.

Next Steps

  • Enable email verification: Set requireEmailVerification: true in emailAndPassword and implement sendVerificationEmail with a transactional email service like Resend or Mailgun.
  • Add Two-Factor Auth: Better Auth has a built-in 2FA plugin. Install @better-auth/2fa and add it to the plugins array in your auth config.
  • Role-based access control: Use the @better-auth/rbac plugin to define roles and permissions directly in your auth config.
  • Extend the user schema: Add custom fields to the user table by passing user.additionalFields to betterAuth.
  • Deploy to Vercel: Set all environment variables in your Vercel project settings. Better Auth works out of the box with Vercel's serverless functions and Edge Runtime.

Conclusion

You now have a full-featured authentication system built on Better Auth and Next.js 15. The library handled all the hard parts — secure password hashing, session token rotation, OAuth state verification, and CSRF protection — letting you focus on your application's unique features. As Vercel continues investing in Better Auth post-acquisition, expect tighter integration with the Vercel ecosystem and even faster adoption across the TypeScript community.