Back to blog

Authentication with better-auth in Next.js: A First-Hand Setup

July 25, 20264 min read
Authentication with better-auth in Next.js: A First-Hand Setup

This site's own admin login runs on better-auth, so this is a first-hand account, not a tutorial written from the docs alone. Here's the exact shape of a single-admin, email/password setup: server config, the Next.js route handler, the client, and the two-layer route protection that actually keeps /admin locked down.

1. The server instance

// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "@/lib/prisma";

export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: "postgresql" }),

  emailAndPassword: { enabled: true, minPasswordLength: 8 },

  // A custom field on the user model, gating who counts as an admin.
  user: {
    additionalFields: {
      role: { type: "string", required: false, defaultValue: "admin", input: false },
    },
  },

  // Cache the session into a signed cookie so getSession() doesn't hit the
  // database on every navigation — only re-validates against it every 5 min.
  session: {
    cookieCache: { enabled: true, maxAge: 5 * 60 },
  },

  trustedOrigins: [process.env.NEXT_PUBLIC_AUTH_URL as string],
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
});

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

2. Mounting it — one catch-all route handler

better-auth exposes every endpoint (sign-in, sign-out, session, change-password, and anything a plugin adds) through one handler mounted at a single catch-all route.

// src/app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

3. The client

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

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL, // the app's origin only — no path suffix
});

export const { signIn, signOut, useSession } = authClient;
"use client";
import { useState } from "react";
import { signIn } from "@/lib/auth-client";

export default function AdminLoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const { error: signInError } = await signIn.email({ email, password });
    if (signInError) setError(signInError.message ?? "Sign-in failed");
    else window.location.href = "/admin";
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
      <input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
      {error && <p className="text-destructive text-sm">{error}</p>}
      <button type="submit">Sign in</button>
    </form>
  );
}

4. Protecting /admin — two layers, on purpose

One check alone is either too slow (a database round-trip on every navigation) or too weak (a cookie-existence check can't tell a valid session from a forged/expired cookie). This site uses both, each doing the job it's actually good at.

Layer

What it checks

Cost

Purpose

Middleware

Session cookie merely exists

Free — no DB call

Fast redirect-to-login for the obvious case (no cookie at all)

Admin layout (auth.api.getSession)

Cookie is actually valid + role === "admin"

One DB call at most every 5 min (cookie cache)

The real, authoritative gate

// src/middleware.ts — optimistic, cheap check
import { getSessionCookie } from "better-auth/cookies";

const ADMIN_PATH_RE = /^\/(en|fa)\/admin(?!\/login)(\/.*)?$/;

export default function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (ADMIN_PATH_RE.test(pathname)) {
    const sessionCookie = getSessionCookie(request);
    if (!sessionCookie) {
      const locale = pathname.split("/")[1];
      return NextResponse.redirect(new URL(`/${locale}/admin/login`, request.url));
    }
  }
  // ...next-intl routing continues below
}
// src/app/[locale]/admin/layout.tsx — the real gate
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session || session.user.role !== "admin") {
    redirect("/admin/login");
  }

  return <>{children}</>;
}

Middleware can't safely call auth.api.getSession() on every request — it runs on the Edge runtime and would mean a database round-trip before every single admin navigation renders anything. Checking cookie presence there, and doing the authoritative check once in the layout (backed by the 5-minute cookie cache), is what keeps the admin panel both fast and actually secure.

5. Seeding the one intended account

Since there's no public sign-up page, the one admin account is created by going through better-auth's own signUpEmail API in a seed script — this guarantees the password hash format always matches what sign-in verification expects, even across better-auth versions, instead of hand-rolling a hash.

// scripts/seed.ts
await auth.api.signUpEmail({
  body: {
    name: process.env.ADMIN_NAME!,
    email: process.env.ADMIN_EMAIL!,
    password: process.env.ADMIN_PASSWORD!,
  },
});

Checklist

  • baseURL on the client is the app's origin only — never append /api or any other path.

  • trustedOrigins on the server must exactly match the deployed origin (protocol, host, and www vs. apex all matter) or every cross-origin request gets a CORS rejection.

  • Use cookieCache so getSession() doesn't hit the database on every request that needs it.

  • Protect routes in two layers: a cheap cookie-presence check in middleware, and the real auth.api.getSession() check in a layout or page.

  • Create the single admin account through auth.api.signUpEmail() in a seed script, never by hand-inserting a row with a guessed hash format.