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

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

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:

  1. Lints your code with ESLint on every push
  2. Type-checks with TypeScript compiler
  3. Runs unit tests with Vitest
  4. Runs E2E tests with Playwright
  5. Deploys to Vercel only when all checks pass
  6. Caches dependencies for fast builds
  7. 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 --app to 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 chromium

Create 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:

StageTriggerWhat it doesBlocks deploy?
LintEvery push & PRESLint + TypeScript checkYes
Unit TestsAfter lint passesVitest + coverage reportYes
E2E TestsAfter lint passes (parallel with unit)Playwright browser testsYes
DeployAfter all tests pass, main onlyVercel 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:

  1. Go to your GitHub repository → SettingsSecrets and variablesActions
  2. Add these secrets:
SecretHow to get it
VERCEL_TOKENVercel Dashboard → Create Token
VERCEL_ORG_IDRun vercel link locally → check .vercel/project.json
VERCEL_PROJECT_IDSame file as above
# Quick way to get Vercel IDs
vercel link
cat .vercel/project.json

You'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:

  1. Go to SettingsBranchesAdd rule
  2. Branch name pattern: main
  3. Enable:
    • ✅ Require a pull request before merging
    • ✅ Require status checks to pass before merging
    • ✅ Require branches to be up to date before merging
  4. 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:

[![CI/CD Pipeline](https://github.com/YOUR_ORG/YOUR_REPO/actions/workflows/ci.yml/badge.svg)](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/android

2. 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:

ComponentToolPurpose
LintingESLint + TypeScriptCatch errors before runtime
Unit TestsVitest + Testing LibraryTest components in isolation
E2E TestsPlaywrightTest real user flows
CI/CDGitHub ActionsAutomate everything
DeployVercel CLIZero-downtime deploys
CachingGitHub Actions Cache50%+ faster pipelines
Branch ProtectionGitHub SettingsEnforce 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.


Want to read more tutorials? Check out our latest tutorial on Mastering Framer Motion: A Comprehensive Guide to Stunning Animations.

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