A React and Next.js Production Checklist
- react
- nextjs
- webdev
- typescript
- accessibility
- performance
- softwareengineering
- checklist
In Deploying a Next.js App, I covered getting the app onto real infrastructure: choosing between Vercel and self-hosting, keeping Docker images lean, and handling environment variables and graceful shutdown correctly. That article answers how the app gets to a server. This one answers a different question: is it actually ready to be there.
This is part of the React & Next.js Complete Guide series, which started with React Fundamentals for Professionals. Across that series we've covered hooks, rendering strategies, forms, state management, testing, and accessibility one topic at a time, each defensible on its own. Production readiness is a property of the whole system: whether the pieces add up to something that degrades gracefully under a bad deploy, a slow API, or a user on a five-year-old phone with a flaky connection. No single decision gets you there on its own.
I've organized this as a checklist because that's how I actually use these things: as a gate before a feature ships, and again before a major release. Treat each section as a minimum bar. Some of it your framework or CI setup already handles; some you'll have to wire up yourself.
Scope error boundaries to contain the blast radius
A single error boundary at the root of the app is better than nothing, but it means one failed widget takes down the entire page. The React App Router makes this easy to get wrong in the opposite direction too: an error.tsx file at every route segment sounds thorough, until half of them are copy-pasted boilerplate that nobody has actually thought through.
The right granularity is one boundary per section of the page that can fail independently and still leave the rest usable. A dashboard with a chart widget, an activity feed, and a billing summary should have three boundaries, not one, because a broken chart shouldn't take the billing summary down with it.
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Report to your error tracker with the digest, which correlates
// this client-side error to the server log entry that generated it.
reportError(error);
}, [error]);
return (
<div role="alert" className="rounded-md border border-red-200 p-4">
<p className="text-sm text-red-700">
This section failed to load. The rest of the dashboard is unaffected.
</p>
<button onClick={() => reset()} className="mt-2 text-sm underline">
Try again
</button>
</div>
);
}
More files and more decisions about where boundaries belong is the real cost here. It takes real thought instead of a mechanical rule. Watch for boundaries that swallow the error silently in review: a generic fallback with no useEffect reporting call turns a production error into something nobody finds out about until a user complains. That's worse than a visible crash you'd have caught in monitoring.
Verify environment configuration at build time
The previous article covered why NEXT_PUBLIC_* variables get baked into the client bundle at build time. The production-readiness question is different: how do you know a secret didn't end up there anyway? A developer copying an env var name wrong, or a library that reads process.env directly without the NEXT_PUBLIC_ guard, can leak a value into .next/static in a way that's invisible until someone opens the browser devtools and greps the bundle.
The fix is a build-time check that scans the output for patterns that shouldn't be there, run as a CI step after next build and before deploy.
#!/usr/bin/env bash
# scripts/check-bundle-secrets.sh
set -euo pipefail
# Fail the build if any secret-shaped value shows up in the client bundle.
# Extend this list with the actual prefixes/patterns your secrets use.
PATTERNS=(
"sk_live_"
"postgresql://"
"AKIA[0-9A-Z]{16}"
)
for pattern in "${PATTERNS[@]}"; do
if grep -rEl "$pattern" .next/static > /dev/null 2>&1; then
echo "Found pattern '$pattern' in client bundle. Failing build."
exit 1
fi
done
echo "No secret patterns found in client bundle."
This is a safety net, not a substitute for the discipline of only prefixing genuinely public values with NEXT_PUBLIC_. It catches the mistake after it happens instead of preventing it, but that's still far better than finding out about a leaked API key from a security researcher's email. Maintaining the pattern list as your secret formats change is the real cost, and it's a small one next to what a leaked credential shipped to every visitor's browser would cost you.
Enforce performance budgets in CI
Running Lighthouse once before launch tells you the state of the app on that day. It says nothing about whether the next pull request that adds a chart library regresses the largest contentful paint on your marketing page. Production readiness means a budget that fails the build when a metric regresses past a threshold. A dashboard someone checks when they remember to doesn't count.
// lighthouserc.json
{
"ci": {
"collect": {
"url": ["http://localhost:3000/", "http://localhost:3000/pricing"],
"numberOfRuns": 3
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
Wire this into CI with @lhci/cli so it runs against every pull request that touches the pages you've listed. Picking realistic thresholds is the hard part. Set them from your app's actual current numbers plus a small margin. An arbitrary "good" score copied from a blog post won't survive contact with a real deadline: a budget nobody can hit gets disabled the first time it blocks a release. And don't scope this to your homepage alone. The page your paying customers actually use every day, usually the dashboard behind auth, matters more for retention than a marketing page that's easy to keep green in a Lighthouse audit.
Make accessibility checks a CI gate
A manual accessibility review before launch catches real problems, but it's a snapshot. The component that passed review in isolation can fail once it's composed into a page with a different heading hierarchy, or a modal that traps focus incorrectly once it's nested inside another interactive element. Automated checks that run on every pull request catch regressions a one-time audit can't.
// __tests__/dashboard.a11y.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Dashboard } from '@/components/dashboard';
expect.extend(toHaveNoViolations);
test('dashboard has no automatically detectable accessibility violations', async () => {
const { container } = render(<Dashboard />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Automated tools like axe-core catch a real but limited slice of accessibility problems: missing labels, insufficient color contrast, invalid ARIA usage. They do not catch whether a keyboard user can actually complete a multi-step form or whether focus order makes sense to someone using a screen reader. Treat automated checks as a floor that stops obvious regressions from shipping, and keep periodic manual testing with real assistive technology for the things automation structurally can't verify.
Client errors and real user metrics reach you before a support ticket does
Server logs tell you about server errors. They tell you nothing about a client-side exception in a browser you've never tested, or a Core Web Vitals score that's fine in your CI environment but bad on a customer's actual device and network. Production readiness means both are captured and reported before a user has to tell you about them.
// app/layout.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitalsReporter() {
useReportWebVitals((metric) => {
// Send to your analytics or monitoring endpoint. Batch or sample
// this in high-traffic apps rather than firing one request per metric.
navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
});
return null;
}
Pair this with a client-side error tracker (Sentry or an equivalent) initialized early in the app so unhandled exceptions and unhandled promise rejections are captured with a stack trace and enough context, browser, URL, and user ID if you have consent, to actually debug them. Sampling is the one tradeoff worth flagging here. Reporting every single web vitals event from every user becomes unnecessary load on your ingestion pipeline once you have real traffic, so sample down once volume justifies it. Start with full reporting though, so you have a real baseline before deciding what to sample away.
Key takeaways
- Scope error boundaries to independently-failing sections of a page, not one boundary at the root, so a broken widget doesn't take down the whole view.
- Add a build-time scan for secret-shaped strings in the client bundle as a safety net; it catches leaks that discipline alone misses.
- Turn performance checks into a CI gate with real thresholds derived from your app's current numbers, not a Lighthouse run you remember to do occasionally.
- Run automated accessibility checks on every pull request as a floor, and keep manual testing with real assistive technology for what automation can't verify.
- Capture client-side errors and real user Core Web Vitals before launch, so you find out about a broken experience from your monitoring, not from a support ticket.
- Revisit this checklist at every major release; a new integration or a new page type usually adds one more item worth checking.
Frequently asked questions
How many error boundaries does a typical Next.js page need?
There is no fixed number. The right heuristic is one boundary per section of the page that can fail independently and still leave the rest of the page useful to the user. A single-purpose page might need only one; a dashboard with several independent widgets usually needs one per widget.
Can build-time secret scanning replace proper secret management?
No. It is a safety net that catches a leak after a mistake has already been introduced, not a substitute for only prefixing genuinely public values with `NEXT_PUBLIC_` and keeping real secrets in a secrets manager. Treat it as a last line of defense, not the primary control.
What performance threshold should I set in Lighthouse CI?
Start from your app's actual current metrics rather than a number from a blog post or an unrelated project, add a small margin, and tighten it over time as you improve. A threshold nobody can realistically hit gets disabled under deadline pressure the first time it blocks a release.
Does passing jest-axe mean my app is accessible?
No. Automated tools catch a real but limited slice of issues: missing labels, contrast, invalid ARIA. They cannot verify that a keyboard user can complete a multi-step flow or that a screen reader announces things in a sensible order. Use automated checks to stop obvious regressions and keep periodic manual testing for the rest.
Should I sample web vitals reporting from the start?
Not at first. Report everything until you have a real baseline of your actual traffic and cost profile, then sample down once volume justifies it. Sampling before you understand your baseline risks throwing away the data you'd need to notice a regression in the first place.
Is this checklist a one-time pre-launch task?
Treat it as a gate you revisit before every major release, not a box you check once. A new third-party script, a new page template, or a new integration typically introduces a new failure mode that this checklist should account for going forward.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.