DEV Community

mt211211
mt211211

Posted on

Auth.js v5 with Next.js and Drizzle ORM: the complete setup guide

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.ts config file replaces the scattered [...nextauth] options
  • auth() is universal — use it in Server Components, Route Handlers, and Proxy (formerly Middleware)
  • handlers replaces GET/POST export ceremony
  • The middleware.ts convention is now proxy.ts in Next.js 16+

1. Install

npm install next-auth@beta @auth/drizzle-adapter drizzle-orm @neondatabase/serverless
npm install --save-dev drizzle-kit
Enter fullscreen mode Exit fullscreen mode

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] })]
);
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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; };
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Route handler

// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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).*)"],
};
Enter fullscreen mode Exit fullscreen mode

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 });
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

9. Run migrations

npx drizzle-kit generate
npx drizzle-kit migrate
Enter fullscreen mode Exit fullscreen mode

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://...
Enter fullscreen mode Exit fullscreen mode

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)