treeru.com

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.

FeatureNextAuth (Auth.js)Better Auth
TypeScript SupportPartial (complex types)Full type inference
Session MethodJWT / DB sessionsCookie-based sessions
CustomizationCallback chains (complex)Intuitive plugin system
Role ManagementMust implement manuallyBuilt-in support
Social Login60+ providersMajor providers
CommunityVery largeGrowing

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-auth

Server-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 SettingRecommended ValueDescription
expiresIn7 daysSession expiration time
updateAge24 hoursAuto-refresh interval
cookieNameDefaultSession 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 ItemBetter Auth DefaultAdditional Configuration
Password Hashingscrypt (automatic)Custom hashing function supported
CSRF ProtectionBuilt-inSameSite cookie settings
Rate LimitingBasicStrengthen in middleware (recommended)
Session Fixation PreventionAutomaticSession regenerated on login
Password PolicyMinimum length onlyAdd 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.