GitHub Actions for Frontend
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.
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.
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.
- 1Jobs run in parallel by default — use needs to create sequential dependencies
- 2Each job gets a fresh VM — files from one job are not available in another without artifacts
- 3A failed job cancels dependent jobs unless you use if: always() to override
- 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 (likeactions/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.
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%.
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 theinstalljob 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.
- 1Store secrets in Settings → Secrets and Variables → Actions — never in workflow YAML
- 2Secrets are not available in workflows triggered by forks (for security)
- 3Use environment-scoped secrets for staging vs production separation
- 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.
| What developers do | What 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.