modern-react-spa

Chapter 37

Delivery Pipelines

Delivery pipelines for React SPAs — GitHub Actions matrix for Node + Bun + Deno, preview deploys on Vercel / Netlify / Cloudflare Pages, OpenFeature flags, and SPA canary releases.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can wire a GitHub Actions matrix for Node + npm + Vite (with optional Bun / Deno jobs), set up preview deploys to Vercel / Netlify / Cloudflare Pages, run feature flags through OpenFeature, and ship canary releases of an SPA.

🧭 Prerequisites — Ch 17 (Vite), Ch 18 (Bun/Deno), Ch 19 (monorepo), Ch 28 (feature flags).


🔹 37.1 GitHub Actions matrix

The core CI workflow for a Vite SPA (or monorepo — Ch 19 §19.7):

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
  push: { branches: [main] }

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: 'npm' }
      - run: npm ci
      - run: npm run build
      - run: npm test
      - run: npm run lint

  build-bun:
    runs-on: ubuntu-latest
    continue-on-error: true                # observer mode (Ch 18.6 stage 1)
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with: { bun-version: 1.x }
      - run: bun install --frozen-lockfile
      - run: bun run build
      - run: bun run test

The build-bun job runs alongside Node, doesn’t block merges. Watch its times; if Bun’s a clear win and stays stable for a month, promote it to the primary job.

Matrix for multiple Node versions

strategy:
  matrix:
    node: [20, 22]

For libraries (Ch 11) you usually test against the LTS versions you support. For apps you usually pin to one — the one you’ll deploy with.

Concurrent cancellation

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

When a developer pushes a follow-up commit to a PR, the previous CI run cancels. Saves minutes; saves $.

Caching the install + Playwright browsers

- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      ~/.cache/ms-playwright
    key: deps-${{ hashFiles('package-lock.json') }}

setup-node with cache: 'npm' handles the npm cache; the Playwright cache is separate (Ch 36.10).


🔹 37.2 Preview deploys

The pattern: every PR gets a unique URL. Designers and PMs review there before merge.

Vercel — the default

Push a PR; Vercel auto-builds; the PR gets a comment with the preview URL. Zero config beyond connecting the repo.

For monorepos (Ch 19):

Project: acme-web
  Root directory:     apps/web
  Install command:    cd ../.. && bun install --frozen-lockfile
  Build command:      cd ../.. && turbo run build --filter=web
  Output directory:   dist

Repeat per app. Each PR gets web-pr-123.vercel.app, admin-pr-123.vercel.app.

Cloudflare Pages

Same shape, different dashboard. Wrangler CLI for scripted deploys.

Netlify

Same shape. Netlify pioneered the preview-deploy pattern; still solid.

Self-hosted alternatives

  • Render, Fly.io, Railway — all support PR previews.
  • For air-gapped enterprise: spin up your own preview infra with a small script + S3 + an evict-after-N-days lambda.

The PR review flow:

  1. Developer pushes PR.
  2. CI runs; preview deploy publishes.
  3. Bot comments the URL on the PR.
  4. Designer reviews on the URL; comments on Figma/PR.
  5. Lighthouse CI fails the PR if budgets are exceeded (Ch 30).
  6. Merge → production deploy.

🔹 37.3 Feature flags

Cross-link Ch 28.2. The mechanism:

OpenFeature + a provider

import { OpenFeature } from '@openfeature/web-sdk';
import { GrowthBookProvider } from '@openfeature/growthbook-provider';

OpenFeature.setProvider(new GrowthBookProvider({
  clientKey: env.VITE_GROWTHBOOK_KEY,
  attributes: { tenantId: currentTenant.id, userId: currentUser.id },
}));
import { useFlag } from '@openfeature/react-sdk';

const { value: showNewCheckout } = useFlag('new-checkout-flow', false);

if (showNewCheckout) return <NewCheckout />;
return <LegacyCheckout />;

The deploy / release split

  • Deploy — the code goes to production. The flag controls visibility.
  • Release — flip the flag; the feature is live.

This decouples scary-merge from scary-release. The risky flag-flip can happen Monday at 10am with the team watching, not Friday at 5pm because that’s when the PR merged.

Flag governance

Flags accumulate. Set rules:

  • Every flag has an owner and an expiry date.
  • Quarterly review removes flags past expiry.
  • Flags that haven’t flipped in 90 days are candidates for removal.

A 5-year-old “experimental_v2” flag is tech debt with a fancier name.


🔹 37.4 Canary releases for SPAs

Server apps canary easily (route 5 % of traffic to the new version). SPAs are harder because the user’s browser caches the bundle.

The percentage-rollout pattern

// at app load:
const inCanary = await fetch('/api/canary-flag').then((r) => r.json());
if (inCanary && !document.cookie.includes('canary=1')) {
  // load the canary bundle instead of the main one
  window.location.replace('/canary/');
  return;
}

Two builds deployed:

  • / — current stable.
  • /canary/ — new build.

A backend endpoint decides per-user. Cookie sticks the user to one variant for the session.

The version-pinning pattern

For long-lived sessions (admin tools): every API response includes a X-App-Version header. If the client’s loaded version differs from the server’s expected version, show “New version available — refresh.”

const checkVersion = (res: Response) => {
  const v = res.headers.get('X-App-Version');
  if (v && v !== currentVersion) showUpdateBanner(v);
  return res;
};

The flag-gate-everything pattern

Instead of canarying the whole app, ship one big “v2 mode” feature flag. Internal users get it first; gradually widen. Bug found? Flip the flag, no redeploy.

Cache-busting the SPA shell

Even with canaries, your index.html must not be cached forever. Common headers:

index.html        Cache-Control: no-cache
*.js, *.css       Cache-Control: max-age=31536000, immutable  (content-hashed)

The hashed assets cache forever; the entry HTML revalidates every request.

🪤 Common Pitfalls

  1. CI without concurrency cancellation → wasted minutes on superseded commits.
  2. Preview deploys with auth — anonymous reviewers can’t see them. Use signed URLs or basic auth.
  3. Feature flags without expiry → tech debt accumulation.
  4. Canary rollout without a kill switch — bug hits 100 % users while you scramble.
  5. SPA index.html cached forever — users stuck on old versions for weeks.
  6. Lighthouse CI with aspirational budgets nobody enforces.

✅ Recap

  • GitHub Actions matrix with cancel-in-progress and cache.
  • Preview deploys for every PR; designer review before merge.
  • Feature flags decouple deploy from release; OpenFeature is the spec; pick a provider.
  • Canary SPAs via percentage rollout, version-pinning, or feature-flag gating.
  • Cache the assets, not the shell.

🔗 Further Reading

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (4)
  1. 37.1 GitHub Actions matrix
  2. 37.2 Preview deploys
  3. 37.3 Feature flags
  4. 37.4 Canary releases for SPAs