Continuous Integration and Deployment for React Apps

Understand how a CI/CD pipeline automatically validates and ships a React application — from an automated build pipeline in GitHub Actions, through environment-variable handling, preview deployments, and production deployment, to rolling back a bad release.

Continuous Integration (CI) means every change is automatically built, checked, and tested the moment it's pushed, so problems are caught in minutes rather than discovered days later in production. Continuous Deployment (CD) extends that automation all the way to actually shipping the change once it passes. Together, CI/CD turns 'is this safe to ship?' from a manual, error-prone judgment call into a repeatable, automated gate — the same gate every single change has to pass through, no exceptions.

The shape of a build pipeline

A CI pipeline for a React project is a sequence of automated steps that run on every push or pull request: install dependencies, type-check, lint, run unit/integration tests, build the production bundle, and often run end-to-end tests against the built app. Each step should fail fast and fail loud — if type checking fails, there's no point wasting time running the full test suite, so pipelines are usually ordered cheapest-and-most-likely-to-fail first.

The critical property of a good pipeline is that it runs the exact same checks a developer would run locally, just automatically and on infrastructure nobody can skip past. This is why the earlier lessons on dependency locking (npm ci), linting/formatting checks, and Git-based pull requests all converge here: CI is where those individual practices get enforced as a single, unavoidable gate before code reaches production.

GitHub Actions awareness

GitHub Actions is GitHub's built-in CI/CD system: a YAML file under .github/workflows/ defines one or more jobs, each made of steps, triggered by events like a push or a pull request. A job runs on a fresh virtual machine (a 'runner'), checks out the repository, and then executes whatever commands the steps specify — typically installing Node, restoring a dependency cache, and running the project's own npm scripts (lint, test, build) so the pipeline logic itself lives in package.json, not duplicated inside the YAML.

Jobs can run in parallel (e.g., lint and unit tests running simultaneously on separate runners) or be chained with needs: so a later job only starts once an earlier one succeeds. Caching node_modules or npm's cache directory between runs, keyed off the lock file's hash, is what keeps pipeline runtimes reasonable as a project grows — otherwise every run pays the full dependency-install cost from scratch.

• Workflow files live in .github/workflows/*.yml • Triggers: on: push, on: pull_request, on: workflow_dispatch (manual), scheduled cron triggers, etc. • Jobs run on isolated runners; steps within a job share that runner's filesystem • Cache dependencies keyed on the lock file hash to avoid reinstalling from scratch every run

Environment variables and secrets

A React app typically needs different configuration per environment — an API base URL that points to a staging backend in preview deployments and a production backend in production, for instance. Vite (and similar tools) expose build-time environment variables prefixed VITE_ (e.g. VITE_API_URL) that get baked into the bundle at build time; anything not prefixed that way is not exposed to client code, which is an intentional safety boundary — never put a genuine secret (a private API key, a database credential) in a client-side environment variable, because it will be visible in the shipped JavaScript to anyone who opens dev tools.

In CI, secrets (API tokens, deployment credentials) are stored in the platform's encrypted secrets store — GitHub Actions' repository or environment secrets — and injected into the workflow as environment variables at run time, never committed to the repository or hardcoded in the workflow YAML in plaintext.

• Client-exposed env vars (e.g. VITE_*) are baked into the bundle and are effectively public • True secrets belong only on the server/build system, injected via CI's encrypted secrets store • Different environments (dev/preview/production) typically get different values for the same variable name

Preview and production deployment

A preview deployment builds and deploys the app from a pull request's branch to a temporary, shareable URL, distinct from production, so reviewers and stakeholders can click through the actual running change before it merges — this catches visual and behavioral issues that a code diff alone can't reveal. Preview deployments are typically ephemeral: they're created when the PR opens or updates, and torn down when it closes or merges.

Production deployment happens after a change merges to the main branch (or a release branch, depending on the team's branching strategy) and passes every required CI check. Many teams gate production deploys behind an additional manual approval step for extra safety, even when everything else is automated, especially for high-traffic or regulated applications.

• Preview deploy: one per open pull request, temporary URL, built from the feature branch • Production deploy: triggered by merge to main (or a release event), built from the mainline • Some pipelines add a required manual approval gate before the production deploy step runs

Rollback

Even a fully automated, well-tested pipeline will occasionally ship something broken — a bug that only appears with real production traffic or data, a third-party API behaving unexpectedly, a performance regression under real load. Rollback is the ability to quickly revert production to the last known-good deployment. The fastest, safest rollback is usually redeploying a previous build artifact (most hosting platforms — Vercel, Netlify, and others — keep prior deployments available and let you 'promote' an older one back to production instantly), rather than trying to write and ship a hotfix under pressure.

A git revert (creating a new commit that undoes a previous one, as opposed to a rebase-style history rewrite) is the correct way to undo a bad change at the source level once the immediate fire is out, since it preserves history and re-triggers the normal CI/CD pipeline for the revert itself.

• Fastest rollback: re-promote a previous build artifact on the hosting platform, if supported • Source-level fix: git revert <bad-commit> — creates a new commit undoing the change, safe on shared history • Investigate root cause after stabilizing production, not before — restore service first

A GitHub Actions workflow for a React app

npm ci (not npm install) is used for deterministic installs, exactly as covered in the dependency management lesson. The deploy-production job uses needs: so it only runs after build-and-test succeeds, and if: restricts it to pushes on main. environment: production lets GitHub require a manual approval before this job runs, and scopes the PROD_DEPLOY_TOKEN secret so it's unavailable to PR builds.

# .github/workflows/ci.yml
name: CI

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

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Type check
        run: npx tsc --noEmit

      - name: Lint
        run: npm run lint

      - name: Unit tests
        run: npm test

      - name: Build
        run: npm run build
        env:
          VITE_API_URL: ${{ secrets.STAGING_API_URL }}

      - name: E2E tests
        run: npm run test:e2e

  deploy-production:
    needs: build-and-test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: ./scripts/deploy-prod.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}

Code Examples

Vite exposes any environment variable prefixed VITE_ to client code via import.meta.env, baked in at build time. A different VITE_API_URL value is supplied per environment (local, preview, production) via the CI/build configuration, without changing a line of source code.

const API_URL = import.meta.env.VITE_API_URL;

Common mistake: Putting a genuine secret (e.g. a private third-party API key) behind a VITE_ prefix — since it's baked into the public JS bundle, anyone can read it in browser dev tools.

The immediate priority is restoring service, which is almost always faster via re-promoting a previous build than writing and shipping a new fix under pressure. git revert (not a history rewrite) then cleanly undoes the offending commit at the source level, going through the same CI/CD pipeline as any other change.

// src/api/client.ts
const API_URL = import.meta.env.VITE_API_URL;

export async function fetchUser(id: string) {
  const res = await fetch(`${API_URL}/users/${id}`);
  if (!res.ok) throw new Error("Failed to fetch user");
  return res.json();
}
# Fast path: re-promote the last known-good build on the hosting platform
# (exact command depends on platform; conceptually:)
$ platform-cli deployments list
  dpl_9f2a  <- currently live, broken
  dpl_8b71  <- previous, known-good
$ platform-cli deployments promote dpl_8b71

# Source-level fix, once service is restored:
$ git log --oneline -5
a1b2c3d fix: correct pagination bug
9e8f7g6 feat: add checkout summary   <- introduced the regression
$ git revert 9e8f7g6
# opens editor for the revert commit message, then:
$ git push origin main
# CI runs automatically on the revert commit; once green, it deploys normally

React app CI/CD pipeline

Every push runs the same CI gate; only a push that passes every step is eligible to deploy, either to a preview URL (for a PR) or to production (on merge to main).

Git Push
   │
   ▼
┌─────────────────────────────────────────────────┐
│                       CI                         │
│  Install → Type Check → Lint → Test → Build → E2E │
└─────────────────────────────────────────────────┘
   │  (all steps pass)
   ▼
 Deploy
   ├── PR branch  → Preview deployment (temp URL)
   └── main merge → Production deployment

Important Rules

Order CI steps cheapest/fastest-to-fail first (install, type check, lint) before expensive steps (E2E tests).

Never put real secrets in client-exposed environment variables (e.g. VITE_* in Vite) — they end up in the public bundle.

Store secrets only in the CI platform's encrypted secrets store, injected at run time.

Gate production deploys behind all CI checks passing, and consider an explicit manual approval step for high-stakes apps.

Prefer re-promoting a previous known-good build for the fastest rollback; follow up with a git revert at the source level.

Why this matters in production

Platforms like Vercel and Netlify popularized the preview-deployment workflow specifically because reviewing a live, clickable version of a change catches far more real issues than reading a diff — a misaligned button or a broken responsive layout is often invisible in code but obvious in the browser. Teams with well-tuned CI/CD and one-click rollback can recover from a bad deploy in minutes; teams without it can spend hours diagnosing and manually reverting a production incident.

Hardcoding an API key directly in a GitHub Actions YAML file.

Store it in the repository's (or environment's) encrypted secrets and reference it as ${{ secrets.NAME }}.

A hardcoded secret in a YAML file is committed to Git history in plaintext and visible to anyone with read access to the repository.

Trying to hand-write and deploy a hotfix under pressure the moment production breaks.

Roll back to the last known-good deployment first to restore service, then investigate and fix at a normal pace.

A rushed hotfix written under incident pressure is itself likely to introduce a new bug; restoring service first buys time to fix the root cause properly.

Why should CI steps like type-checking and linting typically run before slower steps like E2E tests? A. GitHub Actions requires alphabetical step ordering B. To fail fast — catching cheap, common problems before spending time on expensive steps C. E2E tests cannot run in CI at all D. Type checking and linting are not real CI steps

B. To fail fast — catching cheap, common problems before spending time on expensive steps — Ordering cheap, likely-to-fail checks first means the pipeline fails quickly on common issues instead of wasting time running expensive steps that don't matter if the build is already broken.

Why is it dangerous to put a real secret in a VITE_-prefixed environment variable? A. Vite doesn't support the VITE_ prefix B. VITE_-prefixed variables are baked into the client-side bundle and are visible to anyone viewing the shipped JavaScript C. It will cause the build to fail D. It only works in preview deployments, not production

B. VITE_-prefixed variables are baked into the client-side bundle and are visible to anyone viewing the shipped JavaScript — Build-time client env vars are compiled directly into the public bundle, so anything placed there is effectively public, not a secret.

What is generally the fastest way to restore service after a bad production deployment? A. Writing and shipping a hotfix commit immediately B. Re-promoting the last known-good build artifact on the hosting platform, then investigating the root cause afterward C. Deleting the production environment and recreating it D. Waiting for the next scheduled deployment

B. Re-promoting the last known-good build artifact on the hosting platform, then investigating the root cause afterward — Redeploying a previous known-good build is typically near-instant and doesn't risk introducing a new bug under incident pressure, unlike writing a rushed fix.

What distinguishes a preview deployment from a production deployment? A. A preview deployment uses a different programming language B. A preview deployment is a temporary deployment of a pull request's branch for review; production deploys from the mainline after merge C. Preview deployments never run any CI checks D. There is no meaningful difference

B. A preview deployment is a temporary deployment of a pull request's branch for review; production deploys from the mainline after merge — Preview deployments let reviewers click through the actual running change on a temporary URL before it merges, while production deployment happens from the mainline branch after all checks pass.

Write a GitHub Actions workflow for a React project that runs on every pull request: installs dependencies with npm ci, type-checks, lints, runs unit tests, and builds — failing the whole job if any step fails. Then add a second job that only deploys to production when the workflow runs on main and the first job succeeded. Hint: Use needs: <job-id> to chain the deploy job after the build/test job, and if: github.ref == 'refs/heads/main' to restrict it to the main branch.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.