Skip to content
Codeloom
Next.js

Authentication Patterns in Next.js

A practical guide to authentication in Next.js: NextAuth.js setup, middleware-based auth, server component auth checks, JWT vs session strategies, and protecting routes at every level.

·9 min read · By Codeloom
Intermediate 16 min read

What you'll learn

  • Setting up NextAuth.js (Auth.js) with the App Router
  • JWT vs database session strategies and tradeoffs
  • Protecting routes with middleware
  • Server component and server action auth checks
  • Common patterns: OAuth, credentials, role-based access

Prerequisites

  • Next.js App Router fundamentals
  • Basic understanding of authentication concepts (sessions, tokens)
  • React Server Components basics

Authentication in Next.js touches every layer of the framework: middleware, server components, route handlers, server actions, and client components. Getting it right means understanding which layer to check auth in, what information is available at each level, and how to avoid the common pitfalls that leave routes unprotected.

Auth strategy overview

There are two fundamental approaches to session management:

JWT (JSON Web Tokens) — The session data lives in an encrypted token stored in a cookie. No database lookup is needed to verify the session. Tradeoff: you cannot revoke individual sessions without additional infrastructure (a blocklist).

Database sessions — A session ID is stored in a cookie, and the full session data lives in a database. Every request requires a database lookup. Tradeoff: slightly higher latency, but you can revoke sessions instantly by deleting the row.

JWT Flow:
  Login → Create signed token → Store in cookie → Verify on each request

Database Session Flow:
  Login → Create session row → Store session ID in cookie → Look up on each request

Most production apps use JWT for stateless verification in middleware (where database access may not be available) and database sessions for the full session data in server components.

Setting up NextAuth.js (Auth.js)

NextAuth.js (now called Auth.js) is the most common auth library for Next.js. Here is a complete setup with the App Router.

npm install next-auth@beta
// auth.ts (root of project)
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    Credentials({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });

        if (!user || !user.hashedPassword) {
          return null;
        }

        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.hashedPassword
        );

        if (!isValid) return null;

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        };
      },
    }),
  ],
  session: {
    strategy: "jwt",
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role;
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.role = token.role as string;
        session.user.id = token.id as string;
      }
      return session;
    },
  },
  pages: {
    signIn: "/login",
    error: "/auth/error",
  },
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";

export const { GET, POST } = handlers;

The auth export is the key function. Call it anywhere on the server to get the current session.

Server component auth

In server components, call auth() directly to get the session. No hooks, no context providers.

// app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";

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

  if (!session) {
    redirect("/login");
  }

  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <p>Email: {session.user.email}</p>
      <p>Role: {session.user.role}</p>
    </div>
  );
}

This is the simplest and most reliable pattern. The auth() call reads the session cookie, verifies the JWT, and returns the session data. If the session is invalid, it returns null.

Reusable auth wrapper

For consistent auth checks across multiple pages:

// lib/auth-utils.ts
import { auth } from "@/auth";
import { redirect } from "next/navigation";

export async function requireAuth() {
  const session = await auth();
  if (!session?.user) {
    redirect("/login");
  }
  return session;
}

export async function requireRole(role: string) {
  const session = await requireAuth();
  if (session.user.role !== role) {
    redirect("/unauthorized");
  }
  return session;
}
// app/admin/page.tsx
import { requireRole } from "@/lib/auth-utils";

export default async function AdminPage() {
  const session = await requireRole("admin");

  return <AdminDashboard user={session.user} />;
}

Middleware auth

Middleware auth is the first line of defense. It runs before the route handler and can redirect unauthenticated users before any server component code executes.

// middleware.ts
import { auth } from "@/auth";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
  const isOnAdmin = req.nextUrl.pathname.startsWith("/admin");
  const isOnAuthPage = req.nextUrl.pathname.startsWith("/login") ||
                       req.nextUrl.pathname.startsWith("/register");

  // Redirect logged-in users away from auth pages
  if (isOnAuthPage && isLoggedIn) {
    return Response.redirect(new URL("/dashboard", req.nextUrl));
  }

  // Protect dashboard routes
  if (isOnDashboard && !isLoggedIn) {
    const loginUrl = new URL("/login", req.nextUrl);
    loginUrl.searchParams.set("callbackUrl", req.nextUrl.pathname);
    return Response.redirect(loginUrl);
  }

  // Protect admin routes with role check
  if (isOnAdmin) {
    if (!isLoggedIn) {
      return Response.redirect(new URL("/login", req.nextUrl));
    }
    if (req.auth?.user?.role !== "admin") {
      return Response.redirect(new URL("/unauthorized", req.nextUrl));
    }
  }
});

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

Middleware auth should be a coarse check. It is fast and runs at the edge, but it does not have access to the full database. Use it for “is the user logged in?” and basic role checks. Fine-grained permission checks belong in server components or server actions.

Server action auth

Server actions must always verify auth independently. Never trust that a server action is only called from an authenticated page — anyone can call a server action directly.

// app/actions/posts.ts
"use server";

import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

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

  if (!session?.user) {
    throw new Error("Unauthorized");
  }

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

  if (!title || !content) {
    throw new Error("Title and content are required");
  }

  await prisma.post.create({
    data: {
      title,
      content,
      authorId: session.user.id,
    },
  });

  revalidatePath("/blog");
}

export async function deletePost(postId: string) {
  const session = await auth();

  if (!session?.user) {
    throw new Error("Unauthorized");
  }

  const post = await prisma.post.findUnique({
    where: { id: postId },
  });

  if (!post) {
    throw new Error("Post not found");
  }

  // Authorization: only the author or an admin can delete
  if (post.authorId !== session.user.id && session.user.role !== "admin") {
    throw new Error("Forbidden");
  }

  await prisma.post.delete({ where: { id: postId } });
  revalidatePath("/blog");
}

Route handler auth

API route handlers follow the same pattern.

// app/api/posts/route.ts
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";

export async function GET() {
  const posts = await prisma.post.findMany({
    orderBy: { createdAt: "desc" },
  });
  return NextResponse.json(posts);
}

export async function POST(request: Request) {
  const session = await auth();

  if (!session?.user) {
    return NextResponse.json(
      { error: "Unauthorized" },
      { status: 401 }
    );
  }

  const body = await request.json();

  const post = await prisma.post.create({
    data: {
      title: body.title,
      content: body.content,
      authorId: session.user.id,
    },
  });

  return NextResponse.json(post, { status: 201 });
}

Client component auth

Client components cannot call auth() directly. Use the useSession hook from NextAuth.js, which requires a SessionProvider.

// app/providers.tsx
"use client";

import { SessionProvider } from "next-auth/react";

export function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}

// app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
// components/UserMenu.tsx
"use client";

import { useSession, signIn, signOut } from "next-auth/react";

export function UserMenu() {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <div>Loading...</div>;
  }

  if (!session) {
    return <button onClick={() => signIn()}>Sign In</button>;
  }

  return (
    <div>
      <span>{session.user.name}</span>
      <button onClick={() => signOut()}>Sign Out</button>
    </div>
  );
}

A better pattern is to fetch session data in a server component and pass it down as props, avoiding the loading state entirely:

// app/layout.tsx
import { auth } from "@/auth";
import { Navbar } from "@/components/Navbar";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();

  return (
    <html>
      <body>
        <Navbar user={session?.user ?? null} />
        {children}
      </body>
    </html>
  );
}

Login and registration forms

// app/login/page.tsx
import { signIn } from "@/auth";
import { redirect } from "next/navigation";
import { AuthError } from "next-auth";

export default function LoginPage() {
  return (
    <div className="login-container">
      <h1>Sign In</h1>

      {/* OAuth providers */}
      <form
        action={async () => {
          "use server";
          await signIn("github", { redirectTo: "/dashboard" });
        }}
      >
        <button type="submit">Sign in with GitHub</button>
      </form>

      <form
        action={async () => {
          "use server";
          await signIn("google", { redirectTo: "/dashboard" });
        }}
      >
        <button type="submit">Sign in with Google</button>
      </form>

      <hr />

      {/* Credentials form */}
      <form
        action={async (formData) => {
          "use server";
          try {
            await signIn("credentials", {
              email: formData.get("email"),
              password: formData.get("password"),
              redirectTo: "/dashboard",
            });
          } catch (error) {
            if (error instanceof AuthError) {
              redirect(`/login?error=${error.type}`);
            }
            throw error;
          }
        }}
      >
        <input name="email" type="email" placeholder="Email" required />
        <input name="password" type="password" placeholder="Password" required />
        <button type="submit">Sign In</button>
      </form>
    </div>
  );
}

Type-safe session

Extend the NextAuth types to include custom fields:

// types/next-auth.d.ts
import { DefaultSession } from "next-auth";

declare module "next-auth" {
  interface Session {
    user: {
      id: string;
      role: string;
    } & DefaultSession["user"];
  }

  interface User {
    role: string;
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    id: string;
    role: string;
  }
}

Defense in depth

Never rely on a single auth check. A robust app verifies auth at multiple layers:

  1. Middleware — Redirects unauthenticated users away from protected routes. Fast, but coarse.
  2. Server Components — Verifies the session before rendering sensitive data. Prevents leaked data even if middleware is bypassed.
  3. Server Actions — Always verifies auth independently. Never assumes the caller is authenticated.
  4. Route Handlers — Same as server actions. Always check auth.
  5. Database queries — Filter by the authenticated user’s ID. Never trust user-supplied IDs without verifying ownership.
// The ideal pattern: auth at every layer
// middleware.ts: coarse redirect
// page.tsx: session check + redirect
// actions.ts: session check + authorization check
// db queries: WHERE authorId = session.user.id

This layered approach means that even if one check fails (a misconfigured matcher, a bug in middleware), the other layers catch unauthorized access. Authentication is not a single gate — it is a series of checks at every boundary.