A stray .env file broke exactly 13 pages of my Next.js build - and dev mode never saw it
Overview
A stray .env file broke exactly 13 pages of my Next.js build - and dev mode never saw it. The npx next build command failed on precisely 13 pages, every time, with the same cryptic error: TypeError: Invalid URL, input: ''. Meanwhile, npm run dev worked fine. The key insight is that the problem wasn't in the code or recent changes - it was in the environment, specifically a leftover file from a debugging session a month prior that was invisible to Git and code reviews.
The Setup
The app is a Next.js 14 (App Router) SaaS with NextAuth for GitHub OAuth login. The root layout (app/layout.tsx) calls getServerSession() so the UI can detect if a user is logged in:
export default async function RootLayout({ children }: { children: React.ReactNode }) {
let session = null;
try {
session = await getServerSession(authOptions);
} catch (error) {
if ((error as { digest?: string }).digest === "DYNAMIC_SERVER_USAGE") {
throw error;
}
console.error(" [layout] getServerSession failed (likely missing env vars): ", error);
}
return (
<html lang="en">
<body>
<Providers session={session}>
{children}
</Providers>
</body>
</html>
);
}
This try/catch was intentionally added so that missing environment variables during a static build wouldn't crash the entire page - it logs the error, falls back to session = null, and continues. However, this particular error was not caused by the code itself.
The Investigation
Two wrong guesses narrowed the search:
- First guess: Something was missing from
.env.local. The author checkedNEXTAUTH_URL- it was present with a value. - Second guess: Maybe
NEXTAUTH_URL_INTERNAL(a lesser-known NextAuth variable used behind proxies) was set to something malformed. A grep found the line did not exist at all.
Both guesses were wrong. Instead, the author shifted strategy and looked for hard evidence in the compiled output. The build output resides in .next/, so they grepped the compiled chunk for the literal pattern suggested by the stack trace:
grep -o 'new URL([^)]*)' .next/server/chunks/662.js
The result was nineteen matches, all referencing exactly three identifiers: NEXTAUTH_URL, NEXTAUTH_URL_INTERNAL, and VERCEL_URL. These are not application-specific - they come from next-auth/react itself. The author realized that a compiled bundle is a record of what the code actually does, whereas staring at .env files is just hypothesis space.
Why a Client Component Can Crash a Build
The try/catch in layout.tsx was irrelevant to the core problem. The issue lies in how next-auth/react constructs URLs at import time:
// From next-auth/react's SessionProvider module (import time, top-level)
parseUrl(process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL)
When parseUrl receives an empty string, it proceeds through these checks:
(url && !url.startsWith("http"))- short-circuits on falsy, so an empty string skips thehttps://prefix step._url = new URL(url)- whenurlis"", this becomesnew URL(""), which throws immediately:TypeError: Invalid URL, input: ''.
The critical detail is that this happens during module evaluation inside a <Script> equivalent module body - a side effect of importing next-auth/react. Any try/catch written by the developer cannot protect against exceptions raised by dependencies during their top-level execution.
The Confirming Evidence
The stack trace hit exactly 13 pages:
-
/ /blog(one page)- Four blog post pages
-
/compare -
/faq -
/how-it-works - Four
/legal/*pages
Three routes in the app - /signup, /dashboard, /dashboard/team - were unaffected because they have export const dynamic = "force-dynamic", causing Next.js to skip prerendering them at build time. Those routes never evaluate the layout's module tree during a subsequent build, so they never encounter the crash.
Where the Empty String Came From
The culprit was
NEXTAUTH_URL
in .env.local having a real value, but the build was actually reading a different file. Next.js loads environment files in a specific priority order that differs between commands:
| Command | Priority Order |
|---|---|
npm run dev
|
.env.development.local → .env.local → .env.production → .env |
npx next build (production) |
.env.production.local → .env.local → .env.production → .env |
Since npm run dev never read .env.production.local (it prioritized the development file), the build appeared fine. Conversely, the production build always read .env.production.local because it was placed higher in the priority chain.
The root cause was a leftover debug file: .env.production.local was created while pulling environment variables with vercel env pull .env.production.local --environment=production. Vercel's CLI writes empty strings for environment variables marked "sensitive" - it doesn't return the actual values. The author had pulled a file full of blanks, never deleted it, and it silently became the authoritative file for production builds.
Additionally, .env.production.local matched the .env*.local pattern that ships in every default Next.js .gitignore, making it easy to overlook. It was never staged, committed, or visible in diffs.
The Fix
The solution was simple:
rm .env.production.local
npx next build
After removing the stale file and rebuilding, all 24 pages generated successfully with zero errors. The build log shows .next/BUILD_ID timestamps confirming the fix worked.
Quick Checklist
If next build fails while npm run dev succeeds, and the error points toward configuration rather than logic, follow this order:
-
List all
.env*files in the project root:ls -la .env*Any file named
.env.production.local,.env.production, or similar that wasn't intentionally written is suspect by default - delete it and rebuild. -
If the build output already exists, grep the compiled chunks for the specific string in the error (e.g., a URL pattern or variable name) before formulating a third theory about source files you've already checked twice.
-
Treat any command that pulls secrets from a remote source (like
vercel env pull) as potentially problematic - verify the contents are not empty strings, especially after one-off debugging sessions.
Key Takeaways
next buildandnext devdo not read the same environment files. This is documented but easy to forget when a stale file happens to exist.- A tool that writes credentials back to disk can write blanks instead of failing loudly.
vercel env pullreturning exit code 0 tells you nothing about whether the values inside were real. - Compiled output is a reliable record of what the code actually does. Grepping the compiled bundle revealed the exact code path (
parseUrl) that was throwing, bypassing hours of speculative debugging. - Try/catch only protects what runs inside it. Dependencies that perform real work at import time (module-level side effects) can crash builds regardless of wrapper functions in your own code.
Comments
No comments yet. Start the discussion.