Building a Complete CI/CD Pipeline with GitHub Actions for Next.js

Every time you push code and pray it doesn't break production, you're gambling. CI/CD eliminates that gamble.
In this tutorial, you'll build a complete GitHub Actions pipeline for a Next.js application. By the end, every push will automatically lint your code, run unit tests, execute E2E tests with Playwright, and deploy to Vercel — only if everything passes.
No manual QA. No "it works on my machine." Just confidence on every commit.
What You'll Build
A multi-stage CI/CD pipeline that:
- Lints your code with ESLint on every push
- Type-checks with TypeScript compiler
- Runs unit tests with Vitest
- Runs E2E tests with Playwright
- Deploys to Vercel only when all checks pass
- Caches dependencies for fast builds
- Posts status updates as PR comments
Prerequisites
Before starting, make sure you have:
- A Next.js 14+ project (App Router recommended)
- A GitHub repository for your project
- A Vercel account with the project connected
- Node.js 20+ installed locally
- Basic familiarity with YAML syntax
💡 Don't have a Next.js project yet? Run
npx create-next-app@latest my-app --typescript --tailwind --eslint --appto create one.
Step 1: Set Up the Project Structure
First, let's make sure your project has the right testing tools installed.
# Install testing dependencies
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom
# Install Playwright for E2E tests
npm install -D @playwright/test
npx playwright install --with-deps chromiumCreate the Vitest configuration file:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['**/*.test.{ts,tsx}'],
coverage: {
reporter: ['text', 'json-summary', 'html'],
exclude: ['node_modules/', '.next/', 'tests/e2e/'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
})Create the test setup file:
// tests/setup.ts
import '@testing-library/jest-dom'And the Playwright configuration:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'github' : 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})Step 2: Write Sample Tests
Before wiring up CI/CD, let's create tests that the pipeline will run.
Unit Test
// tests/unit/home.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Home from '@/app/page'
describe('Home Page', () => {
it('renders the main heading', () => {
render(<Home />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toBeInTheDocument()
})
it('contains a get started link', () => {
render(<Home />)
const link = screen.getByRole('link', { name: /get started/i })
expect(link).toBeInTheDocument()
})
})E2E Test
// tests/e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Navigation', () => {
test('homepage loads and displays correctly', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/Next.js/)
await expect(page.locator('h1')).toBeVisible()
})
test('page has no accessibility violations in heading hierarchy', async ({ page }) => {
await page.goto('/')
const headings = await page.locator('h1, h2, h3').all()
expect(headings.length).toBeGreaterThan(0)
})
})Update package.json Scripts
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}Step 3: Create the GitHub Actions Workflow
This is where the magic happens. Create the workflow file:
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '20'
PNPM_VERSION: '9'
jobs:
# ─── Stage 1: Code Quality ───────────────────────────
lint:
name: 🧹 Lint & Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
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: Run ESLint
run: pnpm lint
- name: Run TypeScript type check
run: pnpm type-check
# ─── Stage 2: Unit Tests ─────────────────────────────
unit-tests:
name: 🧪 Unit Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout code
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: Run unit tests with coverage
run: pnpm test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
# ─── Stage 3: E2E Tests ──────────────────────────────
e2e-tests:
name: 🎭 E2E Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout code
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: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Build application
run: pnpm build
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload test screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-screenshots
path: test-results/
retention-days: 7
# ─── Stage 4: Deploy ─────────────────────────────────
deploy:
name: 🚀 Deploy to Vercel
runs-on: ubuntu-latest
needs: [unit-tests, e2e-tests]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: production
url: ${{ steps.deploy.outputs.url }}
steps:
- name: Checkout code
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: Install Vercel CLI
run: pnpm add -g vercel
- name: Pull Vercel environment
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build with Vercel
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy to Vercel
id: deploy
run: |
url=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }})
echo "url=$url" >> $GITHUB_OUTPUT
- name: Post deployment URL
if: success()
run: echo "✅ Deployed to ${{ steps.deploy.outputs.url }}"Let's break down what each stage does:
| Stage | Trigger | What it does | Blocks deploy? |
|---|---|---|---|
| Lint | Every push & PR | ESLint + TypeScript check | Yes |
| Unit Tests | After lint passes | Vitest + coverage report | Yes |
| E2E Tests | After lint passes (parallel with unit) | Playwright browser tests | Yes |
| Deploy | After all tests pass, main only | Vercel production deploy | — |
🚀 Need help setting up CI/CD for your project? Noqta builds production-grade web applications with automated testing and deployment pipelines built in from day one.
Step 4: Configure GitHub Secrets
Your workflow needs access to Vercel. Here's how to set up the secrets:
- Go to your GitHub repository → Settings → Secrets and variables → Actions
- Add these secrets:
| Secret | How to get it |
|---|---|
VERCEL_TOKEN | Vercel Dashboard → Create Token |
VERCEL_ORG_ID | Run vercel link locally → check .vercel/project.json |
VERCEL_PROJECT_ID | Same file as above |
# Quick way to get Vercel IDs
vercel link
cat .vercel/project.jsonYou'll see something like:
{
"orgId": "team_xxxxxxxxxxxx",
"projectId": "prj_xxxxxxxxxxxx"
}Copy those values into your GitHub secrets.
Step 5: Add PR Status Comments
Make your pipeline developer-friendly by posting test results directly on PRs. Add this job to your workflow:
# ─── PR Comment with Results ─────────────────────────
pr-comment:
name: 📝 PR Status
runs-on: ubuntu-latest
needs: [unit-tests, e2e-tests]
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
steps:
- name: Download coverage report
uses: actions/download-artifact@v4
with:
name: coverage-report
path: coverage/
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let coverageSummary = 'Coverage report not available';
try {
const coverage = JSON.parse(
fs.readFileSync('coverage/coverage-summary.json', 'utf8')
);
const total = coverage.total;
coverageSummary = `
| Metric | Coverage |
|--------|----------|
| Statements | ${total.statements.pct}% |
| Branches | ${total.branches.pct}% |
| Functions | ${total.functions.pct}% |
| Lines | ${total.lines.pct}% |`;
} catch (e) {
console.log('Could not read coverage:', e.message);
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## ✅ CI Pipeline Passed
### Test Coverage
${coverageSummary}
### Pipeline Summary
- 🧹 Lint & Type Check: ✅
- 🧪 Unit Tests: ✅
- 🎭 E2E Tests: ✅
> _Automated by GitHub Actions_`
});Step 6: Add Branch Protection Rules
The pipeline is useless if developers can bypass it. Lock it down:
- Go to Settings → Branches → Add rule
- Branch name pattern:
main - Enable:
- ✅ Require a pull request before merging
- ✅ Require status checks to pass before merging
- ✅ Require branches to be up to date before merging
- Add required status checks:
🧹 Lint & Type Check🧪 Unit Tests🎭 E2E Tests
Now nobody can push directly to main without passing all checks.
Step 7: Optimize with Caching
The workflow above already uses pnpm caching via actions/setup-node. But for Playwright browsers and Next.js builds, add explicit caching:
# Add to the e2e-tests job, before "Install Playwright browsers"
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
# Add to any job that runs `next build`
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-With caching, your pipeline drops from ~4 minutes to under 2 minutes on subsequent runs.
Step 8: Add Environment-Specific Deployments
For a real project, you want preview deployments on PRs and production deploys on main. Add this job:
# ─── Preview Deploy (PRs) ───────────────────────────
deploy-preview:
name: 🔍 Preview Deploy
runs-on: ubuntu-latest
needs: [unit-tests, e2e-tests]
if: github.event_name == 'pull_request'
environment:
name: preview
url: ${{ steps.deploy.outputs.url }}
steps:
- name: Checkout code
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: Install Vercel CLI
run: pnpm add -g vercel
- name: Deploy Preview
id: deploy
run: |
vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
vercel build --token=${{ secrets.VERCEL_TOKEN }}
url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
echo "url=$url" >> $GITHUB_OUTPUT
- name: Comment preview URL on PR
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `🔍 **Preview deployed:** ${{ steps.deploy.outputs.url }}`
});Step 9: Monitor Pipeline Health
Add a workflow status badge to your README:
[](https://github.com/YOUR_ORG/YOUR_REPO/actions/workflows/ci.yml)And set up failure notifications. Add to your workflow:
# ─── Notify on Failure ───────────────────────────────
notify:
name: 🔔 Notify
runs-on: ubuntu-latest
needs: [lint, unit-tests, e2e-tests, deploy]
if: failure()
steps:
- name: Send Slack notification
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"text": "❌ CI/CD Pipeline Failed",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "❌ *Pipeline failed* on `${{ github.ref_name }}`\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"
}
}
]
}💡 Want a rock-solid testing and deployment pipeline without building it yourself? Noqta's QA team sets up production-grade CI/CD with monitoring, so your team ships with confidence.
Common Pitfalls and How to Fix Them
1. "Out of disk space" on GitHub runners
Next.js builds can be large. Free up space:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android2. E2E tests timing out
Increase the Playwright timeout and add retries:
// playwright.config.ts
export default defineConfig({
timeout: 60_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
})3. Flaky tests blocking deploys
Use Playwright's toPass for eventually-consistent checks:
await expect(async () => {
const response = await page.request.get('/api/health')
expect(response.status()).toBe(200)
}).toPass({ timeout: 30_000 })4. Slow dependency installs
Always use --frozen-lockfile and pnpm caching:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'The Complete Pipeline Diagram
Push / PR
│
▼
┌─────────────────┐
│ 🧹 Lint & │
│ Type Check │
└────────┬────────┘
│
┌────┴────┐
▼ ▼
┌────────┐ ┌────────┐
│🧪 Unit │ │🎭 E2E │
│ Tests │ │ Tests │
└───┬────┘ └───┬────┘
│ │
└────┬─────┘
│
▼
┌─────────┐ ┌──────────┐
│ PR? │──yes──▶│🔍Preview │
└────┬────┘ └──────────┘
│no (main)
▼
┌──────────┐
│🚀Deploy │
│Production│
└──────────┘
Summary
Here's what you built:
| Component | Tool | Purpose |
|---|---|---|
| Linting | ESLint + TypeScript | Catch errors before runtime |
| Unit Tests | Vitest + Testing Library | Test components in isolation |
| E2E Tests | Playwright | Test real user flows |
| CI/CD | GitHub Actions | Automate everything |
| Deploy | Vercel CLI | Zero-downtime deploys |
| Caching | GitHub Actions Cache | 50%+ faster pipelines |
| Branch Protection | GitHub Settings | Enforce quality gates |
The entire pipeline runs in under 3 minutes with caching. Every PR gets a preview deployment. Every merge to main triggers production deployment — but only after all tests pass.
Stop deploying manually. Stop praying your code works. Automate it.
Next Steps
- Add visual regression testing with Playwright screenshots
- Set up database migrations in the pipeline for full-stack apps
- Add Lighthouse CI for performance monitoring on every PR
- Implement feature flags for gradual rollouts after deployment
The best CI/CD pipeline is the one you set up once and trust forever. Now you have it.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Laravel + Vue CI/CD in 2026: Docker, GitHub Actions, and Coolify from Local to Production
Build and deploy a production-ready Laravel + Vue app with Docker, GitHub Actions, and Coolify, including testing, security, rollback, and observability best practices.

Building a Conversational AI App with Next.js
Learn how to build a web application that enables real-time voice conversations with AI agents using Next.js and ElevenLabs.

Building a Multi-Tenant App with Next.js
Learn how to build a full-stack multi-tenant application using Next.js, Vercel, and other modern technologies.