GitHub Actions CI/CD for Next.js: Test, Build, Deploy Pipeline
Set up a production GitHub Actions CI/CD pipeline for Next.js — lint, type-check, test, build, and deploy automatically on every push.
Why Bother Setting This Up
Manual deploys are a liability. You push, you forget to run tests, you ship a broken build at 4pm on a Friday, and now you're reverting commits instead of eating dinner. It's a rite of passage, sure — but it's also completely avoidable.
GitHub Actions, introduced back in 2018 but now deeply mature as of 2026, gives you a free CI/CD layer baked right into the repo. No separate CircleCI account, no Jenkins server eating memory in the corner. For Next.js projects specifically the integration is tight — you get native caching for .next/cache, first-class Vercel CLI support, and a workflow syntax that's actually readable once you know the shape of it.
In practice, a well-structured pipeline pays for itself in the first week. Your team stops asking 'did you run the tests?' before every merge. Deploys become boring. That's the goal — make shipping boring.
This guide walks through a complete pipeline: ESLint, TypeScript checks, Jest tests, Next.js production build, and deployment to Vercel. You can adapt the deploy step for any host. Worth noting: all of this runs on the free tier of GitHub Actions — 2,000 minutes per month for public repos, unlimited for private repos on Team plans.
Project Structure and Prerequisites
You need a Next.js 14+ project with a working package.json. The examples here assume pnpm but the commands translate directly to npm or yarn — just swap the invocations. You'll also need a Vercel account and a VERCEL_TOKEN stored as a GitHub repository secret.
Grab your Vercel token from the Vercel dashboard and add it under Settings → Secrets and variables → Actions in your repo. Name it VERCEL_TOKEN. While you're there, also add VERCEL_ORG_ID and VERCEL_PROJECT_ID — you get those by running vercel link locally once.
Quick aside: if you're building a component-heavy Next.js app — say, something using Empire UI's glassmorphism or aurora components — you probably already have Storybook or some visual tests. Those can slot into this pipeline too, after the unit test step. But let's get the fundamentals solid first.
Your .github/workflows/ directory is where everything lives. Create it if it doesn't exist. The main file is ci.yml. One file, one workflow, triggered on pushes to main and on all pull requests.
The Core Workflow File
Here's the complete ci.yml that handles lint, type-check, test, and build. Read through it once before I break it down — the structure is intentional.
# .github/workflows/ci.yml
name: CI / CD
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
PNPM_VERSION: '9'
jobs:
quality:
name: Lint + Type-check + Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: ESLint
run: pnpm lint
- name: TypeScript
run: pnpm tsc --noEmit
- name: Unit tests
run: pnpm test --passWithNoTests
build:
name: Next.js Build
runs-on: ubuntu-latest
needs: quality
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Restore Next.js build cache
uses: actions/cache@v4
with:
path: |
.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
NEXT_TELEMETRY_DISABLED: 1The quality job and build job are separate deliberately. needs: quality means the build won't even start if lint or tests fail — no wasted build minutes on broken code. If quality passes in 90 seconds and you catch a TypeScript error, you've saved yourself a 3-minute build that was dead anyway.
The .next/cache caching step is critical for speed. On a cold run, a medium Next.js app builds in 3–4 minutes. With a warm .next/cache — same lock file, same source files — that drops to 40–60 seconds. The cache key includes both the lock file hash and a glob of all .ts/.tsx files, so it invalidates properly when your code actually changes.
Honestly, the NEXT_TELEMETRY_DISABLED: 1 env var is one of those tiny things people always forget. Without it, every build fires a network request to Vercel's telemetry endpoint, adding ~200ms of latency to your build step and cluttering your action logs with telemetry output. Just disable it.
Adding the Deploy Step for Vercel
The deploy step only runs on pushes to main — not on pull requests. That separation is important. PRs get the quality and build checks; only merged code actually ships. Add this job to the same ci.yml file:
deploy:
name: Deploy to Vercel
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: ${{ steps.deploy.outputs.url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
- name: Build for Vercel
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
- name: Deploy to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }})
echo "url=$URL" >> $GITHUB_OUTPUT
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}The environment block on the deploy job does two things: it ties the deployment to a GitHub Environment (so you can add required reviewers or environment-specific secrets later), and it surfaces the live URL in the Actions summary panel. That URL output means your team can see exactly where the deploy landed without leaving GitHub.
One more thing — the vercel build --prod step runs Next.js's build inside Vercel's own build environment, pulling in any environment variables you've configured in the Vercel dashboard. This is much cleaner than trying to replicate those vars in GitHub Secrets. You maintain a single source of truth for env config.
Caching Strategy That Actually Works
Most tutorials show you a basic node_modules cache and call it done. That works, but you're leaving speed on the table. Here's the layered caching approach that shaves the most time off repeated runs.
First, the pnpm setup step's cache: 'pnpm' option (via actions/setup-node@v4) caches the pnpm store — the global content-addressable package cache on disk. That's separate from node_modules. On a cache hit, pnpm install --frozen-lockfile with a warm store takes about 8 seconds instead of 60.
Second, the .next/cache cache shown in the build job covers Next.js's incremental compilation artifacts — the SWC-compiled chunks, the webpack module graph, and the page-level caches. This is where the real time savings live on repeated builds. If your source files haven't changed much between runs, Next.js reuses compiled output at the module level.
# Granular cache key pattern — add to any job that needs node_modules
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.NODE_VERSION }}-That said, don't cache everything blindly. node_modules caching can cause subtle bugs when a package updates but the lock file hash doesn't change in the way you'd expect. Always use --frozen-lockfile (pnpm) or --ci (npm) to guarantee the installed tree matches your lock file exactly, cache or no cache.
Pull Request Previews and Status Checks
The pipeline above gives you passing/failing checks on every PR. But you can push further — add a Vercel preview deployment comment so reviewers can click a URL and test the actual UI without pulling the branch locally. That's especially useful for visual components.
preview:
name: Deploy PR Preview
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
- name: Build preview
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
- name: Deploy preview
id: preview-deploy
run: |
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
echo "url=$URL" >> $GITHUB_OUTPUT
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
- name: Comment preview URL on PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview deployed: ${{ steps.preview-deploy.outputs.url }}`
})Look, if you're building UI-heavy apps — pages with glassmorphism components, complex layout stacks, dark/light mode switching — that preview URL in the PR comment becomes a huge productivity multiplier. Designers can review without touching a terminal. Product managers can sanity-check copy. You stop the 'can you send me a screenshot' Slack messages.
One consideration: every PR preview deployment counts against your Vercel bandwidth and build minutes. For high-traffic teams on Vercel's free Hobby plan (which caps at 100 deployments per day), you might want to gate the preview job with a label check — only deploy previews when a deploy-preview label is applied. That's a one-liner: if: contains(github.event.pull_request.labels.*.name, 'deploy-preview').
Common Gotchas and How to Fix Them
The most common failure you'll hit is the build step erroring on missing environment variables. Next.js 14+ surfaces this clearly — it'll tell you which NEXT_PUBLIC_ var is undefined — but the fix isn't obvious. For CI builds, you have three options: add the vars as GitHub Secrets and pass them via env: in the build step, use vercel pull to fetch them from Vercel's project config, or use the @t3-oss/env-nextjs package to make missing vars a hard compile error locally before they ever reach CI.
Another gotcha: pnpm install without --frozen-lockfile will silently update your lock file in CI if there's a mismatch, and then you'll spend 40 minutes wondering why a package behaves differently in production. Always add --frozen-lockfile. Always.
If your tests use jsdom — which Jest's default testEnvironment switched away from in Jest 27 — make sure your jest.config.ts explicitly sets testEnvironment: 'jsdom' and you have jest-environment-jsdom installed. Tests pass locally with Node 18 but blow up in the ubuntu-latest runner on Node 20 because of subtle DOM API differences. Pinning both NODE_VERSION and jest-environment-jsdom versions in your config eliminates that class of flaky CI failures entirely.
Worth noting: the ubuntu-latest runner GitHub provides as of mid-2026 runs Ubuntu 24.04. If your project has any native Node addons or platform-specific dependencies — looking at you, sharp for image optimization — verify they build on 24.04, not just your local macOS or Windows machine. The sharp package specifically requires libvips, which ships in ubuntu-latest but at version 8.14.x. Most sharp releases handle this fine, but older pinned versions can error.
Finally, set up branch protection rules. Go to Settings → Branches → Add rule, target main, enable 'Require status checks to pass before merging', and select your quality and build jobs. Without that, the pipeline is advisory, not enforced. The whole point is that nobody — including you — ships broken code on accident.
FAQ
Add if: github.ref == 'refs/heads/main' && github.event_name == 'push' to your deploy job. This gates deployment to direct pushes (and merged PRs) only — draft PRs and branch pushes won't trigger it.
Missing environment variables are the most common cause. Use vercel pull in CI to sync your Vercel project's env config, or explicitly pass each NEXT_PUBLIC_ variable via the env: block in your build step.
Use pnpm/action-setup@v4 combined with actions/setup-node@v4 and cache: 'pnpm'. This caches the pnpm content-addressable store — faster and more reliable than caching node_modules directly.
Yes. Swap the deploy job for your host's CLI — Netlify has netlify-cli, Fly.io has flyctl, Railway has its own GitHub Action. The quality and build jobs are host-agnostic and stay identical.