DevOps & Deployment
GitHub Actions for Next.js: My CI/CD Pipeline Setup
Last updated: April 14, 2026
TL;DR
Every Next.js project I build runs through a GitHub Actions pipeline before it touches Vercel. The pipeline runs lint, type-check, test, and build in sequence. If any step fails, the pull request is blocked. This catches broken imports, type errors, and failing tests before they reach preview deploys. I've been running this exact setup across EuroParts Lanka, uvin.lk, and iamuvin.com for over a year. It adds about 3 minutes to every push, and it's saved me from deploying broken code more times than I can count. This article is the complete breakdown — real YAML files, caching strategies, branch protection rules, and the full workflow I copy into every new project.
Why CI/CD for Next.js
Here's what happens without a CI/CD pipeline: you push code to GitHub, Vercel picks it up, and the build either passes or fails. If it fails, you find out on Vercel's dashboard five minutes later. If it passes but you introduced a type error that doesn't break the build, you find out when a user reports a bug.
I've shipped broken code this way. Once, I pushed a change to EuroParts Lanka that passed the Vercel build but had a runtime type mismatch in a Server Action. The form silently failed for users. No error in the console, no build failure — just a broken product that I didn't catch for 12 hours.
That was the last time I shipped without a pipeline.
A CI/CD pipeline for Next.js solves three problems:
It catches errors before Vercel. Type errors, lint violations, and failing tests get caught on push, not after deploy. The pull request goes red, and I fix it before it reaches any environment.
It enforces code quality automatically. I don't have to remember to run npm run lint before pushing. The pipeline does it. Every time. For every contributor. The rules are the same whether I'm writing code at 2 AM or a collaborator is pushing their first PR.
It creates a deployable confidence level. When a pull request has a green check from my pipeline, I know the code lints, type-checks, passes all tests, and builds successfully. I can merge with confidence. That green check is the minimum bar for production.
If you're building production web applications and your workflow is "push and pray," this guide will change how you ship.
My Pipeline Structure
My pipeline runs five stages in a strict sequence. If any stage fails, everything after it stops:
Push / PR
|
v
Lint (ESLint)
|
v
Type Check (tsc --noEmit)
|
v
Test (Vitest / Playwright)
|
v
Build (next build)
|
v
Deploy Check (Vercel preview)The ordering is intentional. Lint is the fastest check — it runs in under 30 seconds and catches the most common issues (unused imports, missing dependencies in hooks, formatting violations). Type-check runs next because type errors are the second most common failure. Tests come after because they take the longest and there's no point running them if the code doesn't even type-check. Build runs last as the final verification.
This sequence means that if I have a type error, I get feedback in under a minute instead of waiting for the full build to fail three minutes later. Fast feedback loops are the entire point.
I run this pipeline on two triggers:
- Push to any branch — catches issues immediately after push.
- Pull request to `main` — gates the merge. No green check, no merge.
Lint and Type Check
The first two stages are the fastest and catch the most issues. Here's the lint job:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lintMy ESLint config is strict. I use next/core-web-vitals as the base, add @typescript-eslint/strict, and configure rules that catch real bugs:
// .eslintrc.js (relevant rules)
module.exports = {
extends: [
"next/core-web-vitals",
"plugin:@typescript-eslint/strict",
],
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
}],
"react-hooks/exhaustive-deps": "error",
"import/no-duplicates": "error",
},
};The no-explicit-any rule alone catches a category of bugs that TypeScript can't warn you about if you've silenced it with any. I've seen projects where any crept into API response types and masked null pointer errors for months.
The type-check job runs TypeScript's compiler in check mode:
type-check:
name: Type Check
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmitNotice the needs: lint — this job only runs if lint passes. No point type-checking code that has lint errors.
The --noEmit flag tells TypeScript to check types without producing output files. It's faster than a full build and catches every type error in the project, including files that Next.js might not touch during a build because they're only imported conditionally.
Running Tests
I use Vitest for unit and integration tests, and Playwright for end-to-end tests. The test job runs both:
test:
name: Test
runs-on: ubuntu-latest
needs: type-check
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npx vitest run --coverage
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
env:
NEXT_PUBLIC_SITE_URL: http://localhost:3000A few things worth noting here:
I only install Chromium for Playwright. The full Playwright install with all browsers takes over two minutes. Chromium covers 90% of real-world browser behavior, and I'm testing application logic, not browser compatibility. If I need cross-browser testing, I run it separately on a nightly schedule.
Coverage runs in CI, not locally. I don't want developers slowed down by coverage reports on every test run. In CI, it runs once per push and I can track coverage trends over time.
E2E tests need environment variables. The NEXT_PUBLIC_SITE_URL is a simple example, but in practice, E2E tests need database URLs, API keys, and auth secrets. I'll cover how I handle those in the environment variables section.
My test strategy follows a simple rule: unit tests for utilities and business logic, integration tests for API routes and Server Actions, E2E tests for critical user flows (checkout, authentication, form submissions). I don't aim for 100% coverage — I aim for 100% coverage of things that would wake me up at 3 AM if they broke.
Build Verification
The build job is the final gate. It runs next build to verify that the entire application compiles:
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }}This catches things the type-checker misses — dynamic import issues, missing environment variables that are referenced at build time, image optimization errors, and Webpack/Turbopack bundling failures.
I've had builds fail in CI that passed locally because my local machine had environment variables set that CI didn't. This is a feature, not a bug. If the build requires an env var, it should be explicitly defined in the workflow, not assumed to exist.
The build step also generates the .next directory, which tells me the exact bundle size. I don't deploy this artifact from CI — Vercel runs its own build — but it validates that the build succeeds with the same Node version and dependencies that production will use.
Preview Deploy Checks
Vercel automatically creates preview deployments for every pull request. My pipeline adds a check on top of that by verifying the preview URL is healthy:
preview-check:
name: Preview Health Check
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'pull_request'
steps:
- name: Wait for Vercel preview
uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
id: vercel
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 300
check_interval: 10
- name: Check preview health
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ steps.vercel.outputs.url }}")
if [ "$STATUS" -ne 200 ]; then
echo "Preview deploy returned status $STATUS"
exit 1
fi
echo "Preview deploy is healthy (200 OK)"This step waits for Vercel to finish its own build and deploy, then hits the preview URL to confirm it returns a 200. It's a simple smoke test, but it catches deployment configuration issues that a local build wouldn't — things like missing serverless function configurations, broken middleware, or environment variables that Vercel doesn't have.
The if: github.event_name == 'pull_request' conditional ensures this only runs on PRs. Direct pushes to branches don't get preview checks because they don't have Vercel preview URLs.
Environment Variables in CI
Environment variables in CI are the number one source of "it works locally but fails in the pipeline" issues. Here's how I handle them:
Public variables go in the workflow file as env values or GitHub repository variables:
env:
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }}
NEXT_PUBLIC_GA_ID: ${{ vars.NEXT_PUBLIC_GA_ID }}Secret variables go in GitHub Secrets and are referenced with ${{ secrets.* }}:
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}Test-specific variables get defined inline in the test step with mock values:
- name: Run E2E tests
run: npx playwright test
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
NEXT_PUBLIC_SITE_URL: http://localhost:3000
STRIPE_SECRET_KEY: sk_test_fake_key_for_ciThe rule is simple: real secrets live in GitHub Secrets. Public configuration lives in GitHub Variables. Test values are hardcoded in the workflow because they're fake anyway.
I never use .env files in CI. They're a local development convenience. In CI, every variable is explicit and visible in the workflow file. If a variable is missing, the failure is obvious.
Caching for Speed
Without caching, my pipeline takes about 7 minutes. With caching, it takes about 3 minutes. The biggest win is caching node_modules:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"The cache: "npm" option in actions/setup-node caches the npm global store based on package-lock.json. When dependencies don't change between pushes (which is most of the time), npm ci runs in seconds instead of minutes.
For Playwright, I cache the browser binaries separately:
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromiumThis skips the Playwright browser download entirely when the cache hits. Since Playwright browsers only change when you update the playwright package version, the cache hits almost every run.
For the Next.js build cache:
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
nextjs-${{ runner.os }}-The restore-keys fallback means even a partial cache hit helps. If I changed one component, Next.js can reuse the cached build output for everything else. This alone cuts build time by 40-50% on large projects.
Branch Protection Rules
The pipeline is useless without branch protection. Here's what I configure on every repository:
- Require status checks to pass — The
lint,type-check,test, andbuildjobs must all pass before merging. - Require branches to be up to date — The PR branch must be rebased on the latest
main. No merging stale branches. - Require pull request reviews — At least one approval before merge. On solo projects, I skip this one.
- No force pushes to main — Ever. History is sacred.
- No deletions of main — Self-explanatory.
To set this up, go to your repo's Settings > Branches > Add branch protection rule. Set the branch name pattern to main, then check the boxes.
The critical setting is "Require status checks to pass before merging" with the specific job names listed. If you don't specify which checks are required, GitHub won't enforce them.
Required status checks:
- Lint
- Type Check
- Test
- BuildWith this configuration, it is physically impossible to merge code into main that doesn't pass every stage of the pipeline. That's the whole point.
My Complete Workflow File
Here's the full .github/workflows/ci.yml I copy into every new Next.js project. It includes everything covered above — lint, type-check, test, build, preview health check, and all caching optimizations:
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: 20
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }}
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
type-check:
name: Type Check
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
test:
name: Test
runs-on: ubuntu-latest
needs: type-check
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npx vitest run --coverage
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXT_PUBLIC_SITE_URL: http://localhost:3000
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
nextjs-${{ runner.os }}-
- name: Build
run: npm run build
env:
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }}
preview-check:
name: Preview Health Check
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'pull_request'
steps:
- name: Wait for Vercel preview
uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
id: vercel
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 300
check_interval: 10
- name: Check preview health
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ steps.vercel.outputs.url }}")
if [ "$STATUS" -ne 200 ]; then
echo "Preview deploy returned status $STATUS"
exit 1
fi
echo "Preview deploy is healthy (200 OK)"A few things worth calling out about this workflow:
The `concurrency` block cancels in-progress runs when you push again to the same branch. Without this, pushing three quick fixes spawns three full pipeline runs. With it, only the latest push runs. This saves CI minutes and avoids confusion about which run is the "real" one.
The `env` block at the top defines shared variables. I set NODE_VERSION here so every job uses the same version. No more "it passed in lint but failed in build because they used different Node versions."
The sequential `needs` chain means a type error kills the pipeline in under 90 seconds instead of waiting for the full 3-minute build. Fast failure is the priority.
Key Takeaways
After running this pipeline across multiple production projects for over a year, here's what I've learned:
Start with lint and type-check. These two stages catch 80% of issues and run in under a minute combined. If you do nothing else, add these two.
Cache aggressively. npm dependencies, Playwright browsers, and Next.js build cache. The difference between a 7-minute pipeline and a 3-minute pipeline is entirely caching.
Make the pipeline sequential, not parallel. It's tempting to run lint, type-check, and test in parallel to save time. Don't. Sequential stages mean faster feedback for common failures. A lint error in 30 seconds is better than a lint error and a test timeout both reported after 4 minutes.
Use `concurrency` with `cancel-in-progress`. This single configuration block saves hours of CI minutes per month on active repos. There's no reason to finish a pipeline run for a commit that's already been superseded.
Branch protection makes the pipeline mandatory. Without branch protection, the pipeline is just a suggestion. With it, the pipeline is a gate. Configure required status checks for every job.
Environment variables should be explicit. Never rely on env vars "just being there." Define every variable your pipeline needs, either in the workflow file, GitHub Variables, or GitHub Secrets. If it's missing, the pipeline should fail loudly.
The pipeline is not testing infrastructure. It tests your code. Keep it focused on lint, types, tests, and builds. Don't add deployment steps, database migrations, or infrastructure provisioning to the same workflow. Those are separate workflows with different triggers.
The three minutes this pipeline adds to every push is the cheapest insurance you'll ever buy. It catches the errors you'd spend hours debugging in production. Set it up once, copy it to every project, and never push broken code again.
About the author: I'm Uvin Vindula↗ (IAMUVIN), a Web3 and AI engineer based between Sri Lanka and the UK. I build production web applications, smart contracts, and AI-powered products. Every project I ship runs through this exact pipeline. If you need a production-grade application with proper CI/CD from day one, check out my services.
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.