DevStacked
Jul 10, 202614 min read

How to Add Auth.js (NextAuth v5) Authentication in Next.js 16 (2026)

Every real app eventually needs to answer one question: who is this user? The moment you add a dashboard, a settings page, or anything that shouldn't be public, you need authentication — and rolling your own login system from scratch (password hashing, sessions, CSRF protection, OAuth flows) is a rabbit hole most developers don't have time for.

That's exactly what Auth.js (the project formerly known as NextAuth) solves. It's free, open source, and gives you production-ready authentication — email/password, GitHub, Google, and dozens of other providers — without owning any of that security-critical code yourself.

In this guide, you'll set up Auth.js v5 in a Next.js 16 App Router project from scratch: installing it, configuring a provider, protecting routes with proxy.ts, and reading the logged-in user inside Server Components. By the end, you'll have a working "Sign in with GitHub" flow you can extend to any provider you want.


What you'll learn

By the end of this guide you'll know how to:

✓ Install Auth.js v5
✓ Configure GitHub OAuth
✓ Protect routes
✓ Read sessions
✓ Use auth() inside Server Components
✓ Secure Server Actions


What Is Auth.js, Exactly?

Auth.js is an authentication library built specifically for full-stack JavaScript frameworks. If you've read older tutorials, you've probably seen it called NextAuth — that's the same project. It was renamed to Auth.js when the team expanded support beyond Next.js to other frameworks like SvelteKit and Express, while the npm package name stayed next-auth for backwards compatibility.

Think of Auth.js as a middleman between your app and the identity providers (GitHub, Google, your own database, etc.). Instead of you writing the OAuth handshake, generating secure session cookies, and validating tokens by hand, Auth.js handles all of it — you just tell it which providers to use.

💡 Tip: If you've seen getServerSession() in an older tutorial, that's the Auth.js v4 API. In v5 (what this guide uses), everything is exported from a single auth() function instead — much simpler.


What We're Building

By the end of this guide, you'll have:

  • Auth.js v5 installed and configured in a Next.js 16 App Router app
  • A working GitHub OAuth sign-in flow (the pattern is the same for Google, Discord, etc.)
  • A proxy.ts file that protects private routes automatically
  • A Navbar that shows "Sign in" or the user's avatar depending on auth state
  • Access to the logged-in user inside Server Components using auth()

Prerequisites

Before we start, make sure you have:

  • Next.js 16 with the App Router
  • TypeScript (strict mode)
  • Tailwind CSS for styling
  • Basic familiarity with React and the App Router

If your project doesn't have these yet, scaffold one first:

npx create-next-app@latest my-app

For this guide, we're using the src/ directory layout (choose Yes when the CLI asks). Everything works the same without it — just drop the src/ prefix from the file paths below.


Folder Structure

src/
├── app/
   └── api/
       └── auth/
           └── [...nextauth]/
               └── route.ts
├── actions/
   └── create-post.ts
├── components/
   └── auth-buttons.tsx
├── lib/
   └── auth.ts
├── proxy.ts
.env.local

How does Auth.js work?

User clicks "Sign in"
        
Redirect to GitHub
        
User approves
        
GitHub redirects back
        
Auth.js validates
        
Session cookie created
        
auth() can now read the user

Step 1: Install Auth.js

Auth.js v5 is still installed under its old package name, next-auth, using the beta tag:

npm install next-auth@beta

⚠️ Common Mistake: Installing next-auth without @beta gives you the old v4 API, which uses a completely different setup ([...nextauth].ts pages, authOptions, getServerSession()). This guide is written entirely for v5.


Step 2: Create a GitHub OAuth App

We're using GitHub as our sign-in provider in this guide. In one sentence: OAuth lets users sign in using an existing account (like GitHub or Google) without ever sharing their password with your application. Instead, GitHub itself confirms the user's identity and hands your app a token proving it — your app never sees or stores their GitHub password.

Since we're using GitHub, GitHub needs to know about your app before it'll let anyone sign in with it.

  1. Go to github.com/settings/developers
  2. Click New OAuth App
  3. Fill in:
    • Application name: anything, e.g. My App (Dev)
    • Homepage URL: http://localhost:3000
    • Authorization callback URL: http://localhost:3000/api/auth/callback/github
  4. Click Register application
  5. Copy the Client ID, then generate and copy a Client Secret

💡 Tip: The callback URL pattern /api/auth/callback/<provider> is fixed by Auth.js — don't change it, or GitHub won't be able to redirect back to your app correctly.


Step 3: Add Environment Variables

Create a .env.local file in your project root:

# .env.local
AUTH_SECRET=replace-this-with-a-random-string
AUTH_GITHUB_ID=your_github_client_id
AUTH_GITHUB_SECRET=your_github_client_secret

AUTH_SECRET is used to sign and encrypt session tokens — it must stay private. Generate a strong random value with:

npx auth secret

This command (from the auth CLI that ships with next-auth) automatically writes a secure AUTH_SECRET into your .env.local file for you.

⚠️ Common Mistake: Forgetting to add .env.local to .gitignore. If AUTH_SECRET or your OAuth secret leaks into a public repo, attackers can forge valid sessions for your app.


Step 4: Configure Auth.js

This is the heart of the setup. Create an auth.ts file inside src/lib/:

// src/lib/auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [GitHub],
});

What's happening here?

  • NextAuth(...) takes a config object and returns everything you need: handlers (for the API route), signIn/signOut (Server Actions you can call from forms or buttons), and auth() (a function to read the current session anywhere on the server).
  • providers: [GitHub] tells Auth.js to use GitHub OAuth. Since our environment variables follow the AUTH_GITHUB_ID / AUTH_GITHUB_SECRET naming convention, Auth.js picks them up automatically — no need to pass them manually.

💡 Tip: Want to add Google too? Just import Google from next-auth/providers/google, add matching AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET env variables, and drop it into the providers array. Auth.js supports 80+ providers this way.


Step 5: Create the API Route Handler

Auth.js needs a route to handle sign-in, sign-out, and OAuth callbacks. Create this file:

// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";

export const { GET, POST } = handlers;

Why a catch-all route?

The [...nextauth] folder name is a catch-all dynamic route — it matches every path under /api/auth/, like /api/auth/signin, /api/auth/callback/github, and /api/auth/session. Instead of writing a route for each one, Auth.js's handlers object already knows how to respond to all of them internally.


Step 6: Add the Proxy (Middleware)

In Next.js 16, what used to be called middleware.ts is now named proxy.ts — same concept, same file conventions, just a renamed file. This is the ideal place to check authentication before a request even reaches your page.

// src/proxy.ts
export { auth as proxy } from "@/lib/auth";

export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
  ],
};

What's happening here?

Think of proxy.ts like a security guard standing at the entrance of your app — every matching request passes through it before reaching a page. By exporting auth as proxy, every request now has access to the session, which you can use to redirect unauthenticated users.

By default, this doesn't block anything yet — it just makes auth checking available. Let's actually protect a route next.


Step 7: Protect a Route

Let's say you have a /dashboard page that only logged-in users should see, and you also want to bounce already-logged-in users away from your login/home page. Update proxy.ts:

// src/proxy.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";

export default auth((req) => {
    const isLoggedIn = !!req.auth;
    const { nextUrl } = req

    // Define routes that logged-in users shouldn't see
    const isAuthPage = nextUrl.pathname === "/login" || nextUrl.pathname === "/"

    if (isAuthPage) {
        if (isLoggedIn) {
            // Redirect authenticated users to the dashboard
            return NextResponse.redirect(new URL("/dashboard", nextUrl))
        }
        return NextResponse.next()
    }

    // Protect private routes
    if (!isLoggedIn && nextUrl.pathname.startsWith("/dashboard")) {
        return NextResponse.redirect(new URL("/", nextUrl))
    }
});

export const config = {
    matcher: [
        "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
    ]
};

Breaking this down

  • auth((req) => {...}) wraps your proxy function, giving you req.auth — the current session, or null if nobody's logged in.
  • isAuthPage covers routes a logged-in user has no reason to revisit (like /login or your home page) — if they're already signed in, we send them straight to /dashboard instead.
  • The second check does the opposite: if someone isn't logged in and tries to visit /dashboard, we redirect them back to / instead of letting the request through.

⚠️ Common Mistake: Relying on proxy.ts alone for security. Middleware-level checks are great for UX (redirecting before a page even loads), but you should also verify the session inside Server Actions and Route Handlers that touch sensitive data — never trust the frontend redirect as your only line of defense.


Step 8: Add Sign In / Sign Out Buttons

Auth.js exposes signIn and signOut as Server Actions, so you can call them directly from a <form> without any client-side JavaScript.

// src/components/auth-buttons.tsx
import { auth, signIn, signOut } from "@/lib/auth";

export default async function AuthButtons() {
  const session = await auth();

  if (!session?.user) {
    return (
      <form
        action={async () => {
          "use server";
          await signIn("github");
        }}
      >
        <button
          type="submit"
          className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
        >
          Sign in with GitHub
        </button>
      </form>
    );
  }

  return (
    <form
      action={async () => {
        "use server";
        await signOut();
      }}
    >
      <button
        type="submit"
        className="rounded-lg border px-4 py-2 text-sm font-medium"
      >
        Sign out
      </button>
    </form>
  );
}

What's happening here?

  • AuthButtons is an async Server Component — it calls auth() directly to check whether someone's logged in, no useEffect or loading state needed.
  • The inline "use server" function inside action={...} is an inline Server Action. When the form is submitted, this function runs on the server and calls signIn("github") or signOut().
  • signIn("github") tells Auth.js exactly which provider to start the OAuth flow with — useful once you add more than one provider.

Drop this component into your navbar, and you've got a fully working auth toggle with zero client-side JavaScript.


Step 9: Read the Session in a Server Component

Anywhere on the server — a page, a layout, another component — you can call auth() to get the current user:

// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";

export default async function DashboardPage() {
  const session = await auth();

  return (
    <div>
      <h1 className="text-2xl font-bold">Dashboard</h1>
      <p className="text-muted-foreground">
        Welcome back, {session?.user?.name}!
      </p>
    </div>
  );
}

Since we already protect /dashboard in proxy.ts, session is guaranteed to exist by the time this page renders — but it's still good practice to use optional chaining (session?.user?.name) in case that logic ever changes.


Step 10: Protect a Server Action Directly

Even with proxy.ts in place, always double-check authentication inside anything that mutates data:

// src/actions/create-post.ts
"use server";

import { auth } from "@/lib/auth";

export async function createPost(formData: FormData) {
  const session = await auth();

  if (!session?.user) {
    return { error: "You must be signed in to do that." };
  }

  const title = formData.get("title") as string;

  // Your database logic here
  // await db.post.create({ data: { title, authorId: session.user.id } })

  return { success: true };
}

This is the same idea as validating input on the server even though your form already validates on the client — never trust that a request only came from your UI.


Frequently Asked Questions

Is Auth.js free?

Yes, completely. Auth.js is open source and has no usage limits, unlike some hosted auth providers that charge per monthly active user. You're only responsible for hosting and maintaining your own database if you want persistent user records.

Why is Auth.js still installed as next-auth?

Because the npm package name never changed, even though the project itself rebranded. Renaming a package that thousands of projects already depend on would break every existing install, so the team kept next-auth as the package name and just changed the project's public name and docs to Auth.js. You'll see both names used interchangeably — they refer to the exact same library.

Why did NextAuth become Auth.js?

NextAuth started out Next.js-only, but the maintainers wanted the same authentication core to work with other frameworks too — SvelteKit, Express, and more, via a shared @auth/core package. "NextAuth" no longer described what the project did, so it was renamed to the framework-agnostic Auth.js, with next-auth continuing on as the Next.js-specific package built on top of that shared core.

Do I need a database?

Not for OAuth-only sign-in like the GitHub example above — Auth.js can run in a stateless JWT session mode with zero database. You'll need a database (with an Auth.js adapter, like the Drizzle or Prisma adapter) if you want persistent user records, email/password login, or multiple linked accounts per user.

What's the difference between Auth.js and Clerk?

Auth.js gives you full ownership of your data — everything lives in your own database (or nowhere, if you're stateless) — but you build your own UI. Clerk is a hosted service with prebuilt sign-in components and a dashboard, which is faster to set up but means your user data lives on Clerk's infrastructure. If data ownership matters for compliance reasons, Auth.js is the safer default.

💡 If you're deciding between the two, see Clerk vs NextAuth (Auth.js) in 2026 for a full comparison.

Can I add email/password login instead of OAuth?

Yes, using the Credentials provider. It requires you to write your own password hashing and verification logic (Auth.js won't do this for you, since it's security-sensitive), and you'll need a database to store user records.

Why is my session null even after signing in?

This is almost always a mismatched AUTH_SECRET or an incorrect callback URL. Double-check that your GitHub OAuth app's callback URL exactly matches http://localhost:3000/api/auth/callback/github (or your production domain), and that .env.local was loaded — restart your dev server after any environment variable change.


Wrapping Up

You've now got a complete Auth.js v5 setup in Next.js 16 — GitHub OAuth sign-in, route protection via proxy.ts, and session access in both Server Components and Server Actions. This same pattern extends cleanly: add more providers, connect a database adapter for persistent users, or layer role-based access control on top of the session object.

From here, a natural next step is wiring up a database adapter so user accounts persist across sign-ins, or adding a second provider like Google for more sign-in options.

📦 Source Code: View on GitHub

Useful Resources

Continue Learning

Helpful Free Tools

  • Meta Tag Generator for Next.js & HTML — generate SEO meta tags, Open Graph tags, Twitter Cards, canonical URLs, and JSON-LD structured data instantly. Handy for wiring up per-post metadata like the generateMetadata() function above.
  • Supabase RLS Policy Generator — if your next project pairs this blog setup with a Supabase backend, generate secure, performance-optimized Row Level Security policies in seconds.
  • Zod Schema Generator — generate Zod schemas instantly from JSON or TypeScript. Includes smart inference, live validation, React Hook Form, Next.js API Routes, and Server Actions.
Next.jsAuth.jsNextAuthAuthenticationTypeScriptApp RouterReact
Share On