
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.
More posts

Recharts in Next.js + Tailwind: Theming and Custom Styling
Making Recharts feel native to a Tailwind design system: correct Client Component boundaries, wiring colors to CSS variables for automatic dark mode, custom tooltips/legends, and handling RTL locales.

Tiptap in Next.js: Editable Admin Panel, Read-Only Public Render
The exact Tiptap setup behind every post on this site: one shared extension list for the editor and generateHTML(), storing JSON in Postgres, syntax-highlighted code blocks, and uploaded (not base64) images.