Session Management & Route Protection with Next.js Middleware
Step 15 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
By the end of this step, your app will:
- Read the session in Server Components to show personalized content
- Protect the
/posts/newroute so only authenticated users can access it - Redirect unauthenticated users to the login page with a middleware
- Display the logged-in user's name on the homepage
Goal: Understand the two ways to read sessions (server-side with auth() and client-side with useSession()) and protect routes using middleware.
Table of Contents
- Two Ways to Read the Session
- Reading the Session in Server Components
- Update the Homepage
- Protecting Routes with Middleware
- Understanding the Middleware
- Protecting API Routes
- Verify the Result
- Summary & Key Takeaways
Two Ways to Read the Session
In Step 14, we used useSession() in the Header (a Client Component). But there is another way — auth() — which works in Server Components, API routes, and middleware.
| Method | Where it works | How it works |
|---|---|---|
useSession() | Client Components | Reads from SessionProvider context (client-side) |
auth() | Server Components, API routes, middleware | Reads from the session cookie (server-side) |
Both return the same data, but they work in different contexts. Use auth() whenever you are on the server — it is faster because there is no client-side JavaScript involved.
Reading the Session in Server Components
auth() is exported from the auth.ts file we created in Step 13. It returns the session or null:
import { auth } from '@/auth'
export default async function SomePage() {
const session = await auth()
if (!session) {
return <p>You are not signed in.</p>
}
return <p>Hello, {session.user?.name}!</p>
}
auth() is an async function — you must await it. It reads the session cookie from the incoming request, verifies the JWT, and returns the session data.
Update the Homepage
Let's personalize the homepage to greet the logged-in user. Update app/page.tsx:
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
export default async function Home() {
const session = await auth()
const postCount = await prisma.post.count()
const userCount = await prisma.user.count()
return (
<>
<h2 className="mb-4 text-4xl font-bold text-gray-900">
{session
? `Welcome back, ${session.user?.name || session.user?.email}!`
: 'Welcome to Superblog'}
</h2>
<p className="mb-8 text-lg text-gray-600">
A full-stack blog application built with Next.js, Prisma, and
NextAuth.js.
</p>
<div className="flex gap-4 mb-8">
<div className="px-6 py-4 text-center rounded-lg bg-blue-50">
<p className="text-3xl font-bold text-blue-600">{postCount}</p>
<p className="text-sm text-blue-700">Posts</p>
</div>
<div className="px-6 py-4 text-center rounded-lg bg-green-50">
<p className="text-3xl font-bold text-green-600">{userCount}</p>
<p className="text-sm text-green-700">Users</p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="p-6 bg-white rounded-lg shadow-md">
<h3 className="mb-2 text-lg font-semibold text-gray-800">
Blog Posts
</h3>
<p className="text-gray-600">
Create, read, and delete blog posts with a real database.
</p>
</div>
<div className="p-6 bg-white rounded-lg shadow-md">
<h3 className="mb-2 text-lg font-semibold text-gray-800">
Authentication
</h3>
<p className="text-gray-600">
{session
? `Signed in as ${session.user?.email}`
: 'Sign in to create and manage posts.'}
</p>
</div>
</div>
</>
)
}
What changed
- Added
import { auth } from '@/auth'andconst session = await auth(). - The heading now shows "Welcome back, Alice!" if signed in, or "Welcome to Superblog" if not.
- The authentication card shows the user's email when signed in.
This is a Server Component — auth() runs on the server, reads the cookie, and the personalized HTML is sent to the browser. No client-side JavaScript is needed for this.
Protecting Routes with Middleware
Right now, anyone can visit /posts/new — even if they are not signed in. The page is a placeholder, but once we add the form (Step 16), we need to make sure only authenticated users can access it.
Next.js middleware runs before every request. We can use it to check the session and redirect unauthenticated users to the login page.
The Edge Runtime Problem
Middleware in Next.js runs in the Edge Runtime — a lightweight environment that does not support all Node.js modules. Our auth.ts imports bcrypt and prisma, which are Node.js-only modules. If we import auth from auth.ts into the middleware, we get this error:
The edge runtime does not support Node.js 'crypto' module.
The solution: split the auth config into two files. One lightweight config (Edge-safe) for the middleware, and the full config (with bcrypt and Prisma) for everything else.
Step 1 — Create auth.config.ts
Create auth.config.ts in the project root (next to auth.ts):
import type { NextAuthConfig } from 'next-auth'
export const authConfig: NextAuthConfig = {
pages: {
signIn: '/login',
},
session: {
strategy: 'jwt',
},
callbacks: {
authorized({ auth }) {
return !!auth?.user
},
},
providers: [], // providers are added in auth.ts
}
This file contains the shared configuration — pages, session strategy, and an authorized callback — but no bcrypt, no prisma, and no authorize() function. It is safe to import in the Edge Runtime.
The authorized callback is the key: it runs in the middleware and decides whether the request is allowed. When it returns false, NextAuth redirects to the signIn page (/login). When it returns true, the request continues normally.
Step 2 — Update auth.ts
Update auth.ts to import and spread the shared config:
import prisma from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { authConfig } from './auth.config'
export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const email = credentials?.email as string
const password = credentials?.password as string
if (!email || !password) return null
const user = await prisma.user.findUnique({
where: { email },
})
if (!user) return null
const isValid = await bcrypt.compare(password, user.password)
if (!isValid) return null
return {
id: user.id,
name: user.name,
email: user.email,
}
},
}),
],
})
The key change: ...authConfig spreads the shared settings (pages, session strategy) so they are not duplicated. The heavy authorize function (which uses bcrypt and prisma) stays only here.
Step 3 — Create the middleware
Create middleware.ts in the project root (not inside app/):
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
const { auth } = NextAuth(authConfig)
export const middleware = auth
export const config = {
matcher: ['/posts/new'],
}
How it works
- We create a second NextAuth instance using only
authConfig— no bcrypt, no Prisma. This instance can only verify JWT tokens (not authenticate users), which is all the middleware needs. authfrom this lightweight instance checks the session cookie and runs theauthorizedcallback fromauthConfig.- The
authorizedcallback returns!!auth?.user—trueif a session exists,falseif not. When it returnsfalse, NextAuth automatically redirects to thesignInpage (/login). - The
config.matcherarray tells Next.js which routes this middleware applies to. Only/posts/newis protected.
Why two NextAuth instances? The
authexported fromauth.tsis the full-featured instance — it can authenticate users, create sessions, and verify them. The instance inmiddleware.tsis lightweight — it can only verify existing sessions by reading the JWT cookie. Since middleware runs in Edge Runtime, we cannot use any Node.js modules (likebcryptor Prisma) there. By splitting the config, the middleware only imports what it needs.
The file structure after splitting
project-root/
├── app/
│ └── ...
├── auth.config.ts ← NEW — shared config (Edge-safe)
├── auth.ts ← UPDATED — spreads authConfig, adds providers
├── middleware.ts ← NEW — uses lightweight authConfig
└── ...
Understanding the Middleware
The middleware.ts file location
project-root/
├── app/
│ └── ...
├── middleware.ts ← Must be here, NOT inside app/
├── auth.config.ts
├── auth.ts
└── ...
Middleware must be at the project root (next to app/). If you put it inside app/, Next.js will not find it.
The matcher pattern
export const config = {
matcher: ['/posts/new'],
}
The matcher array defines which routes the middleware runs on. Without it, the middleware runs on every route — including API routes, static files, and the login page itself (which would cause an infinite redirect loop).
Common patterns:
// Protect a single page
matcher: ['/posts/new']
// Protect multiple pages
matcher: ['/posts/new', '/settings', '/dashboard']
// Protect all pages under /admin
matcher: ['/admin/:path*']
// Protect everything except public pages
matcher: ['/((?!api|login|register|_next/static|_next/image|favicon.ico).*)']
auth.config.ts vs auth.ts — What Goes Where
| File | Contains | Safe for Edge? |
|---|---|---|
auth.config.ts | Pages, session strategy, shared options | Yes |
auth.ts | Providers, authorize(), bcrypt, Prisma | No (Node.js only) |
Rule of thumb: anything that imports a Node.js module goes in auth.ts. Everything else goes in auth.config.ts.
Why not check the session inside the page?
You could check auth() at the top of app/posts/new/page.tsx and redirect from there:
// This works, but middleware is better
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function NewPostPage() {
const session = await auth()
if (!session) redirect('/login')
// ...
}
This works, but middleware is better because:
- The page component never runs if the user is not authenticated — no wasted server resources.
- The redirect happens earlier in the request lifecycle.
- All route protection logic lives in one file (
middleware.ts) instead of being scattered across pages.
Protecting API Routes
You can also use auth() to protect API routes. Update the POST handler in app/api/posts/route.ts so only authenticated users can create posts:
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const take = Number(searchParams.get('take')) || undefined
const skip = Number(searchParams.get('skip')) || undefined
const [posts, total] = await Promise.all([
prisma.post.findMany({
include: { author: true },
orderBy: { createdAt: 'desc' },
...(take && { take }),
...(skip && { skip }),
}),
prisma.post.count(),
])
return NextResponse.json({ posts, total })
}
export async function POST(request: Request) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
if (!body.title || !body.content) {
return NextResponse.json(
{ error: 'Title and content are required' },
{ status: 400 }
)
}
const post = await prisma.post.create({
data: {
title: body.title,
content: body.content,
authorId: body.authorId,
},
include: { author: true },
})
return NextResponse.json(post, { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create post' },
{ status: 500 }
)
}
}
The only change in POST: we added const session = await auth() and return 401 Unauthorized if there is no session. The GET handler remains public — anyone can read posts.
Verify the Result
Make sure your dev server is running:
pnpm dev
Test the personalized homepage
- Sign out (if signed in) and visit
http://localhost:3000. You should see "Welcome to Superblog". - Sign in and visit the homepage again. You should see "Welcome back, Alice!" (or whatever name the user has).
Test route protection
- Sign out and visit
http://localhost:3000/posts/new. You should be redirected to/login. - Sign in and visit
/posts/newagain. You should see the page (still a placeholder at this point).
Test API protection
# Without a session — should return 401
curl -X POST http://localhost:3000/api/posts \
-H "Content-Type: application/json" \
-d '{"title": "Test", "content": "Test"}'
You should see {"error":"Unauthorized"} with status 401.
Summary & Key Takeaways
| Concept | What it means |
|---|---|
auth() | Server-side session reader — works in Server Components, API routes, and middleware |
useSession() | Client-side session reader — works in Client Components (requires SessionProvider) |
auth.config.ts | Lightweight, Edge-safe config — shared settings without Node.js dependencies |
auth.ts | Full config — spreads authConfig and adds providers with bcrypt/Prisma |
middleware.ts | Runs before every matched route — imports from auth.config.ts (not auth.ts) |
config.matcher | Controls which routes the middleware applies to — prevents infinite loops |
| API protection | Check auth() at the top of API handlers and return 401 if not authenticated |
Looking ahead: In Step 22, we migrate
middleware.tsto the newproxy.tsconvention introduced in Next.js 16. Themiddlewarefile convention is deprecated — the functionality and API remain the same, but the file and export are renamed toproxyto better reflect its role as a thin network layer that intercepts requests before they reach your app.
What is Next
In Step 16, we will build the "Create Post" form using Server Actions — a new Next.js feature that lets you handle form submissions directly on the server without writing an API route.