A stray .env file broke exactly 13 pages of my Next.js build - and dev mode never saw it
DEV Community

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:

  1. First guess: Something was missing from .env.local. The author checked NEXTAUTH_URL - it was present with a value.
  2. 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:

  1. (url && !url.startsWith("http")) - short-circuits on falsy, so an empty string skips the https:// prefix step.
  2. _url = new URL(url) - when url is "", this becomes new 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:

  1. 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.

  2. 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.

  3. 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 build and next dev do 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 pull returning 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.
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.