Week 21: Next.js — Middleware, Metadata & Caching

The last Next.js week ties off three things every production app needs: a place to run logic before a request reaches a route at all, a way to make pages describe themselves correctly to search engines and link previews, and a clear mental model for the caching layers you've been relying on implicitly since Week 18 — including the one that lets a page be almost-static and still stay fresh.

Module 11 of 17 Week 21 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Gate and redirect requests with proxy.ts
  • Generate correct, per-page SEO and Open Graph metadata
  • Explain Next.js 16's Cache Components model and set up time-based and on-demand cache lifetimes

1. proxy.ts (formerly Middleware)

A single proxy.ts at the project root runs before a request reaches any route — Server Component, Route Handler, or Server Action alike. It can inspect the request, redirect, rewrite the URL, or attach headers, closest in spirit to Week 7's protected-route checks, except it runs on the server, before any page code executes at all.

proxy.ts
import { NextRequest, NextResponse } from 'next/server';

export function proxy(request: NextRequest) {
  const sessionCookie = request.cookies.get('session');

  if (!sessionCookie && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next(); // let the request continue as normal
}

export const config = {
  matcher: ['/dashboard/:path*'], // only run the proxy for these paths
};

The matcher config is important for performance — without it, this would run on every request, including static assets. Scoping it to just the paths that need the check (here, anything under /dashboard) keeps everything else fast.

Renamed in Next.js 16 — and it now runs on Node.js by default

middleware.ts was renamed to proxy.ts (the exported function from middleware to proxy) in Next.js 16 — a Next codemod handles the rename mechanically in an existing project. The bigger practical change: proxy.ts runs on the full Node.js runtime by default, not the restricted Edge runtime middleware used — real Node APIs and easier debugging are available directly here now. middleware.ts still exists specifically for the cases that genuinely need the lighter, globally-distributed Edge runtime.

proxy.ts is still a fast, coarse check — not your only auth layer

Even with full Node.js available, keep this file to cheap, fast checks — is a session cookie present at all. Still verify the session properly (Week 25) in the actual Server Component or Server Action that serves the page, where you have direct database access to confirm the session is genuinely valid, not just present.

2. Metadata & SEO

Every page.tsx or layout.tsx can export a metadata object — or, when the values depend on fetched data, a generateMetadata function — and Next.js renders the correct <title> and <meta> tags into the page's <head> automatically, no separate SEO library required.

app/products/[productId]/page.tsx
import { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ productId: string }>;
}): Promise<Metadata> {
  const { productId } = await params;
  const product = await getProduct(productId); // reuses the same fetch, deduplicated

  return {
    title: `${product.name} — Acme Store`,
    description: product.shortDescription,
    openGraph: {
      title: product.name,
      images: [product.imageUrl],
    },
  };
}

export default async function ProductDetailPage({ params }: { params: Promise<{ productId: string }> }) {
  // ...
}

A call to getProduct inside both generateMetadata and the page component itself sounds like a duplicated fetch — it isn't. Next.js automatically deduplicates identical fetch requests made during a single render pass, so the underlying data is only actually fetched once.

A static layout.tsx without per-page data just exports a plain object instead of a function, and child routes inherit and can override individual fields:

app/layout.tsx
export const metadata: Metadata = {
  title: { default: 'Acme Store', template: '%s — Acme Store' },
  description: 'Everything you need, delivered fast.',
};

3. The Caching Model

Week 19's SSR/SSG/client-rendering choice sits on top of four distinct caching layers working together. Knowing which one is responsible for surprising behavior is the difference between a five-minute fix and an afternoon of confusion:

Older Next.js versions defaulted to caching fetch results and static page output automatically, and asked you to opt out per call (cache: 'no-store') when you needed fresh data. Next.js 16's Cache Components model — enabled with cacheComponents: true in next.config.ts, and the direction all new App Router projects should take — inverts that: everything is dynamic and uncached by default. Nothing is cached across requests unless a specific piece of code explicitly opts in.

app/products/queries.ts — opting a function into caching
import { cacheLife, cacheTag } from 'next/cache';

export async function getProducts() {
  'use cache'; // this function's return value is now cacheable

  cacheLife('hours');   // how long the cache stays fresh before a background refresh
  cacheTag('products'); // a label Week 20's updateTag/revalidateTag can target

  const res = await fetch('https://api.example.com/products');
  return res.json();
}

"use cache" works the same way "use client" (Week 18) and "use server" (Week 20) do — a directive marking a function (or an entire file, or a component) as belonging to a different execution model. Here, it means "cache whatever this function returns." cacheLife(...) sets how long that cached result stays fresh before Next.js refreshes it in the background; cacheTag(...) gives it a name Week 20's updateTag/ revalidateTag can invalidate on demand. Without any of this, calling getProducts from a Server Component just re-fetches on every single request — genuinely dynamic, exactly like everything else by default now.

One caching layer still exists outside this model entirely: the Router Cache, a client-side, in-memory cache of visited route segments in the browser, making back/forward navigation between already-visited pages feel instant. It's unrelated to server-side data caching — it's about not re-rendering a page the browser already has, purely on the client.

4. Cache Lifetimes & On-Demand Invalidation

cacheLife's named profiles ('seconds', 'minutes', 'hours', 'days', 'weeks', 'max') control how long a "use cache" function's result stays fresh before Next.js regenerates it — the same idea the older Incremental Static Regeneration model expressed with a single revalidate number, now expressed per cached function instead of per route.

app/products/[productId]/page.tsx — time-based freshness
async function getProduct(productId: string) {
  'use cache';
  cacheLife('hours'); // regenerate this cached result at most once per hour
  cacheTag('products', `product-${productId}`);

  const res = await fetch(`https://api.example.com/products/${productId}`);
  return res.json();
}

export default async function ProductDetailPage({ params }: { params: Promise<{ productId: string }> }) {
  const { productId } = await params;
  const product = await getProduct(productId);
  return <h1>{product.name}</h1>;
}

The first request after an hour has passed still gets the (now-stale) cached result instantly — Next.js regenerates it behind the scenes and swaps the cache in for the next reader, rather than making that one unlucky request wait. For updates that need to appear the moment they happen — publishing a price change right now, not "sometime in the next hour" — Week 20's updateTag/revalidateTag called from inside a Server Action is the on-demand counterpart: the same regenerate-and-cache behavior, triggered immediately by your own code instead of a timer.

Time-based and on-demand freshness aren't an either/or choice

A product page can set cacheLife('hours') as a safety-net baseline (nothing is ever more than an hour stale) while an admin's "Save" button also calls updateTag('products') for that exact product — instant freshness on an explicit write, with the time-based profile catching anything that changed outside the app's own mutation paths.

5. Putting It Together

A realistic route touches all three of this week's topics at once: proxy.ts gates access, generateMetadata makes it shareable, and a sensible cache strategy keeps it fast without serving stale data indefinitely.

app/dashboard/reports/[reportId]/page.tsx
// proxy.ts already confirmed a session cookie exists for anything under /dashboard

async function getReport(reportId: string) {
  'use cache';
  cacheLife('minutes'); // background-refresh at most every few minutes
  cacheTag('reports', `report-${reportId}`);

  return db.report.findUnique({ where: { id: reportId } });
}

export async function generateMetadata({ params }: { params: Promise<{ reportId: string }> }) {
  const { reportId } = await params;
  const report = await getReport(reportId); // deduplicated against the call below
  return { title: `${report.title} — Reports` };
}

export default async function ReportPage({ params }: { params: Promise<{ reportId: string }> }) {
  const { reportId } = await params;
  const report = await getReport(reportId);
  return <ReportView report={report} />;
}

Each layer solves a genuinely different problem: proxy.ts answers "should this request even proceed," metadata answers "how does this page describe itself to everything outside the browser tab," and "use cache" plus its cacheLife answers "how fresh does this specific content need to be." Treating them as one combined decision, rather than reaching for the strictest option (no caching, full re-auth every request) everywhere, is what keeps a real Next.js app both correct and fast.

6. Hands-on Exercise

Hands-on

Gate, describe and cache the product catalog properly

Apply all three topics to the catalog you've been building since Week 18 — using Next.js 16's proxy.ts and Cache Components model throughout.

Requirements:

  1. Add a proxy.ts that redirects any request to /admin/* to /login when a (simulated) session cookie is missing, scoped with a matcher so it doesn't run on every request.
  2. Add generateMetadata to the product detail page from Week 18, setting a per-product title and an openGraph.images entry, and confirm in your browser's view-source that the correct <title> and Open Graph tags render per product.
  3. Enable cacheComponents: true in next.config.ts, wrap your products-list data function in "use cache" with cacheLife('minutes') and cacheTag('products'), and add a comment explaining, in your own words, what happens to a request that arrives a few minutes after the last regeneration.
  4. In the "Add Product" Server Action from Week 20, call updateTag('products') and, separately, updateTag(`product-${newProductId}`) for the specific new product's own cache tag — explain in a comment why both are needed rather than just one.
  5. Identify, in a short written note, why a deleted product might still briefly appear in the list if you forgot the updateTag call, versus why it would still appear after a hard refresh even with the call — the second case is the client-side Router Cache, not the server cache at all.
Hint

"Still stale immediately after the mutation" almost always points to a missing or mistagged "use cache"/updateTag pairing on the server; "still stale only after a hard refresh, but fine on client-side navigation" points to the Router Cache instead — they fail differently, which is the fastest way to tell them apart.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is scoping proxy.ts with a matcher important, rather than letting it run unconditionally?

Without a matcher, this code runs on every single request, including static assets and routes that never needed the check — a real, if often small, performance cost paid on traffic that gets nothing out of it. Scoping it to only the paths that actually need the logic keeps everything else at full speed.

Q2

In Section 2's example, getProduct is called inside both generateMetadata and the page component. Does this mean the data is fetched twice?

No — Next.js automatically deduplicates identical fetch requests made during a single render pass (request memoization), so the underlying network/database call happens once, and both call sites receive the same resolved result.

Q3

A cached function has cacheLife('hours'). A visitor requests the page 61 minutes after the last regeneration. What do they see, and what happens next?

They still receive the existing cached (now-stale) result immediately, with no added wait. Next.js regenerates it in the background after serving that response, and swaps in the fresh version for the next reader — the one unlucky-timing request never blocks on regeneration itself.

Q4

What's the practical difference between a time-based cacheLife('hours') profile and calling updateTag from a Server Action?

Both refresh a cached result, but cacheLife triggers on a timer regardless of whether anything actually changed, while updateTag triggers immediately, exactly when your own code knows a write happened — useful together, since the timer acts as a safety net for changes outside the app's own mutation paths, and updateTag gives instant freshness for changes the app itself made.