Robust User Authentication with BetterAuth in NextJS Apps for Free
Authentication in Next.js? You’re either handing over your user data to a SaaS provider for convenience, or wrestling with clunky open-source libraries that feel like a full-time job. Both are compromises. Both are building on someone else's terms with centralized systems operating with a central failure point.
- Stop renting your front door.
- Stop relying on brittle, over-engineered solutions.
The real opportunity isn't to patch workflows. It's to build an entirely new, self-sovereign, automated foundation for your authentication. That's what Better-Auth delivers. It’s TypeScript-first, comprehensive, and gives you total control without the bloat of an external service.
Here is the blueprint for building a modern, type-safe authentication foundation in Next.js that you own and control.
The Foundation
Don't start building until you have the tools. This guide assumes you are executing on:
- Next.js (App Router is non-negotiable here)
- TypeScript (If you aren't using types, you're building on sand)
- Drizzle ORM (or Prisma, but Drizzle is faster)
- PostgreSQL (Neon, Supabase, or local)
Phase 1: Installation and Environment
First, we install the core engine.
npm install better-auth drizzle-orm @neondatabase/serverless
npm install -D drizzle-kitSet your environment variables immediately. Do not hardcode secrets.
# .env.local
BETTER_AUTH_SECRET=your_generated_secret_here
BETTER_AUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
DATABASE_URL=...Pro-tip: Generate a secure secret using:
openssl rand -base64 32Phase 2: The Database Schema
Legacy auth asks you to manually create tables and hope they match the library's internal logic. Better-Auth is smarter. It defines the schema for you.
If you are using Drizzle, your schema.ts file should look like this. This covers users, sessions, accounts (for social login), and verifications.
// src/db/schema.ts
import { pgTable, text, integer, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("emailVerified").notNull(),
image: text("image"),
createdAt: timestamp("createdAt").notNull(),
updatedAt: timestamp("updatedAt").notNull()
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt").notNull(),
updatedAt: timestamp("updatedAt").notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId").notNull().references(()=> user.id)
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId").notNull().references(()=> user.id),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt").notNull(),
updatedAt: timestamp("updatedAt").notNull()
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expiresAt").notNull(),
createdAt: timestamp("createdAt"),
updatedAt: timestamp("updatedAt")
});Run your migration. Build the tables.
Pro-tip: Generate a secure secret using:
npx drizzle-kit pushPhase 3: The Authority (Server Configuration)
This is the brain of the operation. We initialize Better-Auth with our database adapter and providers.
Create src/lib/auth.ts:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db"; // Your drizzle db instance
import * as schema from "@/db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: {
// Mapping schema to auth logic
user: schema.user,
session: schema.session,
account: schema.account,
verification: schema.verification,
}
}),
emailAndPassword: {
enabled: true
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
// Add Google, Discord, etc. here
},
});Phase 4: The Bridge (API Route)
We need to expose the auth endpoints so the client can talk to the server. Next.js App Router handles this via a catch-all route.
Create 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);That’s it. No complex handler logic. The library does the heavy lifting.
Phase 5: The Client Experience
Now, let's make it usable for the user. We need a client-side hook to interact with our sessions.
Create a robust client helper src/lib/auth-client.ts:
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL // the base url of your auth server
})
export const { signIn, signOut, useSession } = authClient;Usage Example:
Here is how you actually build a sign-in component.
"use client"
import { signIn, useSession } from "@/lib/auth-client"
export default function AuthComponent() {
const { data: session, isPending } = useSession();
if (session) {
return (
<div>
<p>Welcome back, {session.user.name}</p>
<button onClick={() => signOut()}>Sign Out</button>
</div>
)
}
return (
<div className="flex flex-col gap-2">
<button
onClick={async () => {
await signIn.social({ provider: "github" })
}}
>
Continue with GitHub
</button>
<button
onClick={async () => {
await signIn.email({
email: "builder@example.com",
password: "password123",
name: "The Builder"
})
}}
>
Sign In with Email
</button>
</div>
);
}Phase 6: The Guard (Middleware)
You can't trust the client. You must protect your routes at the edge.
Create middleware.ts:
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth";
export async function middleware(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"], // Protect your dashboard routes
};The Last Word
Legacy auth is a crutch. Managed auth is a tax.
Better-Auth gives you the foundation to build secure, scalable, and self-sovereign applications without reinventing the wheel. You have the database, the API, and the client hooks.
Now, stop configuring and start building.

