Session Management & Route Protection with Next.js Middleware

Step 15 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

By the end of this step, your app will:

  • Read the session in Server Components to show personalized content
  • Protect the /posts/new route 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

  1. Two Ways to Read the Session
  2. Reading the Session in Server Components
  3. Update the Homepage
  4. Protecting Routes with Middleware
  5. Understanding the Middleware
  6. Protecting API Routes
  7. Verify the Result
  8. Summary & Key Takeaways

Two Ways to Read the Session

↑ Index

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.

MethodWhere it worksHow it works
useSession()Client ComponentsReads from SessionProvider context (client-side)
auth()Server Components, API routes, middlewareReads 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

↑ Index

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

↑ Index

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' and const 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 Componentauth() 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

↑ Index

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

  1. 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.
  2. auth from this lightweight instance checks the session cookie and runs the authorized callback from authConfig.
  3. The authorized callback returns !!auth?.usertrue if a session exists, false if not. When it returns false, NextAuth automatically redirects to the signIn page (/login).
  4. The config.matcher array tells Next.js which routes this middleware applies to. Only /posts/new is protected.

Why two NextAuth instances? The auth exported from auth.ts is the full-featured instance — it can authenticate users, create sessions, and verify them. The instance in middleware.ts is 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 (like bcrypt or 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

↑ Index

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

FileContainsSafe for Edge?
auth.config.tsPages, session strategy, shared optionsYes
auth.tsProviders, authorize(), bcrypt, PrismaNo (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:

  1. The page component never runs if the user is not authenticated — no wasted server resources.
  2. The redirect happens earlier in the request lifecycle.
  3. All route protection logic lives in one file (middleware.ts) instead of being scattered across pages.

Protecting API Routes

↑ Index

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

↑ Index

Make sure your dev server is running:

pnpm dev

Test the personalized homepage

  1. Sign out (if signed in) and visit http://localhost:3000. You should see "Welcome to Superblog".
  2. Sign in and visit the homepage again. You should see "Welcome back, Alice!" (or whatever name the user has).

Test route protection

  1. Sign out and visit http://localhost:3000/posts/new. You should be redirected to /login.
  2. Sign in and visit /posts/new again. 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

↑ Index

ConceptWhat 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.tsLightweight, Edge-safe config — shared settings without Node.js dependencies
auth.tsFull config — spreads authConfig and adds providers with bcrypt/Prisma
middleware.tsRuns before every matched route — imports from auth.config.ts (not auth.ts)
config.matcherControls which routes the middleware applies to — prevents infinite loops
API protectionCheck auth() at the top of API handlers and return 401 if not authenticated

Looking ahead: In Step 22, we migrate middleware.ts to the new proxy.ts convention introduced in Next.js 16. The middleware file convention is deprecated — the functionality and API remain the same, but the file and export are renamed to proxy to 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.