Authentication in Next.js with NextAuth.js and the App Router

Authentication in Next.js is commonly handled by NextAuth.js (now rebranded as Auth.js). Version 5 was rewritten from the ground up to support the App Router natively. This guide covers a production-ready auth setup, from installation to protected routes, with the patterns the Auth.js team recommends.

Setup

npm install next-auth@beta
npx auth secret   # generates AUTH_SECRET and writes it to .env.local

Your .env.local should contain:

AUTH_SECRET=<generated_value>

# OAuth providers — create apps on their developer portals
AUTH_GITHUB_ID=...
AUTH_GITHUB_SECRET=...
AUTH_GOOGLE_ID=...
AUTH_GOOGLE_SECRET=...

Create the Auth Configuration

Create a single auth.ts file at the project root. This is the central config. NextAuth calls it your "auth config".

// auth.ts
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 { comparePassword } from "@/lib/crypto";
import { getUserByEmail } from "@/lib/db/users";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    GitHub,
    Google,
    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 getUserByEmail(credentials.email as string);
        if (!user) return null;

        const valid = await comparePassword(
          credentials.password as string,
          user.hashedPassword
        );
        if (!valid) return null;

        return { id: user.id, name: user.name, email: user.email };
      },
    }),
  ],
  callbacks: {
    // Add user ID to the JWT token
    async jwt({ token, user }) {
      if (user) token.userId = user.id;
      return token;
    },
    // Expose user ID in the session
    async session({ session, token }) {
      if (token.userId) session.user.id = token.userId as string;
      return session;
    },
  },
  pages: {
    signIn: "/login",    // custom sign-in page
    error:  "/auth/error",
  },
});

Wire Up the Route Handler

NextAuth needs a single catch-all API route to handle all auth callbacks:

// app/api/auth/[...nextauth]/route.ts
export { handlers as GET, handlers as POST } from "@/auth";

Accessing the Session

In Server Components (App Router)

// 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>Signed in as {session.user?.email}</p>
    </div>
  );
}

In Client Components

Wrap the app in SessionProvider (once, in the root layout):

// app/layout.tsx
import { SessionProvider } from "next-auth/react";
import { auth } from "@/auth";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth();
  return (
    <html lang="en">
      <body>
        <SessionProvider session={session}>
          {children}
        </SessionProvider>
      </body>
    </html>
  );
}
"use client";
// components/UserMenu.tsx
import { useSession, signOut } from "next-auth/react";

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

  if (status === "loading") return <Skeleton />;
  if (!session) return <SignInButton />;

  return (
    <div>
      <img src={session.user?.image ?? ""} alt={session.user?.name ?? ""} />
      <span>{session.user?.name}</span>
      <button onClick={() => signOut()}>Sign out</button>
    </div>
  );
}

Protecting Routes with Middleware

The most efficient way to protect routes: middleware runs on the Edge before any rendering.

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

export default auth(function middleware(req) {
  const isAuthenticated = !!req.auth;
  const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard");

  if (isProtectedRoute && !isAuthenticated) {
    const loginUrl = new URL("/login", req.url);
    loginUrl.searchParams.set("callbackUrl", req.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
});

export const config = {
  matcher: [
    // Run middleware on all paths except static files and Next internals
    "/((?!api/auth|_next/static|_next/image|favicon.ico).*)",
  ],
};

Building the Sign-In Page

// app/login/page.tsx
"use client";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";

export default function LoginPage() {
  const searchParams = useSearchParams();
  const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";

  return (
    <div className="flex flex-col items-center gap-4 p-8">
      <h1>Sign In</h1>

      {/* OAuth providers */}
      <button
        onClick={() => signIn("github", { callbackUrl })}
        className="btn-oauth"
      >
        Continue with GitHub
      </button>
      <button
        onClick={() => signIn("google", { callbackUrl })}
        className="btn-oauth"
      >
        Continue with Google
      </button>

      <div>or</div>

      {/* Credentials form */}
      <form
        action={async (formData) => {
          "use server"; // Server Action
          await signIn("credentials", {
            email:       formData.get("email"),
            password:    formData.get("password"),
            redirectTo:  callbackUrl,
          });
        }}
        className="flex flex-col gap-3 w-full max-w-sm"
      >
        <input type="email"    name="email"    placeholder="Email"    required />
        <input type="password" name="password" placeholder="Password" required />
        <button type="submit">Sign In with Email</button>
      </form>
    </div>
  );
}

Extending the Session Type

NextAuth's session doesn't include user.id by default. Add it via TypeScript module augmentation:

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

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

Security Best Practices

1. Store passwords hashed with bcrypt. Never plaintext:

import bcrypt from "bcryptjs";

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12); // cost factor 12 is the current minimum recommendation
}

export async function comparePassword(plain: string, hash: string): Promise<boolean> {
  return bcrypt.compare(plain, hash);
}

2. Always set AUTH_SECRET in production. A missing secret means sessions cannot be verified.

3. Use HTTPS in production. NextAuth sets cookies with the Secure flag automatically in production.

4. Validate callbackUrl against allowed origins. Never redirect to arbitrary URLs to prevent open redirect attacks. NextAuth validates this by default, but custom logic should too.

5. Rate-limit the credentials endpoint. Brute-force protection for password-based login:

// app/api/auth/[...nextauth]/route.ts — wrap handlers with rate limiting
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 attempts per minute
});

Conclusion

NextAuth.js v5 with the App Router is a solid, well-maintained solution for authentication in Next.js. Use auth() in Server Components for session access, middleware for route protection, and SessionProvider + useSession for client components. Keep passwords hashed, secrets in environment variables, and let middleware handle the security boundary rather than individual pages.


References: