cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Run 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.
  • 02
    Know what each check protectsTell which group a problem belongs to: performance, security, caching, errors or SEO, and which lesson teaches the fix.
  • 03
    Keep 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.

The tea shop before the auditNext.js
app/teas/[id]/public/
app/teas/[id]/page.tsx
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>  );}
Recorded from Next.js 16.4.3
$ 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 content

ESLint 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.

Four steps in a row, joined by arrows. 1, lint: npx eslint . reads the source and finds plain img tags and missing alt text. 2, build: next build prints the route table, with its symbols, and shows opt-outs. 3, ask: curl asks next start and finds status codes and headers. 4, search: grep for sk_tea_ in .next/static finds secrets in the JavaScript.1. lintnpx eslint .the source<img>, alt text2. buildnext buildroute table○ ◐ ƒ, opt-outs3. askcurlnext startstatus, headers4. searchgrep sk_tea_.next/staticsecrets in JS
The audit looks in four places, in the order a release makes them: the source, the build, the running app and the files the browser downloads. Each one finds problems the others can't see.
Real-life analogyA pilot's checklist before take-off

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
The main idea

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:

The audit, in eight commandsShell
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.

Playground: the audit, before and after the fixes
The tea shop, with this version of its filesNext.js
app/teas/[id]/public/
app/teas/[id]/page.tsx
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>  );}
Recorded from Next.js 16.4.3
$ 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 content
Try it yourself
Before the audit

The 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.

Both versions were checked with ESLint and built with next build from Next.js 16.4.3, then run with next start; the Terminal shows what they printed, and the Network tab what the server sent. Reset goes back to the first version.

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:

What the audit found
CheckBefore the auditAfter the fixes
npx eslint .2 warnings for the logo's plain <img>, which has no alt.No problems.
Route table for the tea pagesOnly ◐ /teas/[id]: every tea is made on each visit.○ /teas/green and ○ /teas/mint, made during the build.
The supplier's APIAsked while serving, on every visit to a tea page.Asked during the build, once for each tea on the menu.
Cache-Control of /teas/mintprivate, no-cache, no-store, max-age=0, must-revalidates-maxage=3600, stale-while-revalidate=82800
sk_tea_ in what /teas/mint sendsIn its JavaScript; not in its RSC payload.Not found.
Security headersNone, and X-Powered-By: Next.js.All five, and no X-Powered-By.
<title> of /teas/mintTea shop, as on every page.Mint tea | Tea shop
/robots.txt and /sitemap.xml404200
/teas/oolong, a tea the shop doesn't sell200 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:

Performance checks
What to checkHow to check itThe 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_:

app/teas/[id]/buy-button.tsx before the auditTSX
"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:

app/checkout.ts: the payment call, on the serverTSX
"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.

Two columns. Before: the .env line NEXT_PUBLIC_PAYMENTS_KEY goes into buy-button.tsx, a Client Component, and from there into a browser window, which holds sk_tea_pay_9Fz2Lw: anyone can read it. After: the .env line PAYMENTS_KEY goes into checkout.ts, a Server Action that runs on the server. The browser window holds only the Buy button: no key.BeforeNEXT_PUBLIC_PAYMENTS_KEYbuy-button.tsxbrowsersk_tea_pay_9Fz2Lwanyone can read itAfterPAYMENTS_KEYcheckout.ts, serverbrowserBuyno key
Before, the 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:

The headers /teas/mint sent before the audit, as recordedText
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-8

After 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:

Security checks
What to checkHow to check itThe 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:

The tea page without its leftover opt-outNext.js
app/teas/[id]/public/
app/teas/[id]/page.tsx
import { 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 failed · Next.js 16.4.3
$ 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: null

Without 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]:

app/supplier.ts after the fixTSX
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.

Two columns. Before, with the route table line half circle /teas/[id]: three visits each send an arrow to the supplier's API, which is asked on every visit. After, with circle /teas/green and circle /teas/mint: next build asks the supplier's API, once for each tea, and saves the pages; two visits get the saved pages, and the supplier isn't asked.◐ /teas/[id]visitvisitvisitsupplierasked on every visit○ /teas/green, ○ /teas/mintnext buildsuppliersaved pagesvisitvisitasked during the build
Before, each visit to a tea page waited for the supplier. After, 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:

Caching and rendering checks
What to checkHow to check itThe 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:

How the first version answered a missing teaTSX
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:

Error and monitoring checks
What to checkHow to check itThe 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:

SEO and metadata checks
What to checkHow to check itThe 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:

scripts/release-checks.shShell
#!/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:

Release checks
What to checkHow to check itThe 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:

A script, or a person?
  • No file in .next/static contains sk_tea_.
  • Every response has the five security headers.
  • No page uses a plain <img>.
  • No instant = false is 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.
Try it yourself
0 of 8 correct

Sort each check: can a script make it on every release, or does a person have to judge it?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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/static too.
  • "poweredByHeader: false makes 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 = false is 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 adds noindex instead, and a later request can get 404.
  • "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.

Exercise 1 · Warm-upRead the second route table

This is part of what the second build printed. Which tea pages did it prerender as static content?

Starter codeText
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /robots.txt
├ ○ /sitemap.xml
└   /teas/[id]
  ├ ◐ /teas/[id]
  ├ ○ /teas/green
  └ ○ /teas/mint

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind what leaked the key

    The shop's .env said NEXT_PUBLIC_PAYMENTS_KEY=sk_tea_pay_9Fz2Lw. Which part of the name put the key into the JavaScript?

    Starter codeTSX
    "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>;
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRead a response's headers

      These are the headers /teas/mint sent before the audit. Which one should the shop stop sending?

      Starter codeText
      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-8

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict a soft 404

        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?

        Starter codeTSX
        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>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind the line that hid the problem

          The page fetches the supplier's API with no cache and no Suspense, yet next build passed. Which line let it pass?

          Starter codeTSX
          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>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeRead the cache lifetime

            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?

            Starter codeTSX
            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;
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, think about what the running app sends, not what the code says.

              The production checklist quiz · 7 questionsScore: first tries count
              1. 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.

              2. Question 2 of 7The audit found sk_tea_ in the JavaScript that /teas/mint loads. How did it get there?

                Choose an answer to see the explanation.

              3. 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.

              4. Question 4 of 7What does export const instant = false do in a page?

                Choose an answer to see the explanation.

              5. Question 5 of 7After the fixes, /teas/oolong still answered 200 on the first visit. Is that a failed check?

                Choose an answer to see the explanation.

              6. Question 6 of 7What does poweredByHeader: false change, and why is it on the list?

                Choose an answer to see the explanation.

              7. 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 to next 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 = false hide problems the build would otherwise report. Search for them.
              • A missing page answers 404, or 200 with noindex once 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.

              CompleteFrontend Clear concepts. Working examples.