How to structure a monorepo CI pipeline for Next.js and Node
Structure a deterministic, cache-efficient GitHub Actions workflow for a monorepo that co-locates a Next.js front-end and one or more Node.js packages — covering dependency graph traversal, remote cache hydration, concurrency control, and rollback safeguards.
When to use this pattern
- Your repository contains at least one Next.js application and one or more shared Node.js packages under a single workspace root, and you need builds to run only for packages affected by a given commit.
- Your current pipeline re-builds the entire monorepo on every push, burning runner minutes even when only a utility package changed.
- You need strict SSR output parity validation before any staging or production deployment gate can pass.
Prerequisites
Architecture: how the pipeline fits together
The diagram below shows the five-stage execution flow for a monorepo containing apps/web (Next.js) and packages/shared (Node utility). Stages run top-to-bottom; the cache restore and affected-detection steps gate everything downstream.
Complete working example
The workflow below is self-contained and copy-paste ready. Inline comments explain every non-obvious decision.
name: monorepo-ci
on:
push:
branches: ["main", "release/*"]
pull_request:
branches: ["main"]
# Serialise deployments per ref; cancel superseded PR runs immediately.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Stage 1: resolve which packages are affected ──────────────────────────
detect_changes:
runs-on: ubuntu-22.04 # pin OS for reproducible native deps
outputs:
affected: ${{ steps.affected.outputs.packages }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history required for git diff base
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci --ignore-scripts # install without lifecycle hooks for speed
# Compute affected packages via Turborepo's dependency graph.
# `turbo run build --dry=json` outputs a JSON summary without executing tasks.
- id: affected
run: |
PKGS=$(npx turbo run build --dry=json \
--filter="...[origin/${{ github.base_ref || 'main' }}]" \
| jq -r '[.tasks[].package] | unique | join(",")' \
|| echo "@app/web,@pkg/shared")
echo "packages=$PKGS" >> "$GITHUB_OUTPUT"
# ── Stage 2 + 3: lint, type-check, build (affected packages only) ─────────
build_and_test:
needs: detect_changes
runs-on: ubuntu-22.04
strategy:
matrix:
node: ["20", "22"]
max-parallel: 4 # cap to avoid runner queue starvation
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "${{ matrix.node }}"
cache: "npm"
# Remote cache: keyed on OS + Node major + all lock files.
# Separate path entries prevent cross-package cache poisoning.
- uses: actions/cache@v4
with:
path: |
.turbo
node_modules/.cache/next
.next/cache
key: >-
monorepo-${{ runner.os }}-node${{ matrix.node }}-
${{ hashFiles('**/package-lock.json','**/turbo.json','**/next.config.*') }}
restore-keys: |
monorepo-${{ runner.os }}-node${{ matrix.node }}-
- run: npm ci --ignore-scripts
# Run lint → type-check → build in topological order for affected pkgs only.
# `||` exit code propagates correctly because turbo exits non-zero on failure.
- name: Run affected tasks
run: |
npx turbo run lint type-check build \
--filter="${{ needs.detect_changes.outputs.affected }}" \
--concurrency=4
# ── Stage 4: SSR parity gate ─────────────────────────────────────────
# Start Next.js from the built artifact and probe the health endpoint.
# This catches build-vs-runtime divergence before any artifact is promoted.
- name: SSR parity validation
run: |
npx next start apps/web -p 3001 &
SERVER_PID=$!
# Retry up to 10 s; Next.js cold-starts vary by build size.
for i in $(seq 1 10); do
curl -sf http://localhost:3001/api/health && break
sleep 1
done
curl -sf http://localhost:3001/api/health \
|| { echo "Health check failed — aborting promotion."; kill "$SERVER_PID"; exit 1; }
kill "$SERVER_PID"
# ── Stage 5: upload artifact tagged with SHA + Node version ──────────
- uses: actions/upload-artifact@v4
with:
name: next-build-node${{ matrix.node }}-${{ github.sha }}
path: apps/web/.next
# Retain three builds to enable rollback without a full rebuild.
retention-days: 7Step-by-step walkthrough
detect_changes — scoping affected packages
The job runs turbo run build --dry=json with a --filter that targets only packages changed relative to origin/main (or the PR base). The dry run resolves the dependency graph without executing any tasks, then jq extracts the affected package names into the affected output. Downstream jobs consume this list via ${{ needs.detect_changes.outputs.affected }}, so nothing is built for unaffected packages.
fetch-depth: 0 is required here: without a full history, git diff cannot compute the base commit and Turborepo falls back to building every package.
Remote cache hydration
The actions/cache@v4 step is keyed on OS, Node major version, package-lock.json, turbo.json, and next.config.*. Including turbo.json and next.config.* in the hash ensures that changes to pipeline topology or webpack/compiler config invalidate the cache immediately rather than serving a stale .next/cache. The restore-keys prefix allows partial hits on older lock files, which typically restores 60–80 % of the module graph even on a first-run after a dependency bump.
To adopt Turborepo remote caching for team-wide cache sharing, set TURBO_TOKEN and TURBO_TEAM as GitHub Actions secrets and add --token=${{ secrets.TURBO_TOKEN }} to the turbo invocation.
Concurrency group and cancel-in-progress
The concurrency block at the workflow level serialises all runs sharing the same ${{ github.workflow }}-${{ github.ref }} key. For PR branches, cancel-in-progress: true reclaims runner minutes when a new commit supersedes an in-flight run. For release branches, remove cancel-in-progress or set it to false to avoid cancelling a deployment mid-flight. The stage-gating model described in designing multi-stage CI/CD pipelines for React apps relies on this serialisation to prevent race conditions at the deployment gate.
max-parallel: 4 under the Node version matrix caps the number of concurrent runners. Beyond six parallel jobs in a standard GitHub-hosted environment, queue depth typically exceeds 60 seconds and negates the throughput gain. For a large monorepo (15+ packages), consider splitting lint/test and build into separate jobs to keep each job under five minutes.
Environment matrix and Node version pinning
The matrix.node array defines explicit Node.js version targets ("20" and "22"). Pair this with an engines field in the root package.json:
{
"engines": { "node": ">=20.0.0" }
}Managing environment matrices in GitHub Actions covers how to gate deployment on a specific matrix leg (e.g. only promote from the Node 22 build) while still using the Node 20 build for compatibility validation.
SSR parity gate
The gate starts next start against the built .next directory, retries the /api/health endpoint for up to ten seconds (allowing for cold-start variance), and kills the process regardless of outcome. A non-zero exit from curl -sf aborts the workflow before upload-artifact runs, so a broken SSR build can never reach the promotion stage.
Artifact tagging for rollback
Naming artifacts with both the Node major version and the Git SHA (next-build-node${{ matrix.node }}-${{ github.sha }}) makes rollback deterministic: download the artifact for the last known-good SHA and re-run the deployment step without triggering a rebuild. Setting retention-days: 7 retains three to five builds for a typical weekly release cadence, covering the standard hotfix window without exhausting artifact storage.
Verification
After the workflow runs, confirm it behaved correctly with these checks:
# 1. Confirm only affected packages ran (inspect the step log for turbo filter output)
gh run view --log | grep "turbo run" | head -5
# 2. Confirm cache was restored (not a cold build)
gh run view --log | grep "Cache restored from"
# 3. Confirm the SSR health check passed
gh run view --log | grep "Health check"
# 4. Confirm the artifact was uploaded with the correct SHA tag
gh api repos/:owner/:repo/actions/runs/:run_id/artifacts \
--jq '.artifacts[] | select(.name | startswith("next-build")) | .name'Expected output for step 4:
next-build-node20-abc1234def5678
next-build-node22-abc1234def5678
Common pitfalls
MODULE_NOT_FOUND for shared utilities after turbo prune
Symptom: next build succeeds locally but fails in CI with Cannot find module '@pkg/shared'.
Root cause: turbo prune in v2+ uses --out-dir (not the deprecated --docker flag). If the shared package is not listed as a direct dependency of apps/web in its package.json, the pruned workspace omits it.
Fix: Declare the dependency explicitly and use the workspace protocol:
{
"dependencies": {
"@pkg/shared": "workspace:*"
}
}Then prune with:
npx turbo prune --scope=@app/web --out-dir=outCache hit but runtime peer-dependency mismatch
Symptom: Pipeline reports a cache hit, but npm run start in CI throws peer dependency warnings that cause runtime failures.
Root cause: The cache key omits either the package-lock.json hash or the Node major version, allowing a cache entry from a different Node version or a previous lock file to be restored.
Fix: Ensure both are in the key:
key: >-
monorepo-${{ runner.os }}-node${{ matrix.node }}-
${{ hashFiles('**/package-lock.json') }}And always install with npm ci --ignore-scripts rather than npm install to respect the exact lock file.
Next.js ISR cache serves stale data after rollback
Symptom: After rolling back to the previous SHA artifact, some pages still serve the new ISR-cached version from the CDN edge.
Root cause: The .next/cache directory was not isolated per SHA in artifact storage, so the rollback artifact still contains cached page payloads from the broken build.
Fix: Isolate .next/cache per SHA by uploading it as a separate artifact and triggering a CDN purge webhook on deployment. Include the ISR cache invalidation step in your rollback runbook.
Frequently Asked Questions
How do I prevent cache collisions across the Next.js app and shared Node packages?
Scope actions/cache path entries by package directory rather than using a single monolithic cache path. Include hashFiles('**/package-lock.json') in the key. Use Turborepo’s --filter flag so each package’s task graph is resolved independently.
What is the optimal max-parallel value for a 10+ package monorepo?
Start with max-parallel: 4. Monitor average queue wait time in the GitHub Actions insights dashboard. If queue wait exceeds 30 seconds, reduce to 3; if jobs finish under two minutes each with headroom, raise to 6. Deployment jobs that write to shared infrastructure should always run with max-parallel: 1.
How do I handle environment parity between CI and production for Next.js ISR?
Pin the exact Node.js version (20.x not 20) across all workflow jobs and in the engines field. Inject production environment variables via GitHub Actions secrets mapped to the same names your next.config.js reads. Validate SSR output by running next start against the built artifact before any deployment gate, as shown in the SSR parity gate step above.
← Back to Designing Multi-Stage CI/CD Pipelines for React Apps
Related
- Designing multi-stage CI/CD pipelines for React apps — the parent guide covering stage-gating, artifact promotion, and environment sequencing for React build pipelines.
- CI/CD Pipeline Architecture & Fundamentals — the top-level reference for pipeline topology, concurrency models, and cost trade-offs across the full CI/CD domain.
- Implementing remote build caching with Turborepo — configure team-wide Turborepo remote caching to share cache hits across developers and CI runners.
- Incremental builds and affected detection in monorepos — deep dive on the dependency graph algorithms that power
--filterand affected-only execution. - Managing environment matrices in GitHub Actions — control multi-version matrix builds and gate deployments on specific matrix legs.