Optimizing Next.js Static Generation in CI Pipelines

next build OOM-kills your CI runner or exports stale pages when default configuration meets a large getStaticPaths array or a shared cache namespace — this page shows you exactly how to fix both.

When to use this pattern

  • Your CI runner terminates next build with exit code 137 (OOM kill) on routes with large getStaticPaths arrays.
  • Multiple concurrent branches hit the same build cache namespace and retrieve each other’s stale artifacts.
  • You need to guarantee deployment parity: the HTML emitted by CI must be byte-identical to what your staging baseline produced.

Prerequisites

Complete working example

The snippet below is a full GitHub Actions job. Drop it into .github/workflows/build.yml, adjust the Node version and runner label, and it works without further edits.

# .github/workflows/build.yml
name: Next.js SSG Build

on:
  push:
    branches: ["main", "develop"]
  pull_request:

jobs:
  ssg-build:
    runs-on: ubuntu-latest        # swap for self-hosted if you use one
    env:
      # Raise the V8 old-space ceiling before the runner forks any Node process.
      # 4096 MB covers most getStaticPaths arrays; bump to 6144 for 10k+ routes.
      NODE_OPTIONS: "--max-old-space-size=4096"
      # Stops Next.js phoning home during build — saves ~200 ms per run.
      NEXT_TELEMETRY_DISABLED: "1"
      # Ensures next/image optimisation targets production URL origins.
      NODE_ENV: production

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22        # pin to an LTS; avoid "lts/*" for reproducibility
          cache: npm              # caches ~/.npm — separate from .next/cache

      # Restore the Next.js compiler and page cache from prior runs.
      # Key covers both the lockfile (dependency graph) and next.config.js
      # (experimental flags, image domains, rewrites — all affect compiled output).
      - uses: actions/cache@v4
        id: next-cache
        with:
          path: .next/cache
          key: >-
            next-${{ runner.os }}-${{ github.ref_name }}-${{
              hashFiles('**/package-lock.json', '**/next.config.js') }}
          # Fallback: same branch without the hash → then any branch.
          # Keeps cross-branch reads isolated to the same-OS prefix only.
          restore-keys: |
            next-${{ runner.os }}-${{ github.ref_name }}-
            next-${{ runner.os }}-

      - name: Install dependencies
        run: npm ci               # deterministic install from lockfile

      - name: Build
        run: next build           # uses .next/cache if restored above

      # Gate: confirm the rendered manifest matches the staging baseline.
      # Generate the baseline once with: cp .next/prerender-manifest.json staging-baseline.json
      - name: Parity check
        run: |
          if [ -f staging-baseline.json ]; then
            diff .next/prerender-manifest.json staging-baseline.json \
              || { echo "Parity mismatch — aborting promotion."; exit 1; }
          fi

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: nextjs-build-${{ github.sha }}
          path: .next/
          retention-days: 7       # keep artefact long enough for rollback

Step-by-step walkthrough

Environment block

NODE_OPTIONS: "--max-old-space-size=4096" is set at job level rather than inside a run: step so it applies to every Node.js process the runner spawns — including npm ci’s post-install scripts and any next build child workers. Setting it only on the run: next build line leaves install-time scripts at the default 512 MB ceiling, which causes silent GC thrashing when a monorepo has heavy post-install transforms.

NEXT_TELEMETRY_DISABLED: "1" is not merely a politeness flag. On cold runners without DNS warmup, the telemetry POST request can time out and block the build process for 10–30 seconds.

Cache restoration

The path: .next/cache stanza targets the three subdirectories Next.js writes between builds:

  • .next/cache/webpack/ — SWC-compiled module chunks
  • .next/cache/fetch-cache/fetch() responses from getStaticProps decorated with { next: { revalidate: N } }
  • .next/cache/images/ — optimised image variants (only relevant if next/image is used at build time)

The key includes next.config.js in hashFiles() because any change to experimental flags, custom webpack plugins, or image domain lists invalidates the compiler cache. Omitting it is the most common cause of stale-cache-hit bugs where an old compiled chunk is loaded after a config change.

github.ref_name scopes the primary key to the current branch, preventing main’s cache from leaking into a feature branch that has a different webpack config mid-PR.

The restore-keys: fallback descends: same branch without hash → same OS prefix. This means a fresh feature branch gets a warm-start from the most recent same-branch build rather than starting cold.

Install step

npm ci is used instead of npm install because it refuses to mutate the lockfile. On a CI runner you never want the install step to silently resolve to different versions than what was committed. Pair this with the cache: npm option on actions/setup-node to avoid re-downloading the npm registry cache on every run.

For monorepo CI pipelines that use workspaces, replace npm ci with npm ci --workspace=apps/web to install only the package that contains the Next.js app.

Build step

next build in Next.js 14+ automatically distributes page compilation across worker threads equal to Math.min(cpus - 1, 8). On a 2-vCPU runner that is 1 worker, so upgrading to 4 vCPUs doubles parallelism without any config change. Monitor runner CPU steal time — if it stays above 30% throughout the build, you are hitting hypervisor contention and should switch to a self-hosted runner with dedicated cores.

Parity check

The parity step diffs prerender-manifest.json against a committed staging-baseline.json. Generate the baseline once from a known-good local or staging build, then commit it to the repository root. The diff will catch route additions, removals, and revalidation-period changes that slip through unnoticed when reviewing PR diffs. For completeness, also diff build-manifest.json to catch chunk hash regressions caused by non-deterministic bundler output.

Platform equivalents

GitLab CI — use cache: with a key.files fingerprint. GitLab caches are keyed per-runner by default; add CACHE_COMPRESSION_LEVEL: "fastest" for .next/cache since the directory is already compressed by SWC:

build:ssg:
  image: node:22-slim
  variables:
    NODE_OPTIONS: "--max-old-space-size=4096"
    NEXT_TELEMETRY_DISABLED: "1"
    NODE_ENV: production
  cache:
    key:
      files:
        - package-lock.json
        - next.config.js
    paths:
      - .next/cache
    policy: pull-push
  script:
    - npm ci
    - next build
  artifacts:
    paths:
      - .next/
    expire_in: 7 days

Jenkins — pass environment variables via withEnv so they scoped to the stage, not the global environment:

stage('Next.js SSG Build') {
  withEnv([
    'NODE_OPTIONS=--max-old-space-size=4096',
    'NEXT_TELEMETRY_DISABLED=1',
    'NODE_ENV=production'
  ]) {
    sh 'npm ci && next build'
  }
}

Pipeline data flow

The diagram below shows how .next/cache is written and read across runner invocations, and where the parity gate sits relative to the deployment promotion step.

Next.js SSG CI pipeline — cache write/read and parity gate Flowchart showing a CI runner restoring .next/cache, running next build, writing back the updated cache, then passing through a parity gate before artifact promotion. CI RUNNER CACHE STORE GATE + PROMOTE checkout npm ci next build upload artifact restore .next/cache (hashFiles key) write .next/cache (post-build save) parity check diff prerender-manifest promote pass → ← fail: exit 1

Verification

After your first successful run, confirm the cache is being used and the parity gate is active:

# 1. Confirm the .next/cache was restored (GitHub Actions log line to look for):
#    Cache restored successfully from key: next-Linux-main-abc123...

# 2. Check that next build consumed cached webpack chunks (output should mention):
#    info  - Using existing source maps
#    event - compiled successfully in 12s  (vs 90s+ cold)

# 3. Confirm the parity gate ran (look for the diff command in logs):
#    Files .next/prerender-manifest.json and staging-baseline.json are identical

# 4. Verify Node heap headroom (no GC pressure warnings):
node -e "console.log(Math.round(process.memoryUsage().heapTotal/1024/1024) + ' MB allocated')"
# Expected: a value below your NODE_OPTIONS ceiling

Expected build time reduction after first warm cache hit: 60–80% on a Next.js app with 50–500 static routes, based on SWC chunk reuse. The .next/cache/webpack/ directory is the primary contributor; a 200-route app typically saves 45–75 seconds per run once that directory is warm.

Common pitfalls

next.config.js excluded from cache key

Symptom: pages compile against a stale webpack config after a developer adds a new image domain or modifies experimental.serverComponentsExternalPackages.

Fix: Always include next.config.js in hashFiles():

key: next-${{ runner.os }}-${{ hashFiles('**/package-lock.json', '**/next.config.js') }}

Shared cache namespace across branches

Symptom: a PR branch restores a cache written by main that contains pre-rendered pages for a route that no longer exists on the branch, producing phantom exports.

Fix: add ${{ github.ref_name }} as the first dynamic segment of the cache key (as shown in the complete example above). Use restore-keys: for same-branch fallback only.

OOM kill on getStaticPaths arrays above ~3 000 entries

Symptom: runner exits with code 137 mid-build; last log line is a V8 heap allocation failure.

Fix options (apply in order until resolved):

# Option A: raise heap limit
export NODE_OPTIONS="--max-old-space-size=6144"

# Option B: paginate getStaticPaths — only pre-render the N most-visited routes in CI
# and use fallback: 'blocking' for the rest (runtime SSR on first request)

# Option C: split the build across two jobs and merge artifacts

For artifact management strategies that cover multi-job build merges, see that dedicated guide.

Frequently Asked Questions

How do I prevent cache collisions when multiple branches build concurrently?

Scope cache keys to branch name and commit SHA: next-cache-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles(...) }}. Apply restore-keys: fallback only for main or develop to prevent a feature branch from restoring another branch’s artifacts.

Should I disable Incremental Static Regeneration (ISR) in CI?

No. ISR background revalidation only runs in the Next.js production server process — it does not execute during next build. You do not need to disable it; simply ensure CI uses next build and not a persistent next start server that could trigger revalidation.

What is the optimal cache retention period for .next/cache?

7–14 days for lockfile-dependent layers. Next.js internally invalidates stale entries, so the directory can safely span commits on the same branch. Implement automated pruning via CI cron jobs to prevent storage quota exhaustion beyond your runner’s disk allocation.

How can I verify SSG output parity between CI and local environments?

Compare prerender-manifest.json and build-manifest.json checksums. Run diff -rq .next/static/ against a known-good baseline. Enforce CI failure on mismatch to guarantee deployment consistency. For environments where secrets differ between local and CI, also validate via synchronizing environment variables across stages before promoting the artifact.


← Back to Optimizing Webpack and Vite for CI Environments