Skip to content

GitHub Actions for Frontend

intermediate22 min read

Why Your Frontend Needs a CI Pipeline

Here is the thing that separates teams who ship confidently from teams who ship and pray: automated pipelines. Every PR that lands without running lint, type-check, tests, and a build is a gamble. Maybe it works. Maybe it breaks production on a Friday at 5pm. You do not want to find out.

GitHub Actions gives you a CI/CD system that lives right inside your repository. No separate service to configure, no webhook plumbing, no "it works on the CI server but not here" debugging. Your workflows are YAML files committed alongside your code, versioned, reviewed, and reproducible.

Mental Model

Think of GitHub Actions like a recipe card taped to your oven. Every time someone wants to "cook" (merge a PR), the recipe runs automatically: check ingredients (lint), follow the steps (type-check, test), taste the result (build). No one has to remember the recipe — it just runs. If any step fails, the oven beeps and the dish does not ship.

Workflow Anatomy: The Building Blocks

Every GitHub Actions workflow has three layers: triggers, jobs, and steps. Understanding this hierarchy is the key to writing workflows that are fast, correct, and maintainable.

name: CI

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

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

Let us break this down piece by piece.

Triggers: When Does the Workflow Run?

The on key defines your triggers. The most common ones for frontend projects:

  • push — Runs when code is pushed to specified branches. Use this for main branch deployments.
  • pull_request — Runs when a PR is opened, synchronized (new commits pushed), or reopened. This is your primary CI trigger.
  • schedule — Runs on a cron schedule. Useful for nightly dependency audits or Lighthouse runs.
  • workflow_dispatch — Manual trigger from the GitHub UI. Great for on-demand deploys or cache-clearing.
on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'package.json'
      - 'pnpm-lock.yaml'
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'
  workflow_dispatch:

The paths filter is a performance win most teams miss. If someone edits a README, there is no reason to run your full test suite. Filter by paths that actually affect the build.

Quiz
A workflow has on: pull_request with no branches filter. A developer opens a PR targeting a feature branch (not main). Does the workflow run?

Jobs: Parallel Execution Units

Jobs run in parallel by default. Each job gets its own fresh virtual machine — they share nothing unless you explicitly pass data between them.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm lint

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm tsc --noEmit

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm test

  build:
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm build

The needs keyword creates dependencies between jobs. Here, build waits for all three checks to pass before running. Lint, typecheck, and test run simultaneously — cutting your total CI time from the sum of all jobs to the duration of the slowest one.

Key Rules
  1. 1Jobs run in parallel by default — use needs to create sequential dependencies
  2. 2Each job gets a fresh VM — files from one job are not available in another without artifacts
  3. 3A failed job cancels dependent jobs unless you use if: always() to override
  4. 4Keep jobs focused on a single concern — lint, typecheck, test, build as separate jobs

Steps: Sequential Commands Within a Job

Steps within a job run sequentially on the same machine. They share the filesystem, so one step can install dependencies and the next step can use them.

There are two types of steps:

  • uses — Runs a published action (like actions/checkout@v4)
  • run — Executes a shell command
steps:
  - uses: actions/checkout@v4

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

  - name: Install dependencies
    run: pnpm install --frozen-lockfile

  - name: Run linter
    run: pnpm lint

Always pin actions to a specific major version (@v4) or commit SHA. Using @main or @latest means a breaking change in the action can silently break your CI.

Common Trap

Using npm install instead of npm ci (or pnpm install --frozen-lockfile) in CI is a classic mistake. npm install can modify your lockfile if versions drift, making your CI build different from what developers tested locally. Always use the ci variant or --frozen-lockfile flag to ensure reproducible installs.

Caching: The Biggest Performance Win

Without caching, every CI run downloads and installs every dependency from scratch. For a typical Next.js project, that is 30-90 seconds of wasted time on every single run.

Caching node_modules

The simplest approach uses the built-in cache support in actions/setup-node:

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

This caches the pnpm store directory and restores it when the lockfile has not changed. But for maximum speed, you can cache node_modules directly:

- name: Cache node_modules
  id: cache-deps
  uses: actions/cache@v4
  with:
    path: node_modules
    key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

- name: Install dependencies
  if: steps.cache-deps.outputs.cache-hit != 'true'
  run: pnpm install --frozen-lockfile

The if condition skips the install step entirely when the cache hits. The cache key includes the OS and lockfile hash, so any dependency change busts the cache.

Caching Next.js Build Output

Next.js builds are expensive. Caching .next/cache lets subsequent builds reuse compiled pages and webpack chunks:

- name: Cache Next.js build
  uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

The restore-keys fallback is important. If source files changed but dependencies did not, you still get a partial cache hit from the previous build. This alone can cut build times by 40-60%.

Quiz
Your CI workflow caches node_modules with the key deps-linux-abc123. A developer adds a new dependency. What happens on the next CI run?

Matrix Builds: Testing Across Environments

Matrix builds let you run the same job across multiple configurations — Node versions, operating systems, or browser targets — without duplicating YAML.

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: [18, 20, 22]
      fail-fast: false
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm test

This creates 6 parallel jobs (2 OSes times 3 Node versions). fail-fast: false means all combinations run to completion even if one fails — you want to know all the environments that break, not just the first one.

Excluding and Including Specific Combinations

Sometimes certain combinations do not make sense or need special handling:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    node-version: [18, 20, 22]
    exclude:
      - os: windows-latest
        node-version: 18
    include:
      - os: ubuntu-latest
        node-version: 22
        experimental: true

The exclude key removes specific combinations. The include key adds extra properties to specific combinations or adds entirely new ones.

When to use matrix builds for frontend projects

Most frontend projects do not need matrix builds for day-to-day CI. If you deploy to a single environment (Linux, Node 20), testing on Windows and Node 18 adds time without value. Reserve matrix builds for library authors who need cross-environment compatibility, or for scheduled nightly runs that catch compatibility regressions without slowing down PR feedback.

The Production-Ready Frontend CI Workflow

Here is a complete workflow that covers everything a production frontend project needs:

name: CI

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

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

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

      - uses: pnpm/action-setup@v4
        with:
          version: 9

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

      - name: Cache node_modules
        id: cache-deps
        uses: actions/cache@v4
        with:
          path: node_modules
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

      - name: Install dependencies
        if: steps.cache-deps.outputs.cache-hit != 'true'
        run: pnpm install --frozen-lockfile

  lint:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
      - run: pnpm lint

  typecheck:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
      - run: pnpm tsc --noEmit

  test:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
      - run: pnpm test

  build:
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/cache/restore@v4
        with:
          path: node_modules
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-

      - run: pnpm build

A few things to notice:

  • concurrency — If you push twice quickly, the second run cancels the first. No wasted minutes on outdated commits.
  • Install-first pattern — One job installs and caches. Subsequent jobs restore from cache without re-installing. This saves 30-60 seconds per parallel job.
  • actions/cache/restore — Read-only cache restore. Only the install job writes to the cache; downstream jobs just read.

Secrets Management

Never hardcode API keys, tokens, or credentials in your workflow files. GitHub provides encrypted secrets that are injected at runtime.

- name: Deploy to Vercel
  run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}
  env:
    VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
    VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

Secrets are masked in logs — if a secret value accidentally appears in output, GitHub replaces it with ***. But this is not foolproof. A step could base64 encode a secret and print it, bypassing the mask. Treat secret exposure as a security incident regardless of masking.

Key Rules
  1. 1Store secrets in Settings → Secrets and Variables → Actions — never in workflow YAML
  2. 2Secrets are not available in workflows triggered by forks (for security)
  3. 3Use environment-scoped secrets for staging vs production separation
  4. 4Rotate secrets on a schedule — treat them like passwords

Reusable Workflows: DRY for CI

When multiple repositories share similar CI patterns, reusable workflows eliminate duplication. You define a workflow once and call it from other workflows.

# .github/workflows/shared-ci.yml (in a shared repo)
name: Shared Frontend CI

on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: '20'
    secrets:
      NPM_TOKEN:
        required: false

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm tsc --noEmit
      - run: pnpm test
      - run: pnpm build

Consuming it from another repository:

# .github/workflows/ci.yml (in your project)
name: CI

on:
  pull_request:
    branches: [main]

jobs:
  ci:
    uses: your-org/shared-workflows/.github/workflows/shared-ci.yml@main
    with:
      node-version: '20'
    secrets: inherit

The workflow_call trigger makes a workflow callable. The uses keyword in the calling workflow references it. secrets: inherit passes all the caller's secrets through — no need to enumerate each one.

Quiz
Your reusable workflow is in a private repository called org/shared-workflows. A public repository in the same org tries to call it. What happens?
What developers doWhat they should do
Using npm install in CI instead of npm ci
npm install can modify the lockfile, making CI builds non-reproducible
Always use npm ci or pnpm install --frozen-lockfile
Running all checks in a single job sequentially
A single job means a lint failure still waits for tests to finish before reporting. Parallel jobs give faster feedback.
Split lint, typecheck, test, and build into parallel jobs
Caching node_modules without the lockfile hash in the key
Without the lockfile hash, dependency updates silently use stale cached modules
Always include hashFiles of the lockfile in your cache key
Not using concurrency groups on PR workflows
Without it, pushing 5 quick commits runs 5 full CI pipelines. Only the latest commit matters.
Add concurrency with cancel-in-progress: true

Putting It All Together

A well-structured CI pipeline is not about checking boxes — it is about creating a safety net that lets your team move fast without breaking things. The fastest teams are not the ones who skip CI to save time. They are the ones whose CI is so fast and reliable that it never gets in the way.

Start with the basics: lint, typecheck, test, build. Add caching immediately — the ROI is massive. Use concurrency groups to avoid wasted runs. Graduate to reusable workflows when you have multiple repositories sharing patterns.

The goal is a pipeline that runs in under 3 minutes for typical PRs. If your CI takes longer than that, developers start opening new tabs, context-switching, and forgetting what they were doing by the time the results come back. Fast CI is not a luxury — it is a productivity multiplier.

1/8