Next.js App Router Middleware Caching for Fast Fresh Data

Avatar
M

Maksudur Rahman

Software Engineer•
1Views
5mRead
0Reactions

Developers often struggle to cache API responses in Next.js App Router middleware without serving stale data. This guide shows how to implement edge‑middleware caching with stale‑while‑revalidate, keeping latency low while guaranteeing fresh content.

TL;DR / Quick Summary

  • Prerequisites: Next.js 14, TypeScript 5.5, Node.js 20, Vercel account.

  • Add a proxy.ts (or middleware.ts) at the app root.

  • Use the Edge Cache API with Cache-Control: stale-while-revalidate.

  • Invalidate via cache‑tags or manual caches.default.delete.

  • Deploy as a Vercel Edge Function for sub‑second response times.

Prerequisites & Environment

Tool

Version

Node.js

20.x

Next.js

14.x

TypeScript

5.5

Vercel CLI (optional)

latest

Make sure your project is initialized with the App Router (app/directory) and the experimental.edge flag is enabled (default in Next.js 14).

1. Create Edge Middleware (proxy.ts)

Create the file/app/proxy.ts (the new name for the deprecated middleware.js). This file runs on the Edge network before the request reaches your route handlers.

// /app/proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export const config = {
  // Apply to all API routes under /api/*
  matcher: ["/api/:path*"],
};

export async function middleware(request: NextRequest) {
  const url = request.nextUrl.clone();

  // Build a cache‑key that mirrors the incoming request
  const cacheKey = new Request(url.toString(), {
    method: request.method,
    headers: request.headers,
  });

  // 1️⃣ Try to serve a cached response
  const cached = await caches.default.match(cacheKey);
  if (cached) {
    // Kick off a background revalidation so the next request gets fresh data
    revalidate(cacheKey);
    return cached;
  }

  // 2️⃣ No cache – forward to the origin (your API route)
  const originResp = await fetch(url.toString(), {
    method: request.method,
    headers: request.headers,
    // `revalidate: 0` tells Next.js not to apply its own ISR logic here
    next: { revalidate: 0 },
  });

  // 3️⃣ Attach a SWR‑compatible Cache‑Control header
  const cacheControl = "public, max-age=0, stale-while-revalidate=30"; // 30 s SWR window
  const resp = new NextResponse(originResp.body, {
    status: originResp.status,
    headers: {
      ...Object.fromEntries(originResp.headers.entries()),
      "Cache-Control": cacheControl,
    },
  });

  // 4️⃣ Store the fresh response for the next request
  await caches.default.put(cacheKey, resp.clone());

  return resp;
}

// Background revalidation – fire‑and‑forget
async function revalidate(request: Request) {
  try {
    const fresh = await fetch(request);
    await caches.default.put(request, fresh.clone());
  } catch {
    // Silently ignore – stale content will still be served
  }
}

How it works

  1. Edge cache is checked first. If a hit occurs, the response is returned immediately.

  2. A background revalidate call refreshes the cache without delaying the client.

  3. The Cache-Control header tells downstream CDNs (Vercel Edge) to keep the stale copy for 30 seconds while a fresh copy is fetched.

2. Handling Dynamic Routes

For routes like/api/users/[id], you need a cache key that includes the dynamic segment.

// /app/proxy.ts (excerpt for dynamic routes)
export const config = {
  matcher: ["/api/users/:id*"],
};

// Inside middleware – the URL already contains the id, so the same `cacheKey`
// logic works out‑of‑the‑box. If you want per‑user isolation, add a custom header:
const cacheKey = new Request(url.toString(), {
  method: request.method,
  headers: new Headers({
    "x-user-id": url.searchParams.get("id") ?? "unknown",
  }),
});

3. Cache‑Tag Invalidation Strategy

When data changes (e.g., a product update), you can purge related Edge cache entries using cache tags. Next.js does not expose a built‑in tag API, but you can emulate it with a small helper.

// /lib/cacheTag.ts
export async function purgeTag(tag: string) {
  // Vercel Edge does not support tag listing, so we store a map in KV (or Redis)
  const keys = await getKeysForTag(tag); // implement your own store
  for (const key of keys) {
    await caches.default.delete(new Request(key));
  }
}

// Usage in an API route after a successful mutation
import { purgeTag } from "@/lib/cacheTag";

export async function POST(req: Request) {
  // ...update DB...
  await purgeTag("products-list");
  return new Response(JSON.stringify({ ok: true }), { status: 200 });
}

In the middleware, attach the tag to the cache entry:

await caches.default.put(cacheKey, resp.clone(), {
  // @ts-ignore – Edge API accepts a third options arg for tags (future‑proof)
  tags: ["products-list"],
});

4. Deploying to Vercel Edge

  1. Run vercel login and vercel in your project root.

  2. Ensure output: "standalone"is not set – Edge Functions require the default serverless mode.

  3. In vercel.json, optionally set:

{
  "functions": {
    "app/proxy.ts": {
      "runtime": "edge"
    }
  }
}
  1. Deploy with vercel --prod. The middleware will be served from the Edge network, giving sub‑millisecond latency.

5. Comparison of Cache‑Control Strategies

Use‑case

Cache‑Control Header

Revalidate Window

Static catalog data

public, max-age=86400, stale-while-revalidate=300

24 h cache, 5 min SWR

Frequently changing news

public, max-age=0, stale-while-revalidate=30

0 s cache, 30 s SWR

Private user profile

private, max-age=0, no-store

Never cached

Common Pitfalls & Troubleshooting

Symptom

Likely Cause

Fix

Middleware never runs

matcher pattern is wrong or file placed outside app/

Verify export const config.matcher matches the request path and that the file is app/proxy.ts (or middleware.ts for older versions).

Stale data served forever

Missing Cache-Control header or max-age> 0

Ensure Cache-Control: public, max‑age=0, stale-while-revalidate=…is set on the response.

500 error on revalidation

fetch inside revalidate throws due to missing auth headers

Forward required auth headers when calling fetch inside revalidate.

Vercel deploy shows “Edge Function not supported”

Using output: "standalone"or older Next.js version

Remove output config and upgrade to Next.js 14+.

FAQ

How do I limit the size of cached responses? Set a Cache-Control: max‑size=…directive (supported by Vercel Edge) or manually check response.body size before caches.default.put.

Can I cache POST requests? Edge cache only stores GET‑compatible responses. For POST, return a Location header pointing to a GET endpoint that can be cached.

What happens if the revalidation fetch fails? The background revalidate function catches errors silently, so the stale response continues to be served until the next successful fetch.

Do I need to clear the cache when I deploy a new version? Vercel automatically invalidates Edge caches on deployment, but if you use custom tags you may want to call purgeTag for critical data sets.

Related Engineering Guides

Connect with KoderSolution 🚀

Master modern software engineering, scalable architectures, and advanced backend systems.

What's your setup?What's your production setup for this scenario? Have you encountered similar architectural bottlenecks or edge-case gotchas?

Recommended Resources & Courses

Partner Links
Maksudur Rahman
M
Written by•Software Engineer

Maksudur Rahman

Senior Developer Advocate & System Lead Moderator.

React to this article