Next.js

Authentication in Next.js: Patterns That Scale

Svet Halachev Dec 20, 2025 5 min read
Authentication in Next.js: Patterns That Scale

Authentication in Next.js 15 with the App Router requires a different approach than you might be used to. Server Components can't access hooks, middleware runs on the edge, and you need to think about where your auth checks happen.

Here's how to build auth that actually works.

The Authentication Stack

For most Next.js apps, I recommend:

  • NextAuth.js (Auth.js) — Handles OAuth, credentials, sessions

  • Middleware — Redirects unauthenticated users before pages load

  • Server-side checks — Protects data fetching in Server Components

  • Client-side state — For UI that needs to react to auth status

You can also use services like Clerk, Auth0, or roll your own with JWTs. The patterns are similar regardless.

Setting Up NextAuth.js

Install and configure:

npm install next-auth
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'
import { db } from '@/lib/db'
import bcrypt from 'bcryptjs'

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        const user = await db.user.findUnique({
          where: { email: credentials.email as string }
        })

        if (!user) return null

        const passwordValid = await bcrypt.compare(
          credentials.password as string,
          user.password
        )

        if (!passwordValid) return null

        return { id: user.id, email: user.email, name: user.name }
      }
    })
  ],
  callbacks: {
    async session({ session, token }) {
      if (token.sub) {
        session.user.id = token.sub
      }
      return session
    }
  }
})

export const { GET, POST } = handlers

Middleware: Your First Line of Defense

Middleware runs before every request. Use it to redirect unauthenticated users:

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

export default auth((req) => {
  const isLoggedIn = !!req.auth
  const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard')
  const isOnAuth = req.nextUrl.pathname.startsWith('/login') ||
                   req.nextUrl.pathname.startsWith('/register')

  // Redirect unauthenticated users away from protected routes
  if (isOnDashboard && !isLoggedIn) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  // Redirect authenticated users away from auth pages
  if (isOnAuth && isLoggedIn) {
    return NextResponse.redirect(new URL('/dashboard', req.url))
  }

  return NextResponse.next()
})

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

This handles the redirect before any page code runs. Users never see a flash of protected content.

Server Component Authentication

In Server Components, get the session directly:

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

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

  // Double-check (middleware should catch this, but defense in depth)
  if (!session?.user) {
    redirect('/login')
  }

  // Fetch user-specific data
  const userData = await db.user.findUnique({
    where: { id: session.user.id },
    include: { posts: true, settings: true }
  })

  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <UserDashboard data={userData} />
    </div>
  )
}

The auth() function reads the session from cookies on the server. No client-side fetching needed.

Protecting API Routes

For API routes (Route Handlers), check the session:

// app/api/posts/route.ts
import { auth } from '@/auth'
import { NextResponse } from 'next/server'

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 db.post.create({
    data: {
      ...body,
      authorId: session.user.id,
    },
  })

  return NextResponse.json(post)
}

Client-Side Auth State

For Client Components that need auth info, use the 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 }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Now in Client Components:

'use client'

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

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

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

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

  return <button onClick={() => signIn()}>Sign In</button>
}

Server Actions with Auth

Server Actions should verify auth too:

// app/actions.ts
'use server'

import { auth } from '@/auth'
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

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

  revalidatePath('/posts')
}

Role-Based Access

Add roles to your session:

// In your NextAuth config
callbacks: {
  async session({ session, token }) {
    if (token.sub) {
      session.user.id = token.sub
      session.user.role = token.role // Add role from database
    }
    return session
  },
  async jwt({ token, user }) {
    if (user) {
      const dbUser = await db.user.findUnique({
        where: { id: user.id }
      })
      token.role = dbUser?.role ?? 'user'
    }
    return token
  }
}

Then check in your components:

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

  if (session?.user?.role !== 'admin') {
    redirect('/unauthorized')
  }

  return <AdminDashboard />
}

A Complete Protected Layout

Putting it together:

// app/dashboard/layout.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { DashboardNav } from '@/components/dashboard-nav'

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

  if (!session?.user) {
    redirect('/login')
  }

  return (
    <div className="flex min-h-screen">
      <DashboardNav user={session.user} />
      <main className="flex-1 p-8">{children}</main>
    </div>
  )
}

Every page under /dashboard automatically has auth checked and receives the authenticated layout.

Key Takeaways

  1. Middleware — Redirect before pages render

  2. Server checks — Verify auth in Server Components and Server Actions

  3. Client state — Use SessionProvider for UI that needs to react to auth

  4. Defense in depth — Check auth at multiple layers

  5. Keep secrets server-side — Never expose tokens or sensitive logic to the client

Authentication touches every layer of your app. Taking time to set it up properly saves countless headaches later.

Related Articles