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.
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.
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.
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:
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.
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.
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.
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.
// 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
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:
- Add a
proxy.tsthat redirects any request to/admin/*to/loginwhen a (simulated) session cookie is missing, scoped with amatcherso it doesn't run on every request. - Add
generateMetadatato the product detail page from Week 18, setting a per-producttitleand anopenGraph.imagesentry, and confirm in your browser's view-source that the correct<title>and Open Graph tags render per product. - Enable
cacheComponents: trueinnext.config.ts, wrap your products-list data function in"use cache"withcacheLife('minutes')andcacheTag('products'), and add a comment explaining, in your own words, what happens to a request that arrives a few minutes after the last regeneration. - 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. - Identify, in a short written note, why a deleted product might still briefly appear in the list if you forgot the
updateTagcall, 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.
"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?
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?
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?
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?
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.