Auth.js v5 with Next.js and Drizzle ORM: the complete setup guide
Auth.js v5 (formerly NextAuth.js) is a significant rewrite. The API is cleaner, but most tutorials still show v4 patterns — which won't work. This guide covers everything from OAuth setup to a Drizzle Postgres adapter, in a way that actually compiles.
What changed in v5
The big shifts:
- Single
auth.tsconfig file replaces the scattered[...nextauth]options -
auth()is universal — use it in Server Components, Route Handlers, and Proxy (formerly Middleware) -
handlersreplacesGET/POSTexport ceremony - The
middleware.tsconvention is nowproxy.tsin Next.js 16+
1. Install
npm install next-auth@beta @auth/drizzle-adapter drizzle-orm @neondatabase/serverless
npm install --save-dev drizzle-kit
2. Drizzle schema for Auth.js
Auth.js needs four tables: users, accounts, sessions, verification_tokens. Define them with Drizzle's pg-core:
// src/lib/db/schema.ts
import {
boolean, integer, pgTable, primaryKey, text, timestamp,
} from "drizzle-orm/pg-core";
import type { AdapterAccount } from "@auth/core/adapters";
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").unique(),
emailVerified: timestamp("email_verified", { mode: "date" }),
image: text("image"),
});
export const accounts = pgTable(
"accounts",
{
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("provider_account_id").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => [primaryKey({ columns: [account.provider, account.providerAccountId] })]
);
export const sessions = pgTable("sessions", {
sessionToken: text("session_token").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verification_tokens",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
);
3. Drizzle client (Neon serverless)
// src/lib/db/index.ts
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
4. Auth config
// src/auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/lib/db";
import { accounts, sessions, users, verificationTokens } from "@/lib/db/schema";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
providers: [
GitHub,
Google,
],
callbacks: {
// Expose user.id in the session — not available by default
session({ session, user }) {
session.user.id = user.id;
return session;
},
},
pages: {
signIn: "/sign-in",
},
});
// Augment the Session type to include id
declare module "next-auth" {
interface Session {
user: { id: string; name?: string | null; email?: string | null; image?: string | null; };
}
}
5. Route handler
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
One line. That's it.
6. Sign-in page with Server Actions
Auth.js v5 uses Server Actions for sign-in — no client-side JS required:
// src/app/(auth)/sign-in/page.tsx
import { signIn } from "@/auth";
export default function SignInPage({
searchParams,
}: {
searchParams: { callbackUrl?: string };
}) {
return (
<div>
<form
action={async () => {
"use server";
await signIn("github", {
redirectTo: searchParams.callbackUrl ?? "/dashboard",
});
}}
>
<button type="submit">Continue with GitHub</button>
</form>
<form
action={async () => {
"use server";
await signIn("google", {
redirectTo: searchParams.callbackUrl ?? "/dashboard",
});
}}
>
<button type="submit">Continue with Google</button>
</form>
</div>
);
}
7. Protecting routes with Proxy (Next.js 16)
In Next.js 16, middleware.ts was renamed to proxy.ts. The export is proxy instead of middleware:
// src/proxy.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export const proxy = auth((req) => {
const isLoggedIn = !!req.auth;
const isProtected =
req.nextUrl.pathname.startsWith("/dashboard") ||
req.nextUrl.pathname.startsWith("/chat");
if (isProtected && !isLoggedIn) {
const signIn = new URL("/sign-in", req.nextUrl.origin);
signIn.searchParams.set("callbackUrl", req.nextUrl.pathname);
return NextResponse.redirect(signIn);
}
});
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
On Next.js 15 and earlier, name this file middleware.ts and export as middleware instead.
8. Reading the session
In any Server Component or Route Handler:
import { auth } from "@/auth";
// Server Component
export default async function DashboardPage() {
const session = await auth();
// session.user.id is available (from our callback above)
return <div>Hello {session?.user?.name}</div>;
}
// Route Handler
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// ...
}
9. Run migrations
npx drizzle-kit generate
npx drizzle-kit migrate
Environment variables
# .env.local
AUTH_SECRET= # openssl rand -hex 32
AUTH_GITHUB_ID= # github.com/settings/applications
AUTH_GITHUB_SECRET=
AUTH_GOOGLE_ID= # console.cloud.google.com
AUTH_GOOGLE_SECRET=
DATABASE_URL= # postgresql://...
Auth.js v5 auto-infers AUTH_GITHUB_ID / AUTH_GITHUB_SECRET for the GitHub provider — you don't need to pass them to GitHub({...}).
That's the complete setup
Everything above is production-ready code. The full stack (auth + Stripe billing + Claude AI + MCP server) is in AgentShip, an open-source AI-native SaaS boilerplate — the private repo has the complete implementation if you want to skip the plumbing entirely.
Top comments (0)