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 buildwith exit code 137 (OOM kill) on routes with largegetStaticPathsarrays. - 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 rollbackStep-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 fromgetStaticPropsdecorated with{ next: { revalidate: N } }.next/cache/images/— optimised image variants (only relevant ifnext/imageis 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 daysJenkins — 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.
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 ceilingExpected 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 artifactsFor 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.
Related
- Optimizing Webpack and Vite for CI Environments — module resolution tuning and persistent cache strategies that complement Next.js SWC caching.
- Build Optimization & Caching Strategies — the full domain covering layer caching, remote caching, and incremental build detection.
- Setting Up Redis-Backed Remote Caching for GitLab CI — extend runner-local
.next/cacheto a shared Redis store for distributed teams. - How to Structure a Monorepo CI Pipeline for Next.js and Node — combine
next buildwith workspace-aware pipeline design to avoid redundant compilations. - Artifact Management Strategies for Frontend Builds — versioning, retention, and promotion patterns for
.next/output across environments.