Module P-8·24 min read

generateMetadata vs static metadata, generateViewport(), dynamic OG images with ImageResponse, generateImageMetadata() for multiple images, generateSitemaps() for large catalogs, and draftMode() for CMS preview.

P-5 — Middleware: Routing Logic at the Edge

Who this is for: Practitioners who understand the App Router and have used auth in P-3, and now need to work with Middleware directly — understanding its execution model, writing production-grade matchers, and knowing exactly what it can and cannot do. Middleware is powerful but bounded; understanding those bounds is what separates clean Middleware code from subtle bugs.


What Middleware Actually Is

middleware.ts at the project root defines a function that runs on the edge runtime — a V8 isolate, not a full Node.js process — before every matched request. It intercepts the request, can inspect it, modify response headers, set cookies, or redirect, and then either passes the request through or returns a response directly.

The edge runtime has two defining constraints:

  1. No Node.js APIs. No fs, no path, no crypto (Web Crypto is available), no native Node.js modules. This is intentional — the edge runtime is designed to be deployable at CDN edge locations globally, not just in a central Node.js server.

  2. Performance budget: 1ms target. Middleware runs on every matched request, before the page renders. Every millisecond spent in Middleware is added to every page's Time to First Byte. Keep it fast — read cookies, check a JWT, do a redirect. Don't query databases, don't make slow API calls.

ts

The Matcher — Controlling Which Routes Trigger Middleware

Without a matcher, Middleware runs on every request including static files, Next.js internal routes, and image optimization requests. This wastes compute and can cause surprising behaviour. Always define a matcher.

String matcher:

ts

Array matcher:

ts

Regex matcher with negation (most common pattern):

ts

The pattern (?!...) is a negative lookahead — it matches everything except the listed patterns. This is the most robust matcher for production use: it skips static assets entirely, running Middleware only on page routes and API routes.

Conditional logic in the function itself:

ts

Matchers and in-function conditions both work. Matchers are evaluated first and are more efficient because they prevent the function from running at all. In-function conditions are useful for nuanced logic that regex can't express cleanly.


Reading Cookies and Headers

ts

Vercel-specific headers like x-vercel-ip-country, x-vercel-ip-city, and x-vercel-ip-continent give you geo-location data at the edge. Useful for geo-based redirects, A/B tests by region, or content personalisation. On self-hosted deployments, these headers don't exist — use a separate geo-IP service if needed.


Writing Cookies and Headers

Middleware can set cookies and modify response headers, but with an important constraint: you can only write cookies and headers on the response, not on the request that gets passed to the page.

ts

Passing custom headers to the page via NextResponse.next({ request: { headers } }) is the pattern for forwarding Middleware-derived data (user ID from JWT, A/B test variant, locale) to Server Components without making them re-read cookies.


Authentication in Middleware — The Right Pattern

From P-3, you've seen Auth.js's auth() wrapper for Middleware. Here's what's happening under the hood and how to write it manually when you need custom logic:

ts

jose is the standard edge-compatible JWT library — it uses Web Crypto APIs that work in V8 isolates. Don't use jsonwebtoken in Middleware; it uses Node.js crypto APIs that aren't available in the edge runtime.

In the Server Component, read the forwarded header:

tsx

This pattern avoids re-verifying the JWT in the Server Component — Middleware already did it. The Server Component just reads the pre-verified data from the forwarded header.


Redirects and Rewrites

Redirect:

ts

Rewrite:

ts

Rewrites are the pattern for locale routing — detect the user's preferred locale from the Accept-Language header or a cookie, then rewrite the request to the localised version of the page without the user seeing /en/ in their URL:

ts

draftMode() — CMS Preview

draftMode() is a Next.js API that toggles "draft mode" for a request — allowing you to serve unpublished content from a CMS to authenticated editors while serving published content to regular visitors.

The typical implementation:

ts
tsx

Draft mode sets a special cookie that bypasses the page cache — editors see live CMS content without triggering a rebuild.


Rate Limiting at the Edge

Middleware is where you put rate limiting — it runs before your application code, making it the cheapest place to reject bad actors:

ts

Upstash Redis is the standard choice for edge rate limiting — it's HTTP-based (works in V8 isolates), globally distributed, and has a free tier. The @upstash/ratelimit package implements sliding window, fixed window, and token bucket algorithms.

Important: rate limiting in Middleware adds one Upstash Redis read to every API request. At high volume, verify this doesn't exceed your Upstash plan limits. For very high-traffic rate limiting, consider doing it at the load balancer or CDN level instead.


A/B Testing

Middleware is the right layer for A/B test assignment — before any page renders, Middleware reads or assigns a variant cookie, rewrites the request to the right variant URL, and passes the variant through:

ts

/pricing/control and /pricing/treatment are regular pages in the App Router (app/pricing/control/page.tsx, app/pricing/treatment/page.tsx). The user always sees /pricing in their URL.


What Middleware Cannot Do

Understanding the limits prevents wasted debugging time:

Cannot use Node.js APIs. fs, path, child_process, native Node.js crypto — none of these are available. Use Web APIs: crypto.randomUUID(), fetch(), Headers, URL, TextEncoder.

Cannot import server-only modules. If a module imports anything that uses Node.js APIs (most ORMs, bcrypt, most auth libraries), it can't run in Middleware.

Cannot run slow operations. A 100ms database query in Middleware adds 100ms to every page load. Use Redis (HTTP) for the fastest external storage access, and only when necessary.

Cannot return React components. Middleware returns NextResponse (HTTP responses), not React trees. For auth redirects and rewrites, Middleware is correct. For rendering UI, Middleware is not.

Cannot directly call Server Actions. Middleware runs before the React render. Server Actions are part of the React render.


Where We Go From Here

P-6 is the largest module in the Practitioner phase — the four-layer caching model that determines how data flows from your database to the user. You've seen bits of this in F-4 (fetch caching), P-1 (unstable_cache), and P-3 (session-based bypass). P-6 puts the entire picture together: how the Request Memoization, Data Cache, Full Route Cache, and Router Cache interact, and how the new use cache directive in Next.js 15 changes the model significantly.

Understanding the caching model is what separates applications that cost $50/month to run from applications that cost $50/day.


Knowledge Check

Which of the following is true about Next.js Middleware and the Edge Runtime?


When forwarding data from Middleware (such as an extracted User ID) to Server Components, what is the correct approach?


What Next.js API is typically used in combination with Middleware to support CMS Preview workflows?

Test your knowledge with more question sets

Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.