CI/CD for Frontend Projects: GitHub Actions from Zero to Deploy
Build a complete GitHub Actions CI/CD pipeline for React and Next.js projects. Linting, type checking, testing, accessibility checks, preview deployments, and production deploys.

Why Your Frontend Project Needs CI/CD
Every team has the same story. Someone pushes code that breaks the build. Someone else merges a PR without running tests. A third person deploys on a Friday afternoon and spends the weekend fixing a type error that TypeScript would have caught if anyone had run tsc before pushing.
CI/CD fixes this by making the machine do the checking. Every push triggers linting, type checking, tests, and accessibility scans. Every merge to main triggers a production deployment. No one has to remember to run anything because the pipeline does it automatically.
This guide builds a complete GitHub Actions pipeline for a React or Next.js frontend project. By the end, you will have a workflow that catches errors before they reach production, gives reviewers preview deployments on every PR, and deploys automatically when code lands on main. The same kind of pipeline we set up for the web applications we build.
Prerequisites
- GitHub account with a repository for your project
- Node.js 18 or later installed locally
- A React or Next.js project with a
package.jsonthat hasbuild,lint, andtestscripts configured - Familiarity with your terminal. You should be comfortable running npm/yarn commands and editing YAML files.
- A deployment target (Vercel, Netlify, or similar). This guide uses Vercel for deployment examples, but the CI steps work with any host.
Your First Workflow File
GitHub Actions workflows live in .github/workflows/ at the root of your repository. Each YAML file in that directory is a separate workflow. Create the directory and your first workflow file:
# Create the workflows directory and CI file
mkdir -p .github/workflows
touch .github/workflows/ci.ymlHere is the minimal structure of a workflow file:
# .github/workflows/ci.yml
# This workflow runs on every push and pull request
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run buildWhat Each Piece Does
name is the workflow label that appears in the GitHub Actions tab.
on defines when the workflow runs. push to main and pull_request targeting main are the two triggers you will use most. PRs get checked before merging. Pushes to main trigger the deploy.
jobs contains the actual work. Each job runs on a fresh virtual machine. runs-on: ubuntu-latest gives you a Linux environment, which is the standard for CI.
steps are the individual commands within a job. actions/checkout@v4 clones your repository. actions/setup-node@v4 installs Node.js. npm ci installs dependencies from the lockfile (faster and more reliable than npm install in CI). npm run build runs your build script.
Commit and push this file. Go to your repository on GitHub and click the "Actions" tab. You should see your workflow running.
Step 1: Linting and Formatting
The first thing your pipeline should check is code quality. If someone pushes code with linting errors or inconsistent formatting, the pipeline should catch it before a reviewer spends time looking at the PR.
# Linting and formatting job
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lintIf your project uses Prettier for formatting, add a format check:
# Formatting check step
# Add this as another step in the lint job
- run: npx prettier --check .The --check flag makes Prettier exit with an error if any files are not formatted. It does not modify files. That is what you want in CI: detect the problem, do not fix it silently.
ESLint Configuration Tips for CI
Make sure your ESLint config treats warnings as errors in CI. Warnings that nobody looks at are worse than no linting at all because they train your team to ignore the output. Either fix warnings or configure the rules to be errors:
{
"rules": {
"no-console": "error",
"no-unused-vars": "error"
}
}Or run ESLint with the --max-warnings 0 flag to fail on any warnings:
# Fail on any lint warnings, not just errors
- run: npx eslint . --max-warnings 0Step 2: TypeScript Type Checking
Your build step might catch type errors, but it depends on how your bundler is configured. Some bundlers (Vite with SWC, for example) skip type checking for speed. Run tsc explicitly so type errors never slip through:
# TypeScript type checking job
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx tsc --noEmitThe --noEmit flag tells TypeScript to check types without generating output files. You only want the type checking, not the compilation.
Why Separate from Build?
You could add tsc --noEmit as a step in your build job. But running it as a separate job means it runs in parallel with linting and testing. If your type check takes 30 seconds and your tests take 2 minutes, running them in parallel saves 30 seconds on every pipeline run. That adds up fast.
Step 3: Running Tests
Tests are the core of your pipeline. If tests pass, you have reasonable confidence the code works. If they fail, the PR gets blocked before anyone reviews it.
# Testing job with coverage
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test -- --coverage --watchAll=falseThe --watchAll=false flag is important. Most test runners (Jest, Vitest) default to watch mode locally. In CI, you want a single run that exits with a pass/fail status.
Test Configuration for CI
If you use Vitest, your vitest.config.ts can include CI-specific settings:
// vitest.config.ts
// CI-aware test configuration
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
coverage: {
reporter: ["text", "lcov"],
exclude: ["node_modules/", "src/test/"],
},
// Disable watch mode when running in CI
watch: false,
},
});Handling Flaky Tests
If you have tests that sometimes pass and sometimes fail, do not add retries as a first response. Flaky tests mean something is wrong: a race condition, shared state between tests, or a dependency on external services. Fix the flake. Retries hide bugs.
If you genuinely need retries for integration tests that depend on external services, use them sparingly and log when a retry happens so you can track flake rates.
Step 4: Accessibility Checks in CI
This is the step most frontend pipelines skip. Automated accessibility testing in CI catches regressions before they ship. It does not replace a manual audit, but it catches the things machines can catch: missing alt text, missing form labels, color contrast violations, and broken ARIA attributes.
Using axe-core with Your Test Framework
The @axe-core/react package or jest-axe / vitest-axe integrations let you run accessibility checks inside your existing tests:
# Install the accessibility testing library
npm install --save-dev jest-axe// src/components/Button/Button.test.tsx
// Accessibility test for a component
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Button } from "./Button";
expect.extend(toHaveNoViolations);
test("Button has no accessibility violations", async () => {
const { container } = render(
<Button onClick={() => {}}>Click me</Button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});This test renders your Button component and runs axe-core against the output. If there are any WCAG violations, the test fails with a detailed report of what is wrong and how to fix it.
Running Lighthouse in CI
For page-level accessibility checks, you can run Lighthouse CI:
# Install Lighthouse CI
npm install --save-dev @lhci/cli# Lighthouse CI step
# Runs after the build step completes
- run: npm run build
- run: npx lhci autorun --collect.staticDistDir=./outConfigure Lighthouse CI with a .lighthouserc.json file:
{
"ci": {
"assert": {
"assertions": {
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:performance": ["warn", { "minScore": 0.8 }]
}
}
}
}This fails the pipeline if the accessibility score drops below 90 and warns if performance drops below 80.
Step 5: Preview Deployments on PRs
Preview deployments give reviewers a live URL for every pull request. Instead of pulling the branch and running it locally, they click a link and see the changes in a real browser. This speeds up reviews significantly.
Vercel Preview Deployments
If your project is connected to Vercel, preview deployments happen automatically. Vercel's GitHub integration creates a deployment for every push to a PR branch and posts the URL as a comment on the PR.
If you need to trigger Vercel deployments from GitHub Actions (for example, to run tests before deploying), use the Vercel CLI:
# Preview deployment job
# Only runs on pull requests
preview:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Deploy to Vercel Preview
run: npx vercel --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}The needs: [lint, typecheck, test] line is key. It means the preview only deploys after all checks pass. No point deploying broken code for review.
Netlify Preview Deployments
Netlify works similarly. If your repository is connected to Netlify, deploy previews are automatic. For manual control through GitHub Actions:
# Netlify preview deployment step
- name: Deploy to Netlify Preview
uses: nwtgck/actions-netlify@v3
with:
publish-dir: "./out"
production-deploy: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}Step 6: Production Deployment on Merge to Main
When a PR is merged to main, the production deployment should happen automatically. No manual deploy steps, no "I'll deploy it later," no forgetting.
# Production deployment job
# Only runs on pushes to main (merged PRs)
deploy:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Deploy to Vercel Production
run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}The if condition ensures this only runs on pushes to main, not on PR branches. Combined with needs, the full flow is: code merges to main, lint/typecheck/test run in parallel, and only if all three pass does the production deploy happen.
Caching and Optimization
A pipeline that takes 10 minutes is a pipeline people start ignoring. Caching dependencies is the single biggest optimization you can make.
Caching node_modules
# Node.js setup with built-in caching
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"The cache: "npm" option on actions/setup-node caches the npm cache directory automatically. On subsequent runs, npm ci checks the cache before downloading packages. This typically cuts install time from 30-60 seconds to 5-10 seconds.
If you use Yarn or pnpm, change the cache value:
# Caching for Yarn or pnpm
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn" # or "pnpm"Running Jobs in Parallel
Notice that we defined lint, typecheck, and test as separate jobs. By default, jobs in GitHub Actions run in parallel. This means your 30-second lint, 20-second type check, and 2-minute test suite all start at the same time. The total pipeline time is 2 minutes (the slowest job), not 2 minutes and 50 seconds.
The deploy job uses needs to wait for all three. This is the pattern:
# Parallel jobs with a dependent deploy step
jobs:
lint:
# runs immediately
typecheck:
# runs immediately, in parallel with lint
test:
# runs immediately, in parallel with lint and typecheck
deploy:
needs: [lint, typecheck, test]
# waits for all three to passCaching the Next.js Build
If you are building a Next.js project, caching the .next/cache directory speeds up subsequent builds:
# Next.js build caching
- uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ hashFiles('**/package-lock.json') }}-Secrets and Environment Variables
Your pipeline will need API tokens, deployment keys, and other sensitive values. Never put these directly in your workflow file.
Adding Secrets in GitHub
Go to your repository on GitHub. Click Settings > Secrets and variables > Actions > New repository secret. Add each secret with a name and value:
| Secret Name | What It Is |
|---|---|
VERCEL_TOKEN | Your Vercel personal access token |
VERCEL_ORG_ID | Your Vercel organization ID |
VERCEL_PROJECT_ID | Your Vercel project ID |
NETLIFY_AUTH_TOKEN | Your Netlify personal access token (if using Netlify) |
NETLIFY_SITE_ID | Your Netlify site ID (if using Netlify) |
Using Secrets in Workflows
Reference secrets with the ${{ secrets.SECRET_NAME }} syntax:
# Using secrets in a workflow step
- name: Deploy
run: npx vercel --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 automatically. GitHub replaces secret values with *** in any log output. You do not need to do anything special to prevent them from leaking in logs.
Environment Variables for Builds
Non-sensitive configuration (like NEXT_PUBLIC_SITE_URL) can go in the workflow file directly or in GitHub's environment variables (Settings > Environments):
# Setting environment variables for the build
- run: npm run build
env:
NEXT_PUBLIC_SITE_URL: https://yourdomain.com
NEXT_PUBLIC_API_URL: https://api.yourdomain.comThe Complete Workflow File
Here is the full workflow combining everything from this guide:
# .github/workflows/ci.yml
# Complete CI/CD pipeline for a React/Next.js frontend project
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npx prettier --check .
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx tsc --noEmit
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm test -- --coverage --watchAll=false
preview:
name: Preview Deploy
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: Deploy Preview
run: npx vercel --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
deploy:
name: Production Deploy
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: Deploy Production
run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}This pipeline runs lint, type checking, and tests in parallel on every push and PR. Preview deployments go out on PRs after checks pass. Production deploys happen on merge to main after checks pass. Dependencies are cached for speed.
Copy this file, adjust the deployment steps for your hosting provider, add your secrets in GitHub, and you have a production CI/CD pipeline. The whole setup takes about 15 minutes and saves you from every "oops, that broke production" moment going forward.
Common Questions
What people usually ask about frontend CI/CD with GitHub Actions.
GitHub Actions is free for public repositories. For private repositories, you get 2,000 minutes per month on the free plan and 3,000 minutes on the Pro plan. A typical frontend pipeline run uses 3-5 minutes across all jobs. For most teams, the free tier is more than enough. If you exceed it, additional minutes cost $0.008 per minute on Linux runners.
Start with one file. A single ci.yml that handles both CI (on PRs) and CD (on merge to main) is simpler to maintain and easier to understand. Split into multiple files only when you have genuinely different workflows, like a separate workflow for nightly E2E tests or a release workflow with manual triggers. Splitting too early creates more files to maintain without any benefit.
Three things make the biggest difference: cache dependencies (the cache: "npm" option on setup-node), run jobs in parallel (separate lint, typecheck, and test into their own jobs), and cache your framework's build output (.next/cache for Next.js). If your test suite is the bottleneck, consider splitting tests across multiple runners with a sharding strategy. Most pipelines should complete in under 5 minutes with these optimizations.
Use GitHub Environments. Create a "Preview" environment and a "Production" environment in your repository settings, each with its own variables. Reference them in your workflow with environment: preview or environment: production on the job. This keeps preview and production config separate and lets you require approvals for production deploys if needed.
Yes, using a tool called act (https://github.com/nektos/act). It runs your workflow files locally using Docker containers. It does not perfectly replicate the GitHub Actions environment, but it catches most YAML syntax errors and logic issues without requiring a push to test. Install it with brew install act on Mac or check the GitHub releases for other platforms.