How to Fix: Maximum Call stack exceeded with NextJS 15. Works on 14

6 min read

Next.js 15 can expose recursive module evaluation that appeared harmless in Next.js 14, and that is exactly why this project crashes at startup with Maximum call stack size exceeded instead of reaching the dev server.

In this case, the failure is typically caused by a circular import chain or a server-side initialization path that becomes recursive under the newer Next.js 15 module/runtime behavior. The repository linked in the issue reproduces the problem during development startup, which strongly suggests the error happens while Next is loading application modules rather than during a user interaction.

Understanding the Root Cause

The key technical difference is that Next.js 15 tightened and updated parts of its runtime, compilation, and server module loading behavior. Code that previously loaded in a forgiving order in Next.js 14 may now evaluate in a way that reveals a hidden recursion bug.

The most common pattern behind this exact symptom looks like one of these:

  • A file imports another module that eventually imports the original file again.
  • A shared barrel export such as index.ts re-exports modules that also import from that barrel, creating a loop.
  • A server utility initializes something globally at import time, and that initialization indirectly re-imports the same utility.
  • A config, auth, db, or environment module is referenced both directly and through a higher-level shared module, causing recursive evaluation.

Why does that become a stack overflow? Because JavaScript module evaluation is not magic: if module A needs module B, and module B immediately needs module A again through another path, startup logic can repeatedly execute accessors, wrappers, or initialization functions until the runtime throws Maximum call stack size exceeded.

With Next.js 15, this often becomes more visible because of changes around App Router, server components, bundling, and dev-time graph traversal. The framework is not necessarily introducing the bug; it is surfacing one that already existed.

A particularly risky structure looks like this:

// lib/index.ts
export * from './db'
export * from './auth'

// lib/db.ts
import { authOptions } from './index'

// lib/auth.ts
import { db } from './index'

That pattern creates a hidden loop through the barrel file. In one version it may appear to work. In another, it collapses during startup.

Step-by-Step Solution

The fix is to remove the recursive import path and ensure that low-level modules do not depend on high-level aggregators.

1. Find the circular dependency

Start by scanning modules involved in startup: app, lib, db, auth, config, and any index.ts barrel files.

If you want a quick dependency check, use a cycle detection tool:

pnpm add -D madge
npx madge --circular .

If the project is large, narrow the scan:

npx madge --circular app lib src

2. Remove barrel-based recursion

If a module imports from a shared index.ts that also re-exports the importing module, replace the import with a direct file import.

Problematic:

// lib/auth.ts
import { db } from './index'

Safe:

// lib/auth.ts
import { db } from './db'

Apply the same pattern everywhere startup modules reference a barrel file.

3. Move side effects out of module scope

If a module creates connections, reads session state, builds auth config, or computes values immediately on import, wrap that logic in a function so it runs only when needed.

Problematic:

// lib/db.ts
import { getConfig } from './config'

const config = getConfig()
export const db = createDb(config)

Safer:

// lib/db.ts
import { getConfig } from './config'

let dbInstance: ReturnType<typeof createDb> | undefined

export function getDb() {
  if (!dbInstance) {
    const config = getConfig()
    dbInstance = createDb(config)
  }
  return dbInstance
}

This reduces the chance of import-time recursion during app boot.

4. Split low-level and high-level concerns

A reliable architecture is:

  • config/env modules depend on nothing app-specific.
  • db depends on config only.
  • auth can depend on db/config, but db should not depend on auth.
  • UI and route handlers depend on those modules, not the other way around.

If two modules need each other, create a third shared utility that both can consume instead of importing each other directly.

5. Verify on Next.js 15

After refactoring imports, clear the build cache and restart development:

rm -rf .next
pnpm install
pnpm dev

If the app starts, the recursive evaluation path has been removed.

6. Example refactor pattern

Here is a practical before/after approach that resolves many Next 15 stack overflow issues.

Before:

// lib/index.ts
export * from './db'
export * from './auth'
export * from './config'

// lib/auth.ts
import { db, env } from './index'

// lib/db.ts
import { env } from './index'

After:

// lib/config.ts
export const env = {
  // env values
}

// lib/db.ts
import { env } from './config'
export const db = createDb(env)

// lib/auth.ts
import { db } from './db'
import { env } from './config'
export const authOptions = createAuthOptions({ db, env })

// optional: keep barrel exports for consumers only
// lib/index.ts
export { db } from './db'
export { authOptions } from './auth'
export { env } from './config'

The important rule is simple: internal module code should import concrete files, not the barrel that re-exports them.

Common Edge Cases

  • Client/server boundary mistakes: importing server-only code into a ‘use client’ file can trigger unexpected bundling paths and confusing startup errors.
  • Dynamic route helpers: a route file importing a helper that imports the route tree again can create recursion indirectly.
  • Singleton patterns gone wrong: a cached db/auth instance created at module scope may still recurse if its factory imports a parent module.
  • Path aliases: imports like @/lib can hide that you are really importing a barrel file. Replace ambiguous alias imports with direct module paths while debugging.
  • Mixed ESM/CJS behavior: if a dependency behaves differently under the newer toolchain, import order can shift and expose an existing cycle.
  • Generated code: ORM, auth, or API client generation may create re-export chains you do not notice until Next 15 evaluates them.

FAQ

Why does it work in Next.js 14 but fail in Next.js 15?

Because Next.js 15 can evaluate your module graph differently during dev startup. That often reveals a circular dependency or import-time side effect that existed all along but did not crash under the older evaluation order.

How can I confirm the issue is a circular import?

Run a dependency analyzer such as Madge, inspect barrel files, and search for modules that import from their own re-export tree. If removing a barrel import or moving initialization into a function fixes startup, that is strong confirmation.

Should I downgrade to Next.js 14?

You can as a temporary unblock, but it is better to fix the underlying module cycle. The code is fragile if framework version changes can trigger a stack overflow, and the same bug may reappear later in production builds, tests, or serverless environments.

The durable fix is to simplify the dependency graph, avoid importing from local barrel files inside the modules they re-export, and keep initialization code out of top-level module scope whenever possible.

Leave a Reply

Your email address will not be published. Required fields are marked *