Next.js

Better Auth with Next.js 16: The Complete Authentication Guide

Svet Halachev Dec 20, 2025 7 min read
Better Auth with Next.js 16: The Complete Authentication Guide

Authentication in Next.js has always been a pain point. NextAuth (now Auth.js) works but feels overly complex for simple use cases. Rolling your own auth is a security risk. And third-party services like Auth0 add cost and vendor lock-in.

Better Auth changes the equation. It's a TypeScript-first authentication framework that's genuinely easy to set up, fully type-safe, and gives you complete control over your auth logic. Combined with Next.js 16's new features, you get a modern auth stack that just works.

Why Better Auth?

Before diving into the setup, here's why Better Auth is worth your attention:

  • 5-minute setup — Not marketing speak, it's actually that fast

  • Full TypeScript support — Real type safety, not just type definitions

  • Framework-agnostic — Works with React, Vue, Svelte, and more

  • Plugin ecosystem — Two-factor auth, organizations, and advanced features built-in

  • You own it — No vendor lock-in, runs on your infrastructure

The library has 24k+ GitHub stars and endorsements from Guillermo Rauch (Vercel's CEO). It's production-ready.

Installation

Start by installing Better Auth and its peer dependencies:

npm install better-auth

You'll also need a database adapter. Better Auth supports Prisma, Drizzle, and raw SQL. For this guide, we'll use Prisma:

npm install @prisma/client prisma
npx prisma init

Database Setup

Add the required auth tables to your Prisma schema:

// prisma/schema.prisma

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified DateTime?
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
  sessions      Session[]
  accounts      Account[]
}

model Session {
  id        String   @id @default(cuid())
  userId    String
  token     String   @unique
  expiresAt DateTime
  ipAddress String?
  userAgent String?
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Account {
  id           String  @id @default(cuid())
  userId       String
  provider     String
  providerAccountId String
  accessToken  String?
  refreshToken String?
  expiresAt    DateTime?
  user         User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Verification {
  id         String   @id @default(cuid())
  identifier String
  token      String
  expiresAt  DateTime

  @@unique([identifier, token])
}

Run the migration:

npx prisma migrate dev --name add-auth-tables

Auth Configuration

Create your auth configuration file:

// lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql", // or "mysql", "sqlite"
  }),
  emailAndPassword: {
    enabled: true,
  },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
  plugins: [
    nextCookies(), // Handles cookies in Server Actions automatically
  ],
});

export type Session = typeof auth.$Infer.Session;

The nextCookies plugin is essential for Next.js. It automatically handles cookie setting in Server Actions and Server Components.

API Route Handler

Create the catch-all route that handles all auth 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);

This single file handles sign-in, sign-up, sign-out, OAuth callbacks, and session management.

Client Setup

Create the auth client for use in React components:

// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL,
});

export const {
  signIn,
  signUp,
  signOut,
  useSession
} = authClient;

Building the Sign-In Page

Here's a complete sign-in component:

// app/sign-in/page.tsx
"use client";

import { signIn } from "@/lib/auth-client";
import { useState } from "react";
import { useRouter } from "next/navigation";

export default function SignInPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError("");

    const { error } = await signIn.email({
      email,
      password,
    });

    if (error) {
      setError(error.message);
      setLoading(false);
      return;
    }

    router.push("/dashboard");
  };

  const handleGitHubSignIn = async () => {
    await signIn.social({
      provider: "github",
      callbackURL: "/dashboard",
    });
  };

  return (
    <div className="max-w-md mx-auto mt-20 p-6">
      <h1 className="text-2xl font-bold mb-6">Sign In</h1>

      <form onSubmit={handleSubmit} className="space-y-4">
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="w-full p-3 border rounded"
          required
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="w-full p-3 border rounded"
          required
        />

        {error && <p className="text-red-500 text-sm">{error}</p>}

        <button
          type="submit"
          disabled={loading}
          className="w-full p-3 bg-blue-600 text-white rounded"
        >
          {loading ? "Signing in..." : "Sign In"}
        </button>
      </form>

      <div className="mt-6">
        <button
          onClick={handleGitHubSignIn}
          className="w-full p-3 bg-gray-800 text-white rounded"
        >
          Continue with GitHub
        </button>
      </div>
    </div>
  );
}

Server-Side Session Access

Access sessions in Server Components without any client-side JavaScript:

// 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 className="p-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>
      <p className="mt-4">Welcome, {session.user.name || session.user.email}</p>
    </div>
  );
}

Route Protection with Next.js 16 Proxy

Next.js 16 replaces middleware.ts with proxy.ts. Here's how to protect routes:

// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export async function proxy(request: NextRequest) {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};

For faster checks without database calls, use cookie validation:

// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export async function proxy(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);

  if (!sessionCookie) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"],
};

Adding Two-Factor Authentication

Better Auth makes 2FA straightforward with the twoFactor plugin:

// lib/auth.ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";

export const auth = betterAuth({
  // ... existing config
  plugins: [
    nextCookies(),
    twoFactor({
      issuer: "YourApp",
    }),
  ],
});

Then use it in your components:

// Enable 2FA for a user
const { data: totpURI } = await authClient.twoFactor.enable();

// Verify during sign-in
await signIn.email({
  email,
  password,
  twoFactorCode: userInputCode,
});

Environment Variables

Add these to your .env.local:

# App
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Database
DATABASE_URL="postgresql://..."

# OAuth Providers
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

# Auth Secret (generate with: openssl rand -base64 32)
BETTER_AUTH_SECRET=your_random_secret_key

Common Patterns

Sign Out Button

"use client";

import { signOut } from "@/lib/auth-client";
import { useRouter } from "next/navigation";

export function SignOutButton() {
  const router = useRouter();

  const handleSignOut = async () => {
    await signOut();
    router.push("/");
  };

  return (
    <button onClick={handleSignOut}>
      Sign Out
    </button>
  );
}

Session Hook in Client Components

"use client";

import { useSession } from "@/lib/auth-client";

export function UserMenu() {
  const { data: session, isPending } = useSession();

  if (isPending) return <div>Loading...</div>;
  if (!session) return <a href="/sign-in">Sign In</a>;

  return <span>Hello, {session.user.name}</span>;
}

What to Watch For

  • Migration from middleware: If upgrading from Next.js 15, rename middleware.ts to proxy.ts and the function from middleware to proxy

  • Cookie configuration: In production, ensure your domain and secure settings are correct

  • Database connections: Use connection pooling in serverless environments

Wrapping Up

Better Auth with Next.js 16 is a solid combination. You get type-safe authentication that's easy to understand, extend, and maintain. The new proxy system in Next.js 16 pairs well with Better Auth's session handling, and features like 2FA and organizations are just a plugin away.

Start with email/password auth, add OAuth providers as needed, and you'll have production-ready authentication running in under an hour.

Related Articles