Auth.js (the project formerly known as NextAuth.js, now framework-agnostic) is the closest thing to a free, self-hosted default for authentication in the JavaScript ecosystem, and most of the confusion around it comes from one early decision: JWT sessions or database sessions.
Auth.js is an open-source authentication library supporting OAuth providers, email magic links, and credentials-based login, with pluggable adapters for persisting sessions and user data to your database of choice. It doesn't provide UI components like Clerk, and it's lighter on built-in features (no organizations, no 2FA out of the box) than Better Auth — it's intentionally a smaller, more foundational library.
Why Auth.js Matters (and When to Skip It)
Auth.js is free, self-hosted, and has the largest OAuth provider library of any JavaScript auth solution — dozens of pre-configured providers (Google, GitHub, Discord, and many more) with minimal setup each. For projects that need OAuth login without hosted-provider cost or data residency concerns, and don't need Better Auth's broader plugin ecosystem, it's a solid default.
Skip it if you need built-in multi-tenancy, 2FA, or pre-built UI — Auth.js expects you to build the UI yourself and leaves advanced features to be built on top, unlike Clerk (hosted, pre-built UI) or Better Auth (self-hosted, plugin-based feature coverage).
Getting Started with Auth.js
Configuration in a Next.js App Router project:
// auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "./db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
session: { strategy: "database" },
});
// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth";
Accessing the session server-side:
import { auth } from "@/auth";
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect("/login");
return <div>Welcome, {session.user.name}</div>;
}
Core Auth.js Concepts Every Developer Should Know
JWT vs. database sessions is the first real decision, and it affects everything downstream. JWT sessions are stateless — no database lookup per request, but you can't invalidate a session server-side before it expires. Database sessions let you revoke access instantly (delete the session row) but require a DB round-trip per authenticated request unless cached.
session: { strategy: "jwt" } // stateless, faster, harder to revoke
session: { strategy: "database" } // revocable, requires an adapter + DB
Adapters connect Auth.js to your actual database schema. Official adapters exist for Prisma, Drizzle, and several others — they define the exact tables (users, accounts, sessions, verification_tokens) Auth.js expects, which you migrate into your own database.
Callbacks let you customize the session and JWT payload, commonly used to attach a role or custom field to the session object:
callbacks: {
async session({ session, user }) {
session.user.role = user.role; // add custom field
return session;
},
},
Middleware protects routes at the edge, checking session presence before a request even reaches a page:
export { auth as middleware } from "@/auth";
export const config = { matcher: ["/dashboard/:path*"] };
Common Auth.js Mistakes and How to Fix Them
Mistake 1: choosing JWT sessions, then needing instant session revocation later. Realizing you need to force-logout a banned user only works cleanly with database sessions. Fix: decide the strategy based on whether you'll ever need server-side revocation, not just initial simplicity.
Mistake 2: forgetting NEXTAUTH_SECRET (or AUTH_SECRET) in production. Without it, JWT signing falls back to insecure defaults or throws — a common "works locally, breaks in production" bug. Fix: always set the secret explicitly via environment variable in every deployment environment.
Mistake 3: not handling the AuthError types from credentials-based sign-in. A generic catch-all error message for all sign-in failures (wrong password vs. account not found vs. rate limited) creates a poor and sometimes insecure UX. Fix: check error.type from the thrown AuthError and message appropriately without leaking which failure mode occurred (to avoid user enumeration).
When Should You Use Auth.js Instead of Clerk or Better Auth?
Use Auth.js when you want the widest OAuth provider selection, are comfortable building your own UI, and don't need built-in organizations or 2FA. Use Clerk for fastest time-to-ship with pre-built UI. Use Better Auth when you want self-hosted control plus more built-in features (2FA, organizations) than Auth.js ships with by default.
Auth.js in Production
Pin your Auth.js version deliberately — the library has gone through significant API changes across major versions (including the NextAuth → Auth.js rename), and upgrading without reading the migration guide has broken production sessions for teams before. Also test the full OAuth redirect flow against your actual production domain before launch; misconfigured callback URLs are the most common Auth.js setup failure, same as with any OAuth-based auth system.
If you need OAuth login with maximum provider flexibility and don't mind building your own sign-in UI, Auth.js remains a solid free default — just decide your session strategy deliberately, not by accepting whatever the starter template used.