Production Checklist and Threat-Model Review

A production checklist and threat-model review turns a finished Next.js feature into a release candidate that can be operated, attacked, diagnosed, and rolled back. The outcome is not a decorative document. It is a set of concrete release gates for routes, rendering modes, cache behavior, secrets, headers, data mutations, logging, and deployment controls.

In this capstone lesson, you connect the course pieces: App Router segments, Server Components, Client Components, Server Actions, route handlers, middleware, caching, database access, authentication, testing, and hosting. By the end, you should be able to inspect a Next.js application and say which assets are public, which computations run on the server, which responses may be cached, which identities can mutate data, and what evidence proves the release is ready.

What the Review Protects

Next.js blurs boundaries deliberately. A page can be a Server Component, contain nested Client Components, call a Server Action, fetch cached data, stream partial UI, and serve static assets from the same project. That productivity is useful, but it means production review must trace the actual request path rather than rely on folder names. A secure page can become unsafe if a Client Component receives a secret prop, a route handler forgets authorization, or a cached fetch returns tenant-specific data to the wrong viewer.

The plain-language purpose is this: before deployment, prove that every externally reachable route has the right owner, data source, cache policy, authorization rule, browser exposure, and failure behavior. The review should catch mistakes while the app is still cheap to change.

Internal Mechanics

The App Router builds a route tree from the app directory. Special files such as page.js, layout.js, loading.js, error.js, and route.js define rendering and request behavior for each segment. Production review starts by mapping that tree because different files create different attack surfaces. A page.js may render server-only data. A route.js accepts raw HTTP requests. A middleware.js can run before selected requests and must stay lightweight. A Client Component marked with 'use client' ships its module graph to the browser.

Rendering mode is the next mechanism. Static rendering can make a response fast and cheap, but it is dangerous for user-specific pages unless dynamic data is isolated correctly. Dynamic rendering handles per-request cookies, headers, sessions, and authorization, but it has higher runtime cost. Incremental revalidation sits between them: the server reuses generated output until a time window or tag/path invalidation asks it to refresh. The checklist should therefore record whether each route is public static content, shared cached content, or private dynamic content.

Data mutation has its own boundary. Server Actions and route handlers run on the server, but that alone does not make them trusted. Treat every action argument, form value, URL parameter, header, and cookie-derived identity as input to validate. Authorize inside the mutation, close to the data operation, because client-side hiding of buttons is only interface behavior. It is not an access control.

Checklist Anatomy

A useful Next.js production checklist is organized around mechanisms rather than vague readiness claims. For each route or feature, capture these fields: route pattern, rendering mode, public or authenticated audience, data classification, cache policy, mutation capability, required environment variables, security headers, observability signal, expected error behavior, and rollback method.

The threat model uses the same map. Assets are the things worth protecting: session cookies, database rows, files, paid API credits, admin actions, and private generated HTML. Entry points are pages, route handlers, Server Actions, image optimization, webhooks, and static assets. Trust boundaries are crossed when browser input reaches server code, when server code calls a database or third-party API, and when cached output is reused across requests. Controls are the validations, authorization checks, cache settings, headers, rate limits, and deployment procedures that reduce risk.

Example 1: Route Inventory

The first example converts a small route list into release checks. It is intentionally simple: the value is the habit of making implicit routing decisions visible. The expected output is a list of route risks that need review before deployment.

const routes = [
  { path: '/', mode: 'static', audience: 'public', data: 'marketing', mutates: false },
  { path: '/dashboard', mode: 'dynamic', audience: 'user', data: 'tenant', mutates: false },
  { path: '/api/admin/users', mode: 'dynamic', audience: 'admin', data: 'account', mutates: true }
];

function reviewRoutes(items) {
  return items.map((route) => {
    const checks = [];
    if (route.audience !== 'public' && route.mode !== 'dynamic') checks.push('verify private route is not statically reused');
    if (route.mutates) checks.push('validate input, authorize inside handler, log outcome');
    if (route.data === 'tenant') checks.push('test cross-tenant access denial');
    return { path: route.path, checks };
  });
}

console.log(JSON.stringify(reviewRoutes(routes), null, 2));

The output should require no checks for the home page, a tenant isolation test for the dashboard, and mutation controls for the admin API. If your real inventory produces empty checks for authenticated or mutating routes, the model is probably missing important metadata.

Example 2: Security Header Baseline

Next.js lets you attach headers from next.config.js. Headers do not replace application authorization, but they reduce browser-side risk. This fragment applies a conservative baseline. In a real app, tune Content-Security-Policy for the scripts, styles, images, analytics, and frame targets you actually use.

const securityHeaders = [
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }
];

module.exports = {
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }];
  }
};

Expected behavior is deterministic: every matched route receives those headers in the HTTP response. Verify with curl -I or a browser network panel after building and starting the production server. If a hosting layer also manages headers, compare the final deployed response, not only local configuration.

Example 3: Threat Ranking

The third example ranks threats by likelihood and impact so review time goes to the right places. This is not a substitute for judgment, but it prevents teams from spending an hour on a cosmetic header while ignoring an admin mutation.

const threats = [
  { name: 'cached tenant page reused for another user', likelihood: 2, impact: 5 },
  { name: 'admin route accepts forged role from request body', likelihood: 3, impact: 5 },
  { name: 'missing alt text on marketing image', likelihood: 4, impact: 1 }
];

function rankThreats(items) {
  return items
    .map((item) => ({ ...item, score: item.likelihood * item.impact }))
    .sort((a, b) => b.score - a.score);
}

console.log(rankThreats(threats).map((item) => `${item.score}: ${item.name}`).join('\n'));

The admin route ranks highest with a score of 15, followed by cached tenant leakage at 10. The accessibility issue still matters, but it should not displace controls that protect private account data or privileged mutations.

Design Choices and Trade-Offs

The hardest checklist decisions usually involve caching, placement of interactivity, and rollout. Static pages are excellent for public product, documentation, and pricing content, but user-specific HTML must not be generated once and shared accidentally. Dynamic pages simplify per-request authorization and cookie access, but raise latency and infrastructure cost. Tag-based or path-based revalidation is useful when content changes after publishing, but you must document who can trigger invalidation and what stale content is acceptable.

Client Components are appropriate for browser state, event handlers, local UI transitions, and interactive controls. They are the wrong place for secrets, privileged database queries, and authorization decisions. Server Components keep implementation code out of the browser bundle, but any data they render still becomes visible in HTML or streamed payloads. The review question is not merely where code executes; it is what the user can observe.

Server Actions reduce boilerplate for form mutations, while route handlers are clearer for webhooks, public APIs, non-form clients, and custom HTTP semantics. Both need schema validation, authorization, idempotency where retries are possible, and bounded error responses.

Failure Modes and Troubleshooting

Symptom: one user occasionally sees another tenant’s dashboard data. Cause: a route or fetch was cached as shared output while using tenant-specific data. Diagnose: inspect route rendering mode, search for fetch cache options, reproduce with two accounts, and compare response headers. Correct: force dynamic rendering for the private route or move shared cached data away from tenant-specific queries.

Symptom: production login works locally but fails after deployment. Cause: missing environment variables, wrong callback origin, insecure cookie settings, or a proxy changing host headers. Diagnose: list required variables, inspect deployed values without printing secrets, check callback URLs, and review Set-Cookie attributes. Correct: fix deployment configuration, rotate exposed secrets, and add a startup validation check.

Symptom: an admin action appears disabled in the UI but succeeds when called directly. Cause: authorization was enforced only in a Client Component. Diagnose: send a direct request or submit the Server Action as a lower-privilege user. Correct: check role and ownership inside the server mutation before changing data.

Symptom: error pages hide useful diagnostics during an incident. Cause: logs contain unstructured messages or omit route, deployment version, and failure category. Diagnose: trigger a known failure in staging and trace it from browser symptom to server log. Correct: log bounded identifiers, status, route, and error class while avoiding tokens, cookies, request bodies, and personal data.

Security, Performance, and Reliability

Security review should include CSRF exposure for cookie-authenticated mutations, webhook signature verification, upload limits, image domain allowlists, dependency updates, secret rotation, and least-privilege database credentials. Performance review should measure production builds, route latency, cache hit rate, bundle size for Client Components, slow database queries, and cold starts if the deployment platform uses serverless execution. Reliability review should prove that failed third-party calls degrade predictably, retries do not duplicate purchases or emails, and rollback can restore a known-good deployment.

Hands-On Lab

Prerequisites: a small Next.js App Router project, Node.js installed, a package manager, access to the deployment environment or a staging equivalent, and at least one authenticated route or mock authenticated route.

  1. Build a route inventory with columns for path, file, rendering mode, audience, data type, mutation behavior, cache policy, and owner.
  2. Run npm run build and save the route output. Mark any route whose generated mode does not match the inventory.
  3. Add or review security headers in next.config.js. Start the production server locally and verify with curl -I http://localhost:3000/.
  4. Pick one authenticated page and test it with two different users or fixtures. Confirm that each user only sees their own records.
  5. Pick one mutation. Submit valid input, malformed input, and a request from an unauthorized identity. Verify that only the valid authorized request changes data.
  6. Trigger one controlled dependency failure, such as a disabled mock API key. Confirm the user-facing error, server log category, and absence of leaked secrets.
  7. Write the rollback step: redeploy the previous build, disable the feature flag, or revert the relevant environment variable. Execute it in staging if possible.

Verification: the lab is complete when the route inventory matches the production build, headers are visible in HTTP responses, private data isolation is demonstrated, unauthorized mutation is denied on the server, and rollback has a named command or platform action. Cleanup: remove test users, delete generated test records, restore any disabled credentials, and reset feature flags.

Assessment Exercises

  1. A dashboard reads cookies and fetches account data, but the page was designed as a static route for speed. Explain the risk and propose a safer rendering and caching design.
  2. A Server Action receives { role: 'admin' } from a form. Identify the trust boundary and rewrite the control strategy without trusting that field.
  3. Your build output shows a route is dynamic after a developer added headers(). What production trade-off changed, and what evidence would you collect before accepting it?
  4. Create a threat entry for a webhook endpoint. Include the asset, attacker goal, likely entry point, control, and verification step.
  5. Choose one route in your app and define the exact log fields that would help diagnose failures without exposing secrets or personal data.

Summary

A Next.js production checklist is effective when it follows the framework’s real execution model: route tree, server-client boundary, rendering mode, cache reuse, mutation path, and deployment behavior. A threat-model review adds adversarial thinking to the same map. Together they produce release evidence: private routes are dynamic or safely isolated, mutations validate and authorize on the server, headers are present, secrets stay server-side, failures are diagnosable, and rollback is rehearsed.