The production checklist
Audit a Next.js app before launch: performance, security, caching, error pages and SEO, each check runnable and linked to the lesson that teaches it.
- 01Run an audit, not a read-throughCheck a built app with ESLint, the route table, real requests and a search of its JavaScript, and read what each one found.
- 02Know what each check protectsTell which group a problem belongs to: performance, security, caching, errors or SEO, and which lesson teaches the fix.
- 03Keep the checks runningTurn the checks a script can make into a CI job that runs before every release, and keep a short list for people.
The tea shop, the day before launch
The tea shop is ready to go live. Every page works on the developer's computer, and next build passes. A production checklist asks a different question: what will real visitors, search engines and attackers get from the running app? Its answer is a list of checks you can run, each linked to the lesson that teaches the fix.
Here is the shop as it was. It's a real Next.js 16.4.3 project: ESLint checked it, next build built it, and next start served the pages the audit asked for. Open the Terminal tab.
app/teas/[id]/page.tsximport { getTea } from "../../supplier";import BuyButton from "./buy-button"; // Left over from adopting Cache Components.export const instant = false; export default async function TeaPage({ params }: PageProps<"/teas/[id]">) { const { id } = await params; const tea = await getTea(id); if (!tea) return <p>We could not find that tea.</p>; return ( <main> <h1>{tea.name}</h1> <p>${tea.price} a box</p> <BuyButton id={id} /> </main> );}$ npx eslint ./my-app/app/layout.tsx 10:11 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element 10:11 warning img elements must have an alt prop, either with meaningful text, or an empty string for decorative images jsx-a11y/alt-text ✖ 2 problems (0 errors, 2 warnings) $ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ /teas/[id] └ ◐ /teas/[id] ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentESLint printed two warnings, and the build passed. Nothing in the Terminal says the shop has a problem. Yet the audit found one in every group of checks: the payment key is in the JavaScript, the pages have no security headers, every tea is made on each visit, every page has the same title, and a tea that doesn't exist gets a normal page. A passing build checks that the code compiles and the pages render. It doesn't check what the running app sends.
A pilot has flown the plane a hundred times, and still reads the checklist before each take-off. Each line says what to look at: the gauge, the switch, the light. The engines starting proves very little. The checklist catches the small thing nobody noticed, before it matters.
- In real life: The printed checklist in the cockpit
- In Next.js: The production checklist
- In real life: Look at the fuel gauge, don't remember filling up
- In Next.js: Run the command and read what it prints
- In real life: Engines that start can still be low on fuel
- In Next.js: A build that passes can still leak a key
- In real life: The checklist runs before every flight
- In Next.js: The audit runs before every release
A production checklist is a list of checks you run against the built app, each one tied to what it protects. Lint the source, read the route table, ask the started app for its pages, and search the JavaScript it sends. Run it before launch, and again on every release.
Running the audit, before and after
An audit is a handful of commands, run in the project's folder:
npx eslint .
npm run build
npm run start
curl -s -D - -o /dev/null http://localhost:3000/teas/mint
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/teas/oolong
grep -rl "sk_tea_" .next/static
curl -s http://localhost:3000/teas/mint | grep -c "sk_tea_"
curl -s -H "rsc: 1" "http://localhost:3000/teas/mint?_rsc=" | grep -c "sk_tea_"
Line 1 runs ESLint, which next build no longer does since Next.js 16. Line 2 builds the app and prints the route table. Line 3 starts it. With the server running, in a second terminal, line 4 asks for /teas/mint and prints the response's status and headers (-D -), dropping the page itself (-o /dev/null). Line 5 asks for a tea the shop doesn't sell and prints only the status code. Line 6 searches every file the build made for the browser, under .next/static, and prints the names of the ones that contain sk_tea_, the start of each of the shop's secret keys. Lines 7 and 8 search what the server sends for a page, and print how many lines hold the key: its HTML, which also carries the RSC payload in inline scripts, and the RSC payload on its own, which rsc: 1 asks for, as the router does. A rendered value or a Client Component's prop would show up there, not in .next/static. The recordings in the playground asked for the same pages, and searched the JavaScript and the RSC payload of /teas/mint.
Pick each version. Read the Terminal first, then the Network tab, which opens on /teas/mint. Before you switch, guess which problems the fixes made disappear from the Terminal.
app/teas/[id]/page.tsximport { getTea } from "../../supplier";import BuyButton from "./buy-button"; // Left over from adopting Cache Components.export const instant = false; export default async function TeaPage({ params }: PageProps<"/teas/[id]">) { const { id } = await params; const tea = await getTea(id); if (!tea) return <p>We could not find that tea.</p>; return ( <main> <h1>{tea.name}</h1> <p>${tea.price} a box</p> <BuyButton id={id} /> </main> );}$ npx eslint ./my-app/app/layout.tsx 10:11 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element 10:11 warning img elements must have an alt prop, either with meaningful text, or an empty string for decorative images jsx-a11y/alt-text ✖ 2 problems (0 errors, 2 warnings) $ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (4/4) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found└ /teas/[id] └ ◐ /teas/[id] ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentThe shop as it was. ESLint printed two warnings and the build passed, with /teas/[id] only as ◐. Line 5 is the leftover opt-out, and line 10 answers a missing tea with a normal page. In the Network tab, /teas/mint has X-Powered-By and no security headers, and /robots.txt answers 404.
Some of what the audit found is in the tabs, and some only in the recording: which JavaScript files hold the key, and when the supplier's API was asked. Each row is one check, and what it found in each version:
| Check | Before the audit | After the fixes |
|---|---|---|
npx eslint . | 2 warnings for the logo's plain <img>, which has no alt. | No problems. |
| Route table for the tea pages | Only ◐ /teas/[id]: every tea is made on each visit. | ○ /teas/green and ○ /teas/mint, made during the build. |
| The supplier's API | Asked while serving, on every visit to a tea page. | Asked during the build, once for each tea on the menu. |
Cache-Control of /teas/mint | private, no-cache, no-store, max-age=0, must-revalidate | s-maxage=3600, stale-while-revalidate=82800 |
sk_tea_ in what /teas/mint sends | In its JavaScript; not in its RSC payload. | Not found. |
| Security headers | None, and X-Powered-By: Next.js. | All five, and no X-Powered-By. |
<title> of /teas/mint | Tea shop, as on every page. | Mint tea | Tea shop |
/robots.txt and /sitemap.xml | 404 | 200 |
/teas/oolong, a tea the shop doesn't sell | 200 every time, with no noindex. | 200 with noindex, then 404. |
None of the fixes is new: each one has a lesson of its own, linked from its line of the checklist.
Performance checks
Performance is what visitors feel: how soon the main content shows, whether things jump, how fast a click answers. In the shop, ESLint pointed at line 10 of app/layout.tsx: the logo is a plain <img> with no alt. After the fix it's next/image with alt="", since the logo sits next to the shop's name and adds nothing a screen reader should say, and ESLint finds nothing. Each row is one check:
| What to check | How to check it | The lessons that teach it |
|---|---|---|
| Real visitors' LCP, CLS and INP are measured. | useReportWebVitals sends each visit's numbers to your analytics. Decide from field data, not from one Lighthouse run. | Core Web Vitals |
| The biggest image on the first screen loads early. | It uses next/image with preload, and sizes when it changes width. npx eslint . warns about every plain <img>. | Optimizing images |
| Fonts don't make the text jump. | Fonts come from next/font, which serves the files with the app and gives the text a size-adjusted fallback. | Optimizing fonts |
| Other companies' scripts don't make the page wait. | They load with next/script and a strategy, or with @next/third-parties, rather than as a plain <script> tag. | Scripts and third-party code |
| Each route sends only the JavaScript it needs. | npx next analyze lists the routes with the most client JavaScript. Keep "use client" low in the tree, and load heavy parts with next/dynamic. | Shrinking the JavaScript bundle |
The recordings here are one visit on a fast computer, which is lab data. Only field data, from real visitors' devices, says whether a launch went well, which is why the first row comes first.
Security checks
Security checks ask what an attacker can read or do. The shop's Buy button called the payment provider from the browser, so it needed the key there, and the key's name started with NEXT_PUBLIC_:
"use client"; export default function BuyButton({ id }: { id: string }) { async function buy() { await fetch("https://payments.example/checkout", { method: "POST", headers: { authorization: `Bearer ${process.env.NEXT_PUBLIC_PAYMENTS_KEY}` }, body: JSON.stringify({ tea: id }), }); } return <button onClick={buy}>Buy</button>;} Line 1 makes the file a Client Component, so its code goes to the browser. Line 7 reads process.env.NEXT_PUBLIC_PAYMENTS_KEY, and the prefix tells next build to write the value into that code. The recording found sk_tea_ in the JavaScript /teas/mint loads. The fix moves the call to a Server Action, and drops the prefix from the name in .env:
"use server";
import { menu } from "./menu";
// A Server Action is a public endpoint: check what it was sent.
export async function startCheckout(id: string) {
if (!menu.some((tea) => tea.id === id)) throw new Error("Unknown tea");
// A stand-in for the payment provider's API. The key never leaves the server.
await fetch(`${process.env.PAYMENTS_API_URL}/checkout`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.PAYMENTS_KEY}` },
body: JSON.stringify({ tea: id }),
});
}
Line 1 makes every function in the file a Server Action: the button calls it, and it runs on the server. Line 7 checks the tea id, because anyone can call the action with any value. Lines 9 to 13 call the provider, and line 11 reads process.env.PAYMENTS_KEY. Without the prefix, the build doesn't copy the value into any JavaScript, but the name alone doesn't keep it private. It stays private because only this server code reads it, and never renders it, returns it or passes it to a Client Component. The provider's address is a stand-in. After the fix, the recording found no sk_tea_ in the JavaScript, in the page, or in its RSC payload.
NEXT_PUBLIC_ prefix let the build copy the key into the Buy button's JavaScript. After, only the Server Action reads it, on the server, and the browser gets the button and nothing else.The Network tab showed the other finding. The recording keeps the headers that describe a page, and the ones the audit looked for. Before, /teas/mint sent these, with no security header among them:
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time: 300
x-nextjs-prerender: 1
x-nextjs-postponed: 1
X-Powered-By: Next.js
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/html; charset=utf-8After the fix, next.config.ts adds five headers to every response with headers(), and poweredByHeader: false removes X-Powered-By. Each row is one check:
| What to check | How to check it | The lessons that teach it |
|---|---|---|
| No secret reaches the browser. | Search for the start of every secret, like sk_tea_, in .next/static after next build, and in each page's HTML and RSC payload, which hold rendered values and Client Component props. Only values meant for everyone get NEXT_PUBLIC_. | Server-only code and environment variables |
| Every Server Action checks who sent it and what it sent. | Each action is a public endpoint: it reads the session, checks the user may do this, and checks every argument. | Securing Server Actions |
| Server Actions only accept posts from your own site. | Next.js compares a post's Origin with the host. Behind a proxy or on another domain, list the extra sites in serverActions.allowedOrigins, exactly, and no more. | Securing Server Actions |
| Costly public endpoints have a rate limit. | Actions and Route Handlers that send email, take payments or call a model check who is asking, then a limit kept in a store every instance shares, before the costly call. | Securing Server Actions, Building AI features, Next.js as a backend for your frontend |
| Data is read through a layer that checks access. | Pages, actions and Route Handlers get data from a server-only data access layer that checks the user and returns only the fields needed. | Authorization and the data access layer |
proxy.ts is never the only check. | A check in proxy.ts is a first filter. The code that reads the data checks again, so one skipped check can't open it. | Case study: the middleware bypass |
| HSTS is rolled out in steps. | Strict-Transport-Security starts with a short max-age, like a day, and grows once every page works over HTTPS. Add includeSubDomains and preload only when every subdomain will serve HTTPS. | Security headers and Content Security Policy |
| The security headers are on every response. | Ask for a page and a file from public with curl: HSTS, X-Content-Type-Options, X-Frame-Options or a policy with frame-ancestors, Referrer-Policy, Permissions-Policy, and no X-Powered-By. | Security headers and Content Security Policy |
| Next.js is on a version with the latest security fixes. | Compare next in package.json with the latest patch of your major, and read the security posts on nextjs.org/blog. | Upgrading Next.js, Case study: the middleware bypass |
| Your packages have no known vulnerabilities. | Commit the lockfile, install with npm ci, and run npm audit in the deploy steps; fix or replace what it reports. | Security headers and Content Security Policy, Case study: the middleware bypass |
| Every instance of a deployment can read every Server Action. | All servers of one deployment share the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY and deploymentId, so an action made by one can run on another. | Self-hosting |
| The image optimizer only fetches images you expect. | images.remotePatterns lists exact hosts and paths, not a whole domain with **; each allowed image is one your server fetches and resizes. | Optimizing images, Self-hosting |
Caching and rendering checks
Caching checks ask who waits, and for what. In the first build, the route table listed the tea pages only as ◐ /teas/[id]: a shell made during the build, and the whole tea made on every visit. The stand-in for the supplier's API kept a log, and it was asked while the server ran, on every visit to a tea page. The page fetched without a cache, and nothing in it said which teas to prerender. So why did the build allow it? Here is the same page without line 5:
app/teas/[id]/page.tsximport { getTea } from "../../supplier";import BuyButton from "./buy-button"; export default async function TeaPage({ params }: PageProps<"/teas/[id]">) { const { id } = await params; const tea = await getTea(id); if (!tea) return <p>We could not find that tea.</p>; return ( <main> <h1>{tea.name}</h1> <p>${tea.price} a box</p> <BuyButton id={id} /> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error: Route "/teas/[id]": Next.js encountered uncached or runtime data during prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. Ways to fix this: - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`) - [block] Set `export const instant = false` to allow a blocking route Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic at body (<anonymous>) at html (<anonymous>)To get a more detailed stack trace and pinpoint the issue, try one of the following: - Start the app in development mode by running `next dev`, then open "/teas/[id]" in your browser to investigate the error. - Rerun the production build with `next build --debug-prerender` to generate better stack traces.Error occurred prerendering page "/teas/[id]". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /teas/[id]/page: /teas/[id], exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullWithout it, the build stops: Route "/teas/[id]": Next.js encountered uncached or runtime data during prerendering, and the message lists three fixes. The third, [block], is export const instant = false. It turns off the check that a route has a static shell to send at once, so a migration can switch on Cache Components first and fix routes one by one. The shop's line 5 was left over from that. The fix is the second one, [cache]:
import { cacheLife, cacheTag } from "next/cache"; export type Tea = { name: string; price: number }; // Each tea's name and price, from the supplier's API, kept for hours.export async function getTea(id: string) { "use cache"; cacheLife("hours"); cacheTag(`tea-${id}`); const response = await fetch(`${process.env.SUPPLIER_API_URL}/teas/${id}`); if (!response.ok) return null; return (await response.json()) as Tea;} Line 7 caches what getTea returns, for each id. Line 8 keeps it for hours, and line 9 tags it, so a Server Action can refresh one tea with updateTag. With generateStaticParams listing the menu, the build asked the supplier once for each tea and printed ○ /teas/green and ○ /teas/mint. And /teas/mint now answers Cache-Control: s-maxage=3600, stale-while-revalidate=82800, which lets a CDN keep it for an hour.
next build asked it once for each tea on the menu and saved the pages, and visits get those pages. cacheLife("hours") lets Next.js refresh them later, in the background.Each row is one check:
| What to check | How to check it | The lessons that teach it |
|---|---|---|
| Each route has the symbol you expect. | Read the route table after next build: ○ for pages that are the same for everyone, ◐ with a shell worth sending, and ƒ only where every visit must wait. | How Next.js renders pages |
| No opt-out is left over from a migration. | Search the code for instant = false. Each one turns off the build's check that the route has a shell to send at once. | Cache Components |
| Slow data that may be a little old is cached, with a lifetime. | Calls to slow services get "use cache" with cacheLife and a cacheTag. The service's log shows it was asked during the build, not on each visit. | Cache lifetimes and tags |
| A saved change shows up. | Each Server Action that writes calls updateTag or revalidateTag with the tags the reads use, and an end-to-end test proves it. | Revalidating cached data |
| One visitor's data never lands in a shared cache. | Pages that read cookies answer Cache-Control: private, no-cache, no-store…, and cached data for one user uses "use cache: private". | Private and remote caches |
| Every server uses the same cache. | With more than one instance, set a cache handler they share, and a deploymentId that names each deployment, for the version skew of a rolling deployment. | Self-hosting |
Error pages and monitoring
Error checks ask what happens when something is missing or breaks, and who finds out. In the first version, a tea the shop doesn't sell got this, on line 10 of the page:
import { getTea } from "../../supplier";import BuyButton from "./buy-button"; // Left over from adopting Cache Components.export const instant = false; export default async function TeaPage({ params }: PageProps<"/teas/[id]">) { const { id } = await params; const tea = await getTea(id); if (!tea) return <p>We could not find that tea.</p>; return ( <main> <h1>{tea.name}</h1> <p>${tea.price} a box</p> <BuyButton id={id} /> </main> );} /teas/oolong answered 200 every time, with We could not find that tea. and no noindex. That's a soft 404: a page that says it's missing, with a status that says everything is fine, which search engines may keep in their results. The fix calls notFound(). Now the first visit answered 200 with <meta name="robots" content="noindex">, because the page had already started streaming when the supplier answered 404, and the second answered 404, from the result Next.js kept. Both pass the check. Not found, forbidden and unauthorized explains why the status can't change once streaming has started.
The rest of this group can't be seen in a recording that has no errors. Each row is one check:
| What to check | How to check it | The lessons that teach it |
|---|---|---|
| An address with nothing on it says so. | Ask for an unknown address: 404, or 200 with <meta name="robots" content="noindex"> when the page had already started streaming. | Not found, forbidden and unauthorized |
| A failed render shows a page of your own. | error.tsx covers its segment and global-error.tsx the root layout. In production the browser gets a digest, never the message. | Error handling |
| Server errors reach a person. | instrumentation.ts exports onRequestError, which sends each error with its digest to your error tracker. | Instrumentation and observability |
| Logs help without leaking. | Each log line has a request id and an allowlist of fields. No cookies, Authorization headers, query strings, form bodies or personal data. | Instrumentation and observability |
| A visitor's error can be found in the logs. | The digest on the error page matches a line in the server's log. Read that line and its stack. | Debugging |
SEO and metadata checks
SEO checks ask what search engines and link previews read. Before the fixes, every page's <title> was Tea shop, and /robots.txt and /sitemap.xml answered 404. After them, the root layout has a title template, %s | Tea shop, and the tea's page fills it in with generateMetadata: /teas/mint is Mint tea | Tea shop, with the description Mint tea, $3 a box.. app/robots.ts and app/sitemap.ts made the two files, and both answered 200. Each row is one check:
| What to check | How to check it | The lessons that teach it |
|---|---|---|
| Each page has its own title and description. | generateMetadata on pages with params, and a title template in the root layout. Read each page's <title> in its HTML. | The Metadata API |
| Search engines find the pages. | app/robots.ts and app/sitemap.ts exist, and /robots.txt and /sitemap.xml answer 200. | Sitemaps, robots and structured data |
| A shared link shows a picture. | An opengraph-image file, or openGraph in the metadata, gives each page an og:image tag with a full address. | Open Graph images |
| Staging stays private and out of search results. | A staging or preview copy asks visitors to sign in and sends noindex. A Disallow in robots.txt only asks crawlers to stay away; it isn't a lock. | Sitemaps, robots and structured data |
| Everyone can use the pages. | <html lang>, alt text and labels are there (npx eslint . checks some), and you've used the pages with a keyboard and a screen reader. | Accessibility |
Checks that run on every release
A checklist you run once stops being true: the next Client Component can bring the key back. So put every check a script can make in one file, and let your CI service run it on every change:
#!/bin/sh
# Stop at the first command that fails.
set -e
npx eslint .
npx vitest run
npm run build
# Fail when any built file holds the start of a secret key.
if grep -rl "sk_tea_" .next/static; then exit 1; fi
npx playwright test
Line 3 stops the script at the first command that fails, which fails the CI job. Line 4 lints, and line 5 runs the unit tests. Line 6 builds, which also type-checks the project. Line 8 searches the built files: grep succeeds when it finds sk_tea_, and then exit 1 fails the job. Line 9 runs the end-to-end tests, whose Playwright config builds and starts the app, as in End-to-end testing, and they can ask for each page and check its status and headers too. Each row is one more check for the release:
| What to check | How to check it | The lessons that teach it |
|---|---|---|
| Unit tests pass. | vitest run checks components, actions and the data access layer. | Testing components |
| End-to-end tests pass against the built app. | Playwright drives real Chrome against next build and next start, as visitors will meet it. | End-to-end testing |
| The place you deploy to supports what the app uses. | Streaming, proxy.ts, cached pages and image optimization each need the right kind of host. | Deploying Next.js |
Some checks need a person, though. Sort these:
- No file in
.next/staticcontainssk_tea_. - Every response has the five security headers.
- No page uses a plain
<img>. - No
instant = falseis left in the code. - The alt text says what a photo shows.
- An hour is the right cache lifetime for prices.
- The refund action lets only staff refund an order.
- The error page tells visitors what to do next.
Sort each check: can a script make it on every release, or does a person have to judge it?
Common mix-ups
- "The build passed, so the app is ready." No. The tea shop's build passed with a key in its JavaScript, no security headers and a soft 404. A build checks the code, not what the running app sends.
- "A secret no page shows is safe." No. The key never appeared on screen, but it was in the JavaScript of
/teas/mint. Search.next/statictoo. - "
poweredByHeader: falsemakes the app secure." No. It removes one header. A Next.js app is still easy to recognize, so the protection is a patched version. - "
instant = falseis harmless once the build passes." No. It turned off the check that would have stopped the build, and every visit to a tea waited for the supplier. - "A missing page must always answer
404." No. Once a page has started streaming, its status is sent. Next.js addsnoindexinstead, and a later request can get404. - "One good Lighthouse score means visitors are happy." No. That's one visit in a lab. Field data from real visitors decides.
- "A checklist is for launch day." No. Each release can bring a problem back, so the checks a script can make run on every change.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
This is part of what the second build printed. Which tea pages did it prerender as static content?
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /robots.txt
├ ○ /sitemap.xml
└ /teas/[id]
├ ◐ /teas/[id]
├ ○ /teas/green
└ ○ /teas/mint/teas/green and /teas/mint. generateStaticParams listed both, and getTea was cached, so the build made both pages. ◐ /teas/[id] is the fallback shell for any other tea, also prerendered during the build; only that tea's own part is made for each visit.
The shop's .env said NEXT_PUBLIC_PAYMENTS_KEY=sk_tea_pay_9Fz2Lw. Which part of the name put the key into the JavaScript?
"use client";
export default function BuyButton({ id }: { id: string }) {
async function buy() {
await fetch("https://payments.example/checkout", {
method: "POST",
headers: { authorization: `Bearer ${process.env.NEXT_PUBLIC_PAYMENTS_KEY}` },
body: JSON.stringify({ tea: id }),
});
}
return <button onClick={buy}>Buy</button>;
}
NEXT_PUBLIC_. The build writes the value of every process.env.NEXT_PUBLIC_… that the browser's code uses into that code. The recording found sk_tea_ in the JavaScript of /teas/mint.
These are the headers /teas/mint sent before the audit. Which one should the shop stop sending?
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time: 300
x-nextjs-prerender: 1
x-nextjs-postponed: 1
X-Powered-By: Next.js
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/html; charset=utf-8X-Powered-By. It tells anyone which framework the shop runs. Removing it doesn't make the shop safe, but there's no reason to send it. The shop also needs the five security headers, which aren't in this list at all.
The shop doesn't sell oolong, so the supplier answers 404 for it. What status does /teas/oolong answer with this version of the page?
import { getTea } from "../../supplier";
import BuyButton from "./buy-button";
// Left over from adopting Cache Components.
export const instant = false;
export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
const { id } = await params;
const tea = await getTea(id);
if (!tea) return <p>We could not find that tea.</p>;
return (
<main>
<h1>{tea.name}</h1>
<p>${tea.price} a box</p>
<BuyButton id={id} />
</main>
);
}
200. Line 10 returns a normal page, so Next.js sends it as a normal page, with no noindex. The recording answered 200 both times it was asked. notFound() is the fix.
The page fetches the supplier's API with no cache and no Suspense, yet next build passed. Which line let it pass?
import { getTea } from "../../supplier";
import BuyButton from "./buy-button";
// Left over from adopting Cache Components.
export const instant = false;
export default async function TeaPage({ params }: PageProps<"/teas/[id]">) {
const { id } = await params;
const tea = await getTea(id);
if (!tea) return <p>We could not find that tea.</p>;
return (
<main>
<h1>{tea.name}</h1>
<p>${tea.price} a box</p>
<BuyButton id={id} />
</main>
);
}
Line 5, export const instant = false. Without it, the build stops with Next.js encountered uncached or runtime data during prerendering. With it, the route may block, so every visit waited for the supplier.
After the fix, /teas/mint is cached with this getTea. For how many seconds may a CDN send its copy before it should ask for a new one?
import { cacheLife, cacheTag } from "next/cache";
export type Tea = { name: string; price: number };
// Each tea's name and price, from the supplier's API, kept for hours.
export async function getTea(id: string) {
"use cache";
cacheLife("hours");
cacheTag(`tea-${id}`);
const response = await fetch(`${process.env.SUPPLIER_API_URL}/teas/${id}`);
if (!response.ok) return null;
return (await response.json()) as Tea;
}
3600. /teas/mint answered Cache-Control: s-maxage=3600, stale-while-revalidate=82800. A CDN may send its copy for an hour; after that it may still send it while it asks for a fresh one, up to a day in all.
Check your understanding
For each question, think about what the running app sends, not what the code says.
Question 1 of 7The tea shop's first build passed with no errors. Why did it still fail the audit?
Choose an answer to see the explanation.
Question 2 of 7The audit found
sk_tea_in the JavaScript that/teas/mintloads. How did it get there?Choose an answer to see the explanation.
Question 3 of 7Before the fixes, the route table listed only
◐ /teas/[id]for the tea pages. What did that mean for a visit to/teas/mint?Choose an answer to see the explanation.
Question 4 of 7What does
export const instant = falsedo in a page?Choose an answer to see the explanation.
Question 5 of 7After the fixes,
/teas/oolongstill answered200on the first visit. Is that a failed check?Choose an answer to see the explanation.
Question 6 of 7What does
poweredByHeader: falsechange, and why is it on the list?Choose an answer to see the explanation.
Question 7 of 7When should the tea shop run its checklist?
Choose an answer to see the explanation.
Key takeaways
- A passing build checks the code. The checklist checks what the running app sends, and what its JavaScript holds.
- The audit looks in four places:
npx eslint ., the route table, requests tonext start, and a search of.next/static. - Each group protects something: performance what visitors feel, security what attackers get, caching who waits, errors what happens when things go wrong, SEO what search engines read.
- Leftover opt-outs like
instant = falsehide problems the build would otherwise report. Search for them. - A missing page answers
404, or200withnoindexonce it has started streaming. - Put every check a script can make in CI, run it on every release, and keep a short list for people.
Remember the one-liner.
Don't trust the build: ask the running app, search its JavaScript, and do it on every release.
Coming next: Upgrading Next.js. It reads release notes, runs real codemods on a shop written for Next.js 14, and moves it to 16.4.3 step by step.