DEV Community

Steve Zhang
Steve Zhang

Posted on

Why Istanbul Coverage Doesn't Work with Next.js App Router

If you've tried to set up code coverage for a Next.js 13+ application with the App Router, you've likely hit a wall. The traditional approach — using Istanbul with babel-plugin-istanbul — simply doesn't work anymore. Here's why, and what you can do about it.

The Traditional Approach: Istanbul + Babel

For years, Istanbul has been the standard for JavaScript code coverage. It works by instrumenting your code at build time — injecting counter variables that track which lines, branches, and functions are executed.

// Original code
function add(a, b) {
  return a + b;
}

// After Istanbul instrumentation
function add(a, b) {
  cov_1234.f[0]++;  // Function counter
  cov_1234.s[0]++;  // Statement counter
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

This approach requires a Babel plugin (babel-plugin-istanbul) to transform your code during the build process. It worked great with Create React App, older Next.js versions, and any project using Babel as its compiler.

The Problem: Next.js Moved to SWC

Starting with Next.js 12, and becoming the default in Next.js 13+, the framework switched from Babel to SWC (Speedy Web Compiler) for transpilation. SWC is written in Rust and is significantly faster than Babel.

But here's the catch: Istanbul's instrumentation plugin only works with Babel.

When you try to add a .babelrc file to enable Istanbul, Next.js detects it and falls back to Babel — but this:

  • Disables SWC's performance benefits
  • Can break other SWC-dependent features
  • Often causes configuration conflicts with Next.js's internal setup

The App Router Makes It Worse

Next.js 13 introduced the App Router with React Server Components (RSC). You might think: "I'll just add a .babelrc and force Babel mode." But the App Router creates a critical problem:

Server Actions Break with Babel

When you enable Babel in a Next.js project that uses Server Actions, the build fails. Server Actions require special compiler handling that SWC provides but Babel doesn't support correctly.

// app/actions.ts
'use server'

export async function submitForm(formData: FormData) {
  await db.insert(formData);
}
Enter fullscreen mode Exit fullscreen mode

Try to build this with a .babelrc file present:

$ npm run build
# Build fails with Server Action compilation errors
Enter fullscreen mode Exit fullscreen mode

This isn't a configuration issue — it's a fundamental incompatibility. Next.js's Server Action transformation relies on SWC-specific features that babel-plugin-istanbul can't work with.

The Catch-22

You're stuck in an impossible situation:

  1. Istanbul requires Babel → Add .babelrc to enable instrumentation
  2. Server Actions require SWC → Build fails with Babel enabled
  3. No coverage for Server Actions → Can't measure what your E2E tests exercise

The Common Workaround: Skip E2E Coverage Entirely

Many teams simply give up on E2E coverage and rely only on unit tests:

Unit tests (Vitest/Jest) → Istanbul coverage ✓
E2E tests (Playwright/Cypress) → No coverage ✗
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Server Components remain completely untested for coverage
  • No visibility into what E2E tests actually exercise
  • Coverage reports show artificially low numbers

The Solution: V8 Native Coverage

The fundamental issue is that Istanbul requires pre-instrumentation of source code. But there's another approach: runtime coverage collection.

V8, the JavaScript engine that powers Node.js and Chrome, has built-in coverage support. It tracks code execution at the engine level — no instrumentation needed.

How V8 Coverage Works

  1. For Node.js (server): Set NODE_V8_COVERAGE=./coverage environment variable
  2. For Chrome (browser): Use Chrome DevTools Protocol (CDP) to enable coverage
  3. After tests: Read the coverage data from V8's native format

This approach works with any bundler, any compiler (Babel, SWC, esbuild), and any framework. The code runs exactly as it would in production — V8 just tracks what gets executed.

The Critical Role of Source Maps

There's one catch: V8 coverage reports line/column positions in the bundled code, not your original TypeScript/JSX files. To get meaningful coverage reports, you need source maps.

// next.config.ts
const nextConfig = {
  productionBrowserSourceMaps: !!process.env.E2E_MODE,
  webpack: (config, { isServer }) => {
    if (process.env.E2E_MODE) {
      config.devtool = 'source-map'
    }
    return config
  },
}

export default nextConfig
Enter fullscreen mode Exit fullscreen mode

Then build with the environment variable:

E2E_MODE=true npm run build
Enter fullscreen mode Exit fullscreen mode

With source maps, the coverage tool can:

  • Map bundled code positions back to original source files
  • Show coverage for your TypeScript/JSX, not minified JavaScript
  • Produce reports that reference your actual codebase

This is why nextcov requires source maps in your production build — without them, you'd only see coverage for unreadable bundled code.

V8 Coverage with Next.js

For Next.js applications, you need to collect coverage from multiple processes:

Process Coverage Method
Next.js Server (RSC, Server Actions) NODE_V8_COVERAGE env var
Browser (Client Components) Playwright CDP integration
Test Runner Orchestrates collection

This is exactly what nextcov does. It:

  • Collects V8 coverage from both server and browser during Playwright E2E tests
  • Uses source maps to map bundled code back to original TypeScript/JSX
  • Converts V8 format to Istanbul format for familiar reports
  • Merges with unit test coverage for a complete picture

Getting Started with V8 Coverage

If you're ready to move beyond Istanbul for your Next.js App Router application:

npm install nextcov --save-dev
npx nextcov init
Enter fullscreen mode Exit fullscreen mode

The init command sets up everything you need:

  • Global setup/teardown for Playwright
  • CDP connection to Next.js server
  • Source map processing
  • Report generation

Then run your E2E tests:

# Build with source maps
E2E_MODE=true npm run build

# Start server with coverage and run tests
NODE_V8_COVERAGE=.v8-coverage NODE_OPTIONS='--inspect=9230' npm start &
npx playwright test
Enter fullscreen mode Exit fullscreen mode

Conclusion

Istanbul served us well for over a decade, but the JavaScript ecosystem has evolved. With:

  • SWC replacing Babel as the default compiler
  • Server Actions requiring SWC-specific transformations that break Babel

...it's time for a new approach.

V8 native coverage works with modern tooling, not against it. It collects accurate coverage data from both client and server without requiring any code transformation.

If you're building with Next.js App Router and want complete coverage visibility, check out nextcov.


This is part 2 of a series on test coverage for modern React applications:

  1. nextcov - Collecting Test Coverage for Next.js Server Components
  2. Why Istanbul Coverage Doesn't Work with Next.js App Router (this article)
  3. V8 Coverage vs Istanbul: Performance and Accuracy (coming soon)
  4. V8 Coverage Limitations and How to Work Around Them (coming soon)
  5. How to Merge Vitest Unit and Component Test Coverage (coming soon)
  6. E2E Coverage in Next.js: Dev Mode vs Production Mode (coming soon)

Top comments (0)