Next.js Lesson 17 – Middleware | Dataplexa
Lesson 17

Middleware

Learn to intercept requests before they hit your pages, handle authentication guards, and add custom logic to your Next.js application flow.

Middleware sits between your users and your pages. Every request passes through it first. Think of middleware like a security guard at a building entrance — they check everyone before letting them through. Unlike regular React components that run in the browser, middleware runs on the edge. The edge means servers close to your users around the world. When someone visits NewsWave from Japan, middleware runs on a server in Japan. From Germany? A server in Germany handles it. This keeps things fast. The power comes from interception. You can redirect users, rewrite URLs, add headers, or block requests entirely. All before Next.js renders any page. Traditional React apps can't do this — they only run after the page loads.

What Middleware Does

Middleware handles cross-cutting concerns. These are features that affect multiple pages across your app. Authentication, internationalization, A/B testing, bot detection — all perfect middleware use cases. The NewsWave team needs several middleware features. They want to redirect old article URLs to new ones. Block suspicious requests. Add security headers to every response. Check if users are logged in before they access admin pages. Without middleware, you'd add this logic to every page component. That means duplicated code. Inconsistent behavior. Easy to miss pages. Middleware centralizes everything in one place. Here's what middleware can do:
Request Control
Redirect, rewrite, or block requests before pages load
Authentication Guards
Check login status and protect private routes
Header Modification
Add security headers, CORS, or custom metadata
URL Rewriting
Transform URLs without changing the browser address

Creating Your First Middleware

Middleware lives in a special file called middleware.js. You create this file in your project root — the same level as your app folder. The file must export a function called middleware. Next.js calls this function for every request. The function receives a request object with information about the incoming request.
File structure
📁 newswave/
📄 middleware.js ← Create this file
📁 app/
📄 page.js
📁 articles/
📄 package.json
Your first middleware starts simple. Log every request to see what's happening:
// middleware.js — runs before every page request
export function middleware(request) {
  // Get the URL that the user is trying to visit
  console.log('Request URL:', request.nextUrl.pathname)
  
  // Let the request continue to its destination
  return
}
Terminal
$ npm run dev
Next.js 14.0.0
Local: http://localhost:3000
✓ Ready in 2.1s
Request URL: /
Request URL: /_next/static/chunks/webpack.js
Request URL: /favicon.ico
What just happened?
Your middleware ran for every request — even static files. The console shows each URL that users tried to access. Next.js passes a request object with details about where they want to go. Try this: visit different pages and watch the terminal output change.

Response Objects and Control Flow

Middleware can do three things with requests. Let them through unchanged. Redirect users somewhere else. Or rewrite the URL to serve different content. You control this with response objects. NextResponse creates these response objects. Import it from next/server. Think of NextResponse like a traffic controller — it tells requests where to go.
// Import NextResponse to control request flow
import { NextResponse } from 'next/server'

export function middleware(request) {
  // Get the current path user is visiting
  const path = request.nextUrl.pathname
  
  // Log each request for debugging
  console.log(`Middleware: ${path}`)
}
Terminal
$ npm run dev
Middleware: /
Middleware: /articles
Middleware: /search
Now you can control the flow. Redirects send users to a different URL — their browser address changes. Rewrites serve different content but keep the same URL in the browser:
import { NextResponse } from 'next/server'

export function middleware(request) {
  const path = request.nextUrl.pathname
  
  // Redirect old URLs to new ones (browser shows new URL)
  if (path === '/old-news') {
    return NextResponse.redirect(new URL('/articles', request.url))
  }
  
  // Rewrite URLs (browser keeps showing original URL)
  if (path === '/breaking') {
    return NextResponse.rewrite(new URL('/articles?category=breaking', request.url))
  }
}
Terminal
Middleware: /old-news
→ Redirecting to /articles
Middleware: /breaking
→ Rewriting to /articles?category=breaking
What just happened?
NextResponse gives you three options: redirect (changes browser URL), rewrite (serves different content with same URL), or return nothing (let request continue). The new URL() constructor needs the full URL, so you pass the original request.url as the base. Try this: create an /old-news route and see how it redirects.

Middleware Configuration

By default, middleware runs on every single request. That includes static files, images, API routes — everything. Most of the time you don't want this. You want middleware to run only on specific paths. The config object controls which paths trigger middleware. Export it from your middleware file. Use the matcher property to specify patterns. Think of matcher like a filter. Only requests that match your patterns will run your middleware function. Everything else bypasses middleware completely.
import { NextResponse } from 'next/server'

export function middleware(request) {
  console.log('Protected route accessed:', request.nextUrl.pathname)
  // Your middleware logic here
}

// Only run middleware on these paths
export const config = {
  matcher: ['/admin/:path*', '/profile/:path*']
}
Terminal
$ npm run dev
Middleware will only run on /admin/* and /profile/* routes
Static files and other routes bypass middleware
✓ Ready in 1.8s
Matcher patterns use glob syntax. :path* means match any sub-path. /admin/:path* matches /admin, /admin/users, /admin/settings/general — everything under admin. You can exclude paths too. NewsWave wants middleware on all pages except API routes and static assets:
// Run middleware on all paths except these excluded ones
export const config = {
  matcher: [
    // Match all routes except API and static files
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ]
}
Terminal
Middleware runs on all pages
But skips /api, /_next/static, /_next/image, /favicon.ico
✓ More efficient — no middleware on static assets
What just happened?
The config object filters which requests run middleware. Glob patterns like :path* match multiple routes. Negative lookahead (?!pattern) excludes paths. This keeps middleware fast by avoiding unnecessary runs on static files. Try this: add console logs and visit different pages to see which ones trigger middleware.

Authentication Middleware

Authentication middleware is middleware's killer feature. Traditional React apps check authentication in every protected component. That's slow and inconsistent. Users see protected content for a split second before redirecting. Middleware runs before any page renders. Users never see protected content if they shouldn't. The check happens on the server, closer to your database. Much faster and more secure. The pattern is simple. Check if the user has a valid session token in their cookies. If yes, let them through. If no, redirect to login. All authentication logic centralized in one place.
import { NextResponse } from 'next/server'

export function middleware(request) {
  // Get the user's session token from cookies
  const token = request.cookies.get('session-token')
  const path = request.nextUrl.pathname
  
  // Check if user is trying to access admin area
  if (path.startsWith('/admin')) {
    // No token means not logged in - redirect to login
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }
}
Terminal
User visits /admin without token
→ Redirected to /login
User visits /admin with valid token
→ Allowed through to /admin
✓ No flash of protected content
Real authentication middleware needs token validation. You can't just check if a token exists — you need to verify it's valid and not expired. Here's a more robust version:
import { NextResponse } from 'next/server'

export function middleware(request) {
  const token = request.cookies.get('session-token')?.value
  const path = request.nextUrl.pathname
  
  // Protected routes that require authentication
  const protectedPaths = ['/admin', '/profile', '/dashboard']
  const isProtectedPath = protectedPaths.some(p => path.startsWith(p))
  
  if (isProtectedPath) {
    // No token at all - definitely not authenticated
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
    
    // Here you would validate the token with your auth service
    // For now, we'll assume any token is valid
    console.log(`Access granted to ${path}`)
  }
}
Terminal
Protected routes: /admin, /profile, /dashboard
User visits /profile
→ Checking session-token cookie
✓ Access granted to /profile
What just happened?
Authentication middleware checks cookies before page render. The cookies.get() method reads browser cookies. Array.some() checks if the current path matches any protected route. In production, you'd validate the token against your authentication service. Try this: set different cookies in your browser dev tools and test the middleware behavior.

Headers and Security

Middleware can modify request and response headers. Headers carry metadata about requests and responses. Security headers protect your app from common attacks. CORS headers control which domains can access your API. Response headers get added to every response your app sends. This makes middleware perfect for security headers that should apply site-wide. Content Security Policy, CORS settings, cache control — all handled in one place.
import { NextResponse } from 'next/server'

export function middleware(request) {
  // Create a response to modify headers
  const response = NextResponse.next()
  
  // Add security headers to every response
  response.headers.set('X-Frame-Options', 'DENY') // Prevent embedding in iframes
  response.headers.set('X-Content-Type-Options', 'nosniff') // Prevent MIME sniffing
  response.headers.set('Referrer-Policy', 'origin-when-cross-origin') // Control referrer info
  
  return response
}
Terminal
✓ Security headers added to all responses
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: origin-when-cross-origin
You can read request headers too. User-Agent tells you about the user's browser. Accept-Language shows their language preferences. Authorization carries authentication tokens:
import { NextResponse } from 'next/server'

export function middleware(request) {
  // Read incoming request headers
  const userAgent = request.headers.get('user-agent')
  const acceptLanguage = request.headers.get('accept-language')
  const authorization = request.headers.get('authorization')
  
  // Block suspicious user agents (simple bot detection)
  if (userAgent && userAgent.includes('malicious-bot')) {
    return NextResponse.json({ error: 'Access denied' }, { status: 403 })
  }
  
  // Log user language for analytics
  console.log('User language preference:', acceptLanguage)
  
  return NextResponse.next()
}
Terminal
Request from Chrome browser
User language preference: en-US,en;q=0.9
Request from malicious-bot
✗ Access denied (403)
What just happened?
Middleware can read request headers with request.headers.get() and set response headers with response.headers.set(). NextResponse.json() returns JSON responses with custom status codes. Security headers protect against common attacks by telling browsers how to handle your content. Try this: open browser dev tools and check the Network tab to see these headers on your responses.

Real-World Middleware Examples

Professional Next.js apps combine multiple middleware patterns. NewsWave needs authentication, URL redirects, security headers, and request logging. Here's how to structure complex middleware:
import { NextResponse } from 'next/server'

export function middleware(request) {
  const path = request.nextUrl.pathname
  const response = NextResponse.next()
  
  // 1. Security headers (apply to all responses)
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  
  // 2. Legacy URL redirects
  if (path === '/news') {
    return NextResponse.redirect(new URL('/articles', request.url))
  }
  
  return response
}
// Complete NewsWave middleware with all features
import { NextResponse } from 'next/server'

export function middleware(request) {
  const { pathname } = request.nextUrl
  const token = request.cookies.get('auth-token')?.value
  
  // Create response object for header modifications
  let response = NextResponse.next()
  
  // Authentication check for protected routes
  const protectedRoutes = ['/admin', '/profile', '/dashboard']
  if (protectedRoutes.some(route => pathname.startsWith(route))) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }
  
  // URL redirects for SEO
  const redirects = {
    '/news': '/articles',
    '/blog': '/articles', 
    '/story': '/articles'
  }
  
  if (redirects[pathname]) {
    return NextResponse.redirect(new URL(redirects[pathname], request.url))
  }
  
  // Add security headers to all responses
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('Referrer-Policy', 'origin-when-cross-origin')
  
  return response
}
Terminal
Request: /admin (no auth token)
→ Redirected to /login
Request: /news
→ Redirected to /articles
Request: /articles
✓ Security headers added, request allowed
And the configuration to make it efficient:
// Only run middleware on page routes, not static assets
export const config = {
  matcher: [
    // Skip API routes and static files
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ]
}
Common Middleware Mistakes
Don't run expensive operations in middleware — it slows down every request. Avoid database calls or external API requests. Don't forget the config matcher — without it, middleware runs on static files too. Always return a NextResponse object, even if it's just NextResponse.next().
What just happened?
Professional middleware combines multiple concerns in one function. Early returns handle redirects and authentication. The response object accumulates headers throughout the function. Object lookups make redirect mapping clean and maintainable. The matcher config ensures middleware only runs where needed. Try this: add console.log statements to track which middleware logic runs for different requests.

Quiz

1. The NewsWave team wants to add authentication checks. What's the main advantage of using middleware instead of checking authentication in page components?


2. NewsWave wants to serve different content at /breaking but keep the URL unchanged in the browser. Which NextResponse method should they use?


3. NewsWave's middleware is running on static files and slowing down the app. Which config would fix this by excluding API routes and static assets?


Up Next: Authentication Basics

Build complete user authentication with login, signup, and session management using NextAuth.js and modern authentication patterns.