npx next build was failing on precisely 13 pages, every time, with the same cryptic error:
TypeError: Invalid URL, input: ''
npm run dev worked fine. git stash back to a commit from weeks earlier — same 13 pages, same error. Whatever this was, it wasn't something I'd just written.
The git stash step mattered more than it might look. My first assumption, like most people's, was "I broke something recently." Stashing back past several days of commits and reproducing the exact same failure on the exact same 13 pages ruled that out immediately — whatever this was, it wasn't in the diff. It was in the environment, which meant it was invisible to git log, invisible to code review, and invisible to anyone just reading the codebase. That's the property that made this bug worth writing up: it wasn't a logic error anyone could have caught by reading code, because the code was never wrong.
This is the story of chasing that error down to a single leftover file from a debugging session a month prior, and why the fix required understanding something about how Next.js loads .env* files that I'd never had a reason to learn before.
The setup
The app is a Next.js 14 (App Router) SaaS with NextAuth for GitHub OAuth login. app/layout.tsx is the root layout every page goes through, and it calls getServerSession() so the UI can tell if you're 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>
);
}
Note the try/catch there. I'd added it specifically so that missing env vars during a static build wouldn't crash the whole page — log it, fall back to session = null, move on. It felt like it should be bulletproof. It was not the code that was crashing.
Providers is a small client component:
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children, session }) {
return <SessionProvider session={session}>{children}</SessionProvider>;
}
Thirteen lines, no logic. And yet this is where the trail eventually led.
Two wrong guesses, then I stopped guessing
My first instinct was: something's missing from .env.local. I checked NEXTAUTH_URL — it was there, with a value. Not that.
Second guess: maybe NEXTAUTH_URL_INTERNAL (a lesser-known NextAuth variable used behind proxies) was set to something malformed. I grepped .env.local for it. The line didn't exist at all. Not that either.
Two guesses, two misses. At that point I stopped inventing theories about .env.local and went looking for hard evidence instead — specifically, whatever code was actually constructing the URL that was failing. The build output is right there in .next/server/, so I grepped the compiled chunk for the literal pattern the stack trace implied:
grep -o 'new URL([^)]*' .next/server/chunks/662.js
Nineteen matches. All nineteen referenced exactly three identifiers: NEXTAUTH_URL, NEXTAUTH_URL_INTERNAL, and VERCEL_URL. That's not application code — that's next-auth/react itself. My own code doesn't construct a URL object anywhere near auth.
That grep was the actual turning point, and it's worth naming why it worked when guessing didn't. A .env file is a hypothesis space — you can stare at it and imagine a dozen ways a value could be wrong. A compiled bundle is not a hypothesis space; it's a record of what the code actually does. Every new URL(...) call in that chunk is a call some real module makes, using real variable names, and there's no interpretation involved in reading it. I didn't need to know anything about NextAuth internals going in — the grep told me exactly which three environment variables mattered and, implicitly, which package to go read next. Compiled output is one of the few artifacts in a JS project that can't be wrong about what it contains.
Why a client component can crash a build with an uncatchable error
Here's the part that made the try/catch in layout.tsx irrelevant: next-auth/react's SessionProvider module runs parseUrl(process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL) at import time — at the top level of the module, not inside a function that gets called (and could be wrapped) later. By the time Providers even renders, the module has already executed and either succeeded or thrown.
parseUrl itself (from next-auth@4.24.15's utils/parse-url.js) is short:
function parseUrl(url) {
var _url2;
const defaultUrl = new URL("http://localhost:3000/api/auth");
if (url && !url.startsWith("http")) {
url = `https://${url}`;
}
const _url = new URL((_url2 = url) !== null && _url2 !== void 0 ? _url2 : defaultUrl);
// ...
}
The bug hinges on one line: (_url2 = url) !== null && _url2 !== void 0 ? _url2 : defaultUrl. That's just url ?? defaultUrl written out — nullish coalescing. If url is undefined, this correctly falls back to defaultUrl. But if url is an empty string, "" is not null and not undefined, so the nullish check passes it straight through. And a few lines up, if (url && !url.startsWith("http")) also short-circuits on falsy, so an empty string skips the https:// prefix step too. The empty string sails through both guards unmodified and lands in new URL(""), which throws immediately: TypeError: Invalid URL, input: ''.
So somewhere, NEXTAUTH_URL was resolving to "" — not undefined, not missing, but an actual empty string. And because this throw happens during module evaluation inside a Client Component that every route imports through the root layout, it aborts static generation for every route where that import gets evaluated — with no try/catch in my own code anywhere near it, because the exception isn't inside a function call I made; it's inside a <script>-equivalent module body that runs as a side effect of importing next-auth/react.
This is a general property of import, not something specific to this library: importing a module runs its top-level code immediately, synchronously, before you get a reference to anything it exports. A try/catch you write can only guard the code between its braces — it has no reach into work a dependency chose to do the moment it was loaded. Singleton clients, feature-flag SDKs, analytics initializers, anything that reads process.env at the top of a file rather than inside an exported function, can all fail this same way: the crash happens on import, in a stack frame that belongs to someone else's package, at a point in the render tree your own error boundaries were never positioned to reach.
The number that confirmed it
The stack trace hit exactly 13 pages: /, /blog, four blog post pages, /compare, /faq, /how-it-works, and four /legal/* pages. Three routes in the app — /signup, /dashboard, /dashboard/team — were not affected, and all three had export const dynamic = "force-dynamic", meaning Next.js skips prerendering them at build time entirely. They never evaluate the layout's module tree during next build the same way, so they never hit the crash.
Thirteen static pages, thirteen failures, zero false positives among the dynamic ones. That's the kind of exact match that turns a hypothesis into a near-certainty before you've even fixed anything.
Where the empty string was coming from
NEXTAUTH_URL in .env.local had a real value. So why was the build reading ""?
Next.js loads env files in a specific priority order, and it's different depending on which command you run. For next build (which runs with NODE_ENV=production), the order is roughly:
.env.production.local.env.local.env.production.env
next dev, meanwhile, prefers .env.development.local — a completely different file. That's exactly why npm run dev never showed the problem: it was never reading the file that had the bad value.
And sitting in my working directory, dated about a month earlier, was a leftover .env.production.local. I'd created it running vercel env pull .env.production.local --environment=production while debugging something else entirely. What I hadn't caught at the time: Vercel's CLI doesn't return the actual values for env vars marked "sensitive" via env pull — it writes them back as empty strings. I'd pulled a file full of blanks, moved on to the actual thing I was debugging, and never deleted it. Next.js then quietly gave that file top priority over my real .env.local on every production build, from that day forward, for everyone who ran next build locally.
And here's the part that let it hide for a month: .env.production.local matches the .env*.local pattern that ships in every default Next.js .gitignore, right alongside .env.local itself. It was never staged, never committed, never visible in a diff, never something a teammate or a past version of me could have flagged in review. It just sat on disk, correctly ignored by git for exactly the reason .env.local files are supposed to be ignored — because they're meant to hold real secrets — while accidentally also being an override file that Next.js would treat as authoritative the next time anyone ran a production build from that machine. The gitignore rule that protects secrets and the priority rule that resolves env files are two completely independent systems that happen to both key off the same filename convention, and nothing forces them to agree with your intentions at the same time.
The fix, once identified, took one command:
rm .env.production.local
npx next build
The first time I ran that rebuild, it still looked broken — until I noticed the .next/BUILD_ID timestamp didn't match anything I'd just done. I had two checkouts of the repo open (a worktree for an unrelated branch, and the main one), and the rebuild had run in the wrong directory against a .next cache that had nothing to do with the fix. Once I chained the cd and the build into a single command instead of relying on whichever shell happened to have focus, the real result came back clean: all 24 pages generated, zero errors, Environments: .env.local printed at the top of the build log where .env.production.local used to be listed first.
It's a small thing, but it's the same category of mistake as the bug itself — trusting which directory or which file you think you're operating in, instead of checking what the tool actually reports it used. The build log telling you which env files it loaded, in priority order, was there the whole time; I just hadn't been reading that line until this happened.
The lesson that generalizes
A few things about this are specific to Next.js and NextAuth, but the shape of the bug isn't:
next build and next dev don't read the same env files. This is documented, but it's the kind of documented-but-easy-to-forget fact that only bites you when a stale file happens to exist. If a bug only reproduces in one command and not the other, checking which .env* files each one actually loads should be an early step, not a last resort.
A tool that writes credentials back to disk can write blanks instead of failing loudly. vercel env pull succeeding with exit code 0 told me nothing about whether the values inside were real. A pull that returns empty strings for sensitive vars is functionally a silent failure wearing a success message. If you ever run a credential-pulling command for a one-off debugging session, treat the output file as radioactive — name it something that will never be picked up by convention (not .env.production.local), and delete it the moment you're done.
Two wrong guesses is a signal to stop guessing. Both of my first theories were reasonable and both were about the same file (.env.local) that turned out to be completely fine. The thing that actually worked was going one level closer to the truth: instead of theorizing about what might be in an env file, I grepped the compiled output for the literal code path that was throwing. Compiled output doesn't lie about what identifiers it references. If you find yourself on a second incorrect theory, that's usually the point to trade speculation for whatever raw artifact — a compiled bundle, a request log, a database row — can tell you definitively what actually ran.
A try/catch only catches what runs inside it. My layout's try/catch around getServerSession() was correct and necessary, but it gave me false confidence that env-var failures near auth were handled. A separate, unrelated code path — a third-party client component's module-level side effect — could still crash the same render with no relation to the code I'd defended. When a library does real work at import time rather than inside a function you call, no amount of wrapping your own call sites protects you from it.
None of this was a Next.js bug or a NextAuth bug. Both behaved exactly as documented. It was one leftover debugging artifact, two library behaviors that are individually reasonable, and a build tool that reads a different file than the one I was staring at. That combination is what took an afternoon to trace back to a single rm.
A short checklist, if this ever happens to you
If next build fails somewhere next dev doesn't, and the error points toward configuration rather than logic, this is roughly the order I'd check things in now:
-
ls -la .env*in the project root. Anything named.env.production.local,.env.production, or similar that you don't remember writing on purpose is suspect by default — delete it and rebuild before doing anything else. - If the build output already exists, grep the compiled chunks for the specific string in the error (a URL, a variable name, whatever's unique) before forming a third theory about source files you've already checked twice.
- Treat any command that pulls secrets from a remote source (
vercel env pull,aws secretsmanager get-secret-value,doppler secrets download, and so on) as something that can succeed with exit code 0 and still hand you garbage. Sensitive/encrypted values are the most likely category to come back blank, redacted, or truncated depending on the tool's permission model — check a value, don't just check the exit code. - If a third-party package throws during static generation, don't assume the crash originates in a function you called. Client components and providers can run real code — including
new URL(...),fetch, or anything else — the moment they're imported, before your owntry/catchblocks ever get a chance to run.
Every one of those steps would have gotten me to the answer faster than the two guesses I actually made.
Top comments (2)
The grep-the-compiled-output move is the part I'm going to remember from this. Staring harder at a source file you've already checked twice versus reading what the bundle actually references is such a clean way to break a guessing loop, and it generalizes way past env files.
One thing I kept wondering while reading: would a validated env module (something like the zod-schema-plus-t3-env pattern) have caught this before next-auth's SessionProvider ever got to run parseUrl at import time? The catch is that it only helps if that validated env module is the very first thing the root layout imports, ahead of anything else that touches process.env at its own module top level, otherwise you've just moved the race to a different file. And even people who already have one of those schemas in place often declare the field as a bare z.string() rather than adding a .min(1) or .url() check, and plain z.string() is perfectly happy validating an empty string as present. Feels like the same shape of bug one layer up: a check that looks like it covers "missing" actually only covers "undefined."