Designing Multi-Stage CI/CD Pipelines for React Apps

Shipping a React application reliably requires more than a single build-and-deploy job. Without discrete validation gates, a broken type annotation can deploy alongside a broken canary, and the failure surface spans the entire pipeline. This page shows how to decompose a frontend workflow into independently executable stages β€” each with clear entry criteria, runnable config, and verification commands β€” so failures are isolated, fast, and recoverable. It is part of the broader CI/CD Pipeline Architecture & Fundamentals coverage on this site.

Prerequisites

Before wiring up the pipeline, confirm the following are in place:


How Multi-Stage Pipelines Work

A multi-stage pipeline maps each concern β€” static analysis, unit tests, compilation, integration tests, promotion β€” onto a separate job. Jobs declare explicit needs: relationships, forming a directed acyclic graph (DAG). GitHub Actions executes independent branches of the graph in parallel; dependent jobs wait for their predecessors to succeed before starting.

The critical property is failure isolation: a lint failure does not consume build-runner minutes. A failed integration test does not trigger a deploy. Because each job runs in a fresh environment, there is no shared mutable state between stages β€” which eliminates an entire class of flakiness that plagues monolithic scripts.

The diagram below shows the DAG for the pipeline implemented on this page.

Multi-Stage React CI/CD Pipeline DAG Directed acyclic graph showing validate feeding into build, build into integration-test and deploy-canary in parallel, deploy-canary into promote-production after health check passes. validate lint Β· type-check Β· test build matrix Γ— Node 20/22/24 integration-test dedicated runner pool deploy-canary 10 % traffic Β· health check promote 100 % production job boundary β€” each box is an isolated runner environment

Step-by-Step Implementation

Step 1 β€” Define the validate job

The validate job must be the fastest possible gate. It runs lint, TypeScript type checking, and unit tests in sequence. Fail here in under 90 seconds so developers can iterate without waiting for compilation.

name: React Multi-Stage Pipeline

on:
  push:
    branches: [main, staging]
  pull_request:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'          # restores ~/.npm using lockfile hash

      - run: npm ci             # deterministic install from package-lock.json

      - run: npm run lint       # ESLint; fail fast on rule violations

      - run: npm run type-check # tsc --noEmit; catches type errors without emitting

      - run: npm test -- --coverage --ci
        # --ci disables interactive mode; coverage enforces thresholds

Verification: confirm the job exits non-zero on a deliberate lint error before merging this config.


Step 2 β€” Add the matrix build job

The build job compiles the React application and uploads the immutable bundle as a named artifact. A matrix strategy validates compatibility across Node.js versions in parallel β€” three parallel jobs instead of three sequential runs.

  build:
    needs: validate           # blocks until validate succeeds
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22, 24]
      fail-fast: true         # cancel remaining matrix legs on first failure
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci

      - run: npm run build
        env:
          NODE_ENV: production
          # Do NOT bake REACT_APP_API_URL here β€” inject at runtime

      - uses: actions/upload-artifact@v4
        with:
          # Name includes commit SHA and Node version for traceability
          name: react-bundle-${{ github.sha }}-node${{ matrix.node-version }}
          path: dist/
          retention-days: 14   # balances traceability with storage cost
          if-no-files-found: error

Verification: after a successful run, confirm three artifacts appear under the run’s summary page β€” one per Node.js version.

For monorepo setups that need affected-path filtering before reaching the build stage, see how to structure a monorepo CI pipeline for Next.js and Node.


Step 3 β€” Isolate integration tests

Integration tests hit real API contracts, seed databases, and spin up service containers. They are the slowest gate and must not block the critical path to staging. Run them in parallel with deploy-canary rather than sequentially before it.

  integration-test:
    needs: build
    runs-on: ubuntu-latest   # or a dedicated label for heavier runners
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: ci
          POSTGRES_DB: app_test
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - run: npm ci

      - run: npm run db:seed:test
        env:
          DATABASE_URL: postgresql://postgres:ci@localhost/app_test

      - run: npm run test:integration
        env:
          DATABASE_URL: postgresql://postgres:ci@localhost/app_test

Verification: confirm test:integration exits 0 and that the postgres health check appears in the runner log before tests start.


Step 4 β€” Deploy with canary routing

The canary job downloads the exact artifact validated in the matrix (pinned to Node 22, the LTS version), deploys to edge infrastructure at 10 % traffic weight, then validates the endpoint before the workflow proceeds.

  deploy-canary:
    needs: build
    if: github.ref == 'refs/heads/main'   # restrict to main branch merges
    runs-on: ubuntu-latest
    environment: canary                   # requires manual approval if configured
    steps:
      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4
        with:
          name: react-bundle-${{ github.sha }}-node22
          path: dist/

      - name: Deploy to edge at canary weight
        run: ./scripts/deploy.sh --strategy canary --weight 10
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
          RUNTIME_API_URL: ${{ secrets.STAGING_API_URL }}
          # RUNTIME_API_URL injected at deploy time β€” not baked into the bundle

      - name: Synthetic health check
        run: |
          for i in $(seq 1 5); do
            curl -sf https://app.example.com/api/health && break
            sleep 10
          done
        # Retries 5 times; fails the job if the endpoint never returns 200

  promote-production:
    needs: [deploy-canary, integration-test]  # requires both gates
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4
        with:
          name: react-bundle-${{ github.sha }}-node22
          path: dist/

      - name: Promote to 100 % production
        run: ./scripts/deploy.sh --strategy promote --weight 100
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
          RUNTIME_API_URL: ${{ secrets.PROD_API_URL }}

Verification: after promotion, query real-user monitoring or your CDN’s traffic split API to confirm weight is at 100 %. Check that the canary weight returns to 0 so the next release starts from a clean baseline.


Configuration Reference

Key Type Default Effect
strategy.matrix.node-version array β€” Spawns one parallel job per listed version
strategy.fail-fast bool true Cancels remaining matrix legs on first failure; set false to collect all failures
needs string | array β€” Declares job dependencies; job is skipped if any dependency fails
if expression β€” Conditional execution; use github.ref to restrict deploy jobs to specific branches
environment string β€” Associates job with a GitHub Environment, enabling protection rules and secrets scoping
retention-days int 90 Artifact expiry; keep at 14 for build artifacts to control storage costs
if-no-files-found error | warn | ignore warn Fails the upload step if path resolves to zero files β€” catches silent build failures
cache: 'npm' (setup-node) string β€” Restores ~/.npm keyed on package-lock.json hash; cuts install time by 60–80 %

For a full reference to matrix syntax and concurrency controls, see managing environment matrices in GitHub Actions.


Integration with Sibling Topics

This pipeline sits at the intersection of several concerns within the CI/CD Pipeline Architecture & Fundamentals domain:

Upstream β€” artifact storage: The upload-artifact@v4 / download-artifact@v4 pattern above is the simplest form of artifact promotion. For larger teams with cross-pipeline reuse requirements and CDN-backed storage, artifact management strategies for frontend builds covers immutable registries, content-addressable storage, and retention policies in depth.

Concurrent pipelines β€” cost control: When multiple branches run this pipeline simultaneously, shared runners queue. Optimizing pipeline concurrency and queue limits explains how to apply concurrency: groups, cancel-in-progress strategies, and runner autoscaling to keep queue wait times below one minute.

Financial visibility: Each job above should carry COST_CENTER and PROJECT_ID environment variables so spending can be attributed. Tracking CI/CD compute costs for platform teams covers tagging strategies and the cost per run calculation for multi-matrix pipelines.

Build speed: The cache: 'npm' option in actions/setup-node handles node_modules restore, but compiled output β€” Vite/Webpack chunks, TypeScript declaration caches β€” requires a separate caching layer. Build optimization and caching strategies covers Vite’s cacheDir persistence and Webpack’s cache.type: 'filesystem' for CI environments.


Performance Benchmarks

The following numbers are observed on GitHub-hosted ubuntu-latest runners running a medium-sized React SPA (230 components, 180 k source lines):

Stage Without Caching With cache: β€˜npm’ With Full Layer Cache
npm ci 55 s 12 s 9 s
npm run lint 28 s 28 s 28 s
npm run type-check 41 s 41 s 18 s (incremental)
npm run build (Vite) 67 s 67 s 22 s (warm Vite cache)
End-to-end pipeline ~4 min 30 s ~2 min 55 s ~1 min 45 s

Key takeaways:

  • Lockfile-based npm ci caching recovers ~78 % of install time.
  • TypeScript incremental mode (tsBuildInfoFile persisted via actions/cache) cuts type-check time by ~56 %.
  • The three-way Node matrix runs in parallel, so the total wall-clock cost for the build stage is the slowest single leg, not the sum.
  • Canary and production deploys are network-bound (~15–25 s) and are not improved by caching.

Troubleshooting

Cache not found for input keys

Exact error: Warning: Cache not found for input keys: node-20-Linux-...

Root cause: The cache key is derived from package-lock.json. If the lockfile path differs between jobs (e.g., a monorepo with multiple package.json files) or the file does not exist, the key produces no match.

Fix: Explicitly set cache-dependency-path in actions/setup-node:

- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: 'npm'
    cache-dependency-path: apps/frontend/package-lock.json

Artifact not found on download

Exact error: Unable to find any artifacts for the associated workflow

Root cause: The download-artifact@v4 step is referencing an artifact name that did not match what was uploaded β€” usually a mismatch in the ${{ github.sha }} interpolation or a failed upload that produced no files.

Fix: Confirm the artifact name exactly matches across jobs. Add if-no-files-found: error to the upload step so the pipeline fails loudly rather than silently uploading an empty artifact.


Matrix job cancelled before finishing

Exact error: The job was cancelled because fail-fast is enabled

Root cause: fail-fast: true (the default) cancels all remaining matrix legs the moment one fails. During local development or debugging runs this can mask whether Node 20 also fails.

Fix: Set fail-fast: false temporarily to collect all matrix results. Re-enable for production pipelines once the matrix is stable to avoid burning unnecessary runner minutes.


Health check fails immediately after canary deploy

Exact error: curl: (22) The requested URL returned error: 502

Root cause: The CDN or load balancer route has not yet propagated to all edge nodes. The health check fires too soon after the deploy script returns.

Fix: Add a propagation wait with a retry loop (as shown in the canary job above) rather than a fixed sleep. Five retries at 10-second intervals (50 s total) covers edge propagation for major CDN providers without adding unnecessary delay on fast networks.


Frequently Asked Questions

How do I enforce environment parity between staging and production?

Use runtime configuration injection: the RUNTIME_API_URL variable in the canary job above is injected at deploy time rather than baked into the compiled bundle. Validate configuration schemas in a pre-deploy step using a JSON Schema validator or a TypeScript-first tool like zod. Synchronize feature flag states across environments using your feature flag service’s SDK so the same code path executes in staging and production.

What is the right stage granularity for a React pipeline?

Decompose into lint/type-check, unit test, integration test, build, and deploy. Keep the validate stage under two minutes β€” developers iterate fastest when failures arrive before coffee cools. Isolate integration tests into their own job with dedicated service containers so a slow database seed does not delay the canary deploy. Add a bundle size budget check (e.g., bundlesize or size-limit) as an optional gate that fails with a warning rather than blocking the deploy outright.

How do I balance parallel matrix builds against compute costs?

Run the full three-version matrix on pushes to main and staging. On pull requests, restrict to a single version (the active LTS) using an if: expression on the matrix input:

strategy:
  matrix:
    node-version: ${{ github.ref == 'refs/heads/main' && fromJson('[20,22,24]') || fromJson('[22]') }}

This reduces PR matrix runner minutes by two-thirds while still validating cross-version compatibility on every merge. Track the savings via cost attribution tags described in tracking CI/CD compute costs for platform teams.

What deployment strategy minimizes downtime during a bad release?

Use canary with an automated rollback trigger. After the canary deploy step, poll your real-user monitoring (RUM) error rate for five minutes. If the rate exceeds a defined threshold (e.g., a 2Γ— increase over the baseline rolling window), execute deploy.sh --rollback before the promote-production job runs. GitHub Actions continue-on-error: false and job-level if: success() conditions ensure promotion never proceeds if the canary health check or error-rate gate fails.


← Back to CI/CD Pipeline Architecture & Fundamentals