Next.js Deployment: The Env-Var Mistake That Breaks Every Page
Static export can't run middleware, a restart isn't a rebuild, and a missing env var in a proxy file breaks the whole site on first request. Our own go-live checklist.
next build passing is not the same claim as "the site works once deployed." Three build-time decisions — what gets baked into a static page versus rendered per request, whether an environment variable is frozen at build or read at runtime, and whether your app needs a Node server at all — each fail differently in production than they do under next dev, and none of them show up until you actually deploy. This is the checklist we run against our own storefront, including the near-miss it exists to prevent.
Static export is off the table the moment you need middleware
The first deployment decision is really an architecture decision: can this app ship as static files, or does it need a running Node process? Next.js's static export (output: "export") produces plain HTML/CSS/JS deployable to any static host — but it does not support the proxy layer (formerly Middleware), API routes, or any per-request server logic. Our storefront needs exactly that: src/proxy.ts runs on almost every request to refresh the Supabase session so Server Components see a valid one:
// src/proxy.ts
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(/* … */);
await supabase.auth.getClaims(); // validates the JWT — never getSession()
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|webmanifest|html)$).*)",
],
};
That one file decides the whole deployment shape: a static export can't run it, so the app needs a platform that supports Next's server runtime (Vercel, or a self-hosted Node server) even though the overwhelming majority of individual pages — 272 of 280 routes in our latest build — are prerendered and served as static output. Static-output and static-deployment are different claims; this app is almost entirely the first and cannot be the second.
The mistake that would have broken the site on every page
Before this app's first production deploy, its own CLAUDE.md carries a standing warning: do not run a production deploy until NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are set in the hosting platform's environment. The reason is the file above — proxy.ts runs on every request matched by that config, which is nearly every route on the site, and it calls createServerClient with those two variables asserted non-null (process.env.NEXT_PUBLIC_SUPABASE_URL!). Deploy without them set and every single page — not just the auth-gated ones — throws before it can render, because the proxy that touches every request crashes first.
This is the class of defect that's invisible locally: next dev reads .env.local, which a developer's machine already has. It only shows up on the platform, on the first real request, site-wide, which is the worst possible place to discover a missing environment variable. The fix is procedural, not technical: a documented go-live checklist that puts "environment variables are set in the platform, not just locally" before "run the deploy," and a middleware/proxy file is exactly the kind of code that turns a missing variable into an outage instead of a single broken page.
The env-var gotcha that isn't about missing values
A separate build measured what happens when a value is set, but changes after deploy: next start with an updated NEXT_PUBLIC_* variable still serves the build-time value — in the client JS chunk, and in the CSP connect-src header this app derives from it in next.config.ts. Restarting the server is not the same operation as rebuilding it, because both the client bundle and the response headers were computed once, at build:
// next.config.ts
const supabaseOrigin = (() => {
try {
return new URL(process.env.NEXT_PUBLIC_SUPABASE_URL!).origin;
} catch {
return "https:";
}
})();
// … `connect-src 'self' ${supabaseOrigin}` — baked into every response header at build.
Change that variable on the platform and the running deployment keeps serving the old origin in both places until the next build runs. On a platform like Vercel this usually isn't visible because changing an env var normally triggers a redeploy — but a self-hosted setup that only restarts the Node process on a config change will silently keep serving stale values. The full experiment — grepping the actual build output to confirm this — is in the environment-variables post; the deployment-relevant takeaway is narrower: "redeploy" and "restart" are not interchangeable operations for anything derived from a NEXT_PUBLIC_* variable, and that includes security headers, not just visible UI.
What actually needs checking, by when it can fail
| Check | When it fails | Where |
|---|---|---|
| Required env vars set on the platform | First request after deploy | Middleware/proxy files that run on every route |
next build output matches expectations | Build time | Route table — flag any route you expected static that shows dynamic |
| Changed env var triggers a rebuild, not just a restart | After a config change | Anything baked into the client bundle or response headers at build |
| Static export compatibility | Architecture decision, before any of this | Proxy/Middleware, API routes, anything per-request |
| CSP allows what the deployed app actually loads | First blocked request in the browser console | next.config.ts headers() — see the CSP post |
| Node runtime version matches what CI validated | Build succeeds locally, fails on the platform | Platform's Node version setting vs. local node -v |
The pre-push half of this — lint, format, type-check, the content-graph tests that catch a broken internal link before it ships — is a separate, already-solved problem covered in the launch checklist post. That post is about whether what you're about to deploy is true. This one is about whether the platform you deploy it to is configured to run it correctly — a passing npm run build locally says nothing about whether the target environment has the variables, runtime, or rebuild trigger the app actually needs.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
| Deploying before platform env vars are set | Every page fails, not just auth-gated ones, if a proxy/middleware file asserts them non-null | Set and verify env vars in the platform dashboard before the first deploy |
| Restarting instead of rebuilding after an env change | Client bundle and headers keep serving the old value indefinitely | Trigger a full rebuild for any NEXT_PUBLIC_* or config-time env change |
Assuming output: "export" works because most pages are static | Build fails or silently drops Middleware/API routes | Check for any proxy/Middleware or API route before choosing static export |
| Not checking the route table after a refactor | A page you expected static quietly becomes dynamic, losing CDN caching | Read next build's route symbols (○ static, ● SSG, ƒ dynamic) every deploy |
| CSP written for what you meant to load, not what you actually load | Console errors, broken third-party embeds, in production only | Audit next.config.ts headers against the actual <Script>/fetch calls in the codebase |
| Local Node version newer than the platform's | Build passes locally, fails or behaves differently on the platform | Pin the platform's Node version to match CI |
Frequently asked questions
Can I catch the missing-env-var crash before deploying?
Not with next build alone if the variable is only asserted at request time inside middleware — the build succeeds because the assertion never runs during a build. A staging environment with the same variable requirements as production, deployed before production, is the practical check.
Does every Next.js app need a Node server, or can most be static? Most content-heavy sites (marketing pages, blogs, docs) can be substantially static — our own build prerenders 272 of 280 routes. The presence of even one thing that needs to run per request — auth session refresh, a webhook handler, personalization — pulls the whole deployment target from "any static host" to "a platform that runs Next's server runtime," even if only a handful of routes actually need it.
Is Vercel required, or does self-hosting work? Self-hosting works — Next.js ships a standalone Node server output mode for exactly that. The tradeoffs to plan for yourself: your own CDN and cache-invalidation strategy, your own env-var-change-triggers-rebuild automation, and your own edge network if global latency matters, all of which a managed platform provides by default.
What's the single most common cause of "works locally, breaks in production"?
An environment variable present in .env.local but never added to the platform. It's the exact failure mode this post opens with, and it's invisible in every local check because the local environment always has the variable the deployed one might not.
Templates to deploy from
ASoc Crest Admin is a classic sidebar admin with 5 dashboards and 8 app modules — a build large enough that the route-table check above genuinely matters. ASoc Estate Admin is a real-estate management admin with agent and listing modules behind auth, the same session-refresh-on-every-request shape this post's proxy example covers. ASoc Pulse Admin is a commerce-ops dashboard with a full store back office — another admin build where getting the env-var and CSP checklist right before go-live matters more than for a static marketing page.
Browse the full set of React admin templates, Next.js admin templates, or Tailwind admin templates. For the platform with no framework-aware default, deploying Next.js to AWS prices Amplify Hosting, Lambda, Fargate and EC2 against each other; for what to watch once a deploy is live, error monitoring without an APM vendor.
