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 } = handlersMiddleware: 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
Middleware — Redirect before pages render
Server checks — Verify auth in Server Components and Server Actions
Client state — Use SessionProvider for UI that needs to react to auth
Defense in depth — Check auth at multiple layers
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.