Implementing Next.js Authentication with Better Auth — Sessions, Admin Roles, and SQLite
When building authentication in Next.js, most developers reach for NextAuth (Auth.js). But if you've been frustrated by v5 migration complexity and customization pain points, Better Auth deserves serious consideration. It offers fully type-safe APIs, intuitive session management, and built-in role-based access control. Here's a complete walkthrough of building a production-ready authentication system.
Better Auth vs NextAuth — When to Choose Which
NextAuth has the larger community and broader provider support, but Better Auth was designed to solve its specific pain points. The choice depends on your project's primary authentication needs.
| Feature | NextAuth (Auth.js) | Better Auth |
|---|---|---|
| TypeScript Support | Partial (complex types) | Full type inference |
| Session Method | JWT / DB sessions | Cookie-based sessions |
| Customization | Callback chains (complex) | Intuitive plugin system |
| Role Management | Must implement manually | Built-in support |
| Social Login | 60+ providers | Major providers |
| Community | Very large | Growing |
If your primary need is email/password authentication with admin roles, choose Better Auth. If you need 60+ social login providers and a massive community ecosystem, NextAuth remains the stronger choice. For projects where TypeScript type safety and clean APIs are priorities, Better Auth's architecture is noticeably superior.
Initial Setup — Server and Client Configuration
Better Auth separates server-side and client-side instances. The server handles authentication logic and database operations, while the client provides React hooks and helper functions. Combined with Drizzle ORM and SQLite, the setup is straightforward.
# Install the package
npm install better-authServer-side configuration (lib/auth.ts):
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Refresh every 24 hours
},
});Client-side configuration (lib/auth-client.ts):
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_SITE_URL,
});
export const { useSession, signIn, signUp, signOut } = authClient;The separation is clean: the server instance manages database operations and session creation, while the client instance provides React hooks like useSession for accessing session state in components.
Email/Password Authentication — Signup and Login
Registration and login are handled by calling signUp.email and signIn.email from the client. Better Auth automatically handles password hashing (scrypt), session creation, and cookie management.
// Signup example
const handleSignUp = async () => {
const result = await signUp.email({
email: "user@example.com",
password: "securePassword123",
name: "John Doe",
});
if (result.error) {
console.error(result.error.message); // e.g., duplicate email
} else {
router.push("/dashboard"); // Auto-logged in after signup
}
};
// Login example
const handleSignIn = async () => {
const result = await signIn.email({
email: "user@example.com",
password: "securePassword123",
});
if (result.error) {
setError("Invalid email or password.");
} else {
router.push("/dashboard");
}
};After successful signup, users are automatically logged in — no separate login step required. The API returns typed error objects for handling duplicate emails, invalid passwords, and other failure cases.
Session Management — Server and Client Patterns
Better Auth uses cookie-based sessions. In server components, access the session via headers(). In client components, use the useSession hook.
Server component session check:
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/auth/login");
}
return <div>Welcome, {session.user.name}</div>;
}Client component session check:
"use client";
import { useSession, signOut } from "@/lib/auth-client";
export default function UserMenu() {
const { data: session, isPending } = useSession();
if (isPending) return <div>Loading...</div>;
if (!session) return <Link href="/auth/login">Login</Link>;
return (
<div>
<span>{session.user.name}</span>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}| Session Setting | Recommended Value | Description |
|---|---|---|
| expiresIn | 7 days | Session expiration time |
| updateAge | 24 hours | Auto-refresh interval |
| cookieName | Default | Session cookie name (customizable) |
Admin Role Separation — Middleware Pattern
To distinguish regular users from administrators, add a role field to the user table and create a verification middleware for API routes and pages.
// lib/admin-auth.ts — Admin verification utility
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
export async function requireAdmin() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session || session.user.role !== "admin") {
return null;
}
return session;
}
// For use in API routes
export async function adminGuard() {
const session = await requireAdmin();
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 403 }
);
}
return session;
}// Admin-only API route example
// app/api/admin/members/route.ts
import { adminGuard } from "@/lib/admin-auth";
export async function GET() {
const session = await adminGuard();
if (session instanceof NextResponse) return session;
// Admin-only logic
const members = await db.select().from(users);
return NextResponse.json({ members });
}Critical security note: Admin accounts must be created exclusively through seed scripts that write directly to the database. Never allow admin role assignment through the public signup flow. The registration process should always default the role to "user" — no exceptions.
Security Hardening — What Better Auth Provides and What You Must Add
Better Auth handles several security concerns by default, but production deployments require additional hardening. Here's what's built in and what needs manual configuration.
| Security Item | Better Auth Default | Additional Configuration |
|---|---|---|
| Password Hashing | scrypt (automatic) | Custom hashing function supported |
| CSRF Protection | Built-in | SameSite cookie settings |
| Rate Limiting | Basic | Strengthen in middleware (recommended) |
| Session Fixation Prevention | Automatic | Session regenerated on login |
| Password Policy | Minimum length only | Add complexity validation (recommended) |
AUTH_SECRET must be a strong random string. This environment variable encrypts session data. If exposed, session forgery becomes possible. Generate it with openssl rand -base64 32 and never commit it to version control.
Summary
Server and client separation: Configure auth.ts for server-side logic and auth-client.ts for React hooks. This clean separation makes the codebase easy to navigate and maintain.
Email/password authentication: Use signUp.email and signIn.email — Better Auth handles password hashing, session creation, and cookie management automatically.
Session access patterns: Server components use auth.api.getSession with headers, client components use the useSession hook. Both return fully typed session objects.
Admin role separation: Add a role field, verify it in middleware, and create admin accounts exclusively through seed scripts. Never expose admin role assignment in public signup flows.
Security essentials: Better Auth provides scrypt hashing, CSRF protection, and session fixation prevention by default. You must add rate limiting in middleware, password complexity validation, and keep AUTH_SECRET out of version control.