Type something to search...
Setting Up CI/CD for Next.js with GitHub Actions

Setting Up CI/CD for Next.js with GitHub Actions

A Next.js project without CI relies on everyone remembering to run the linter, the type checker, and the tests before they push. Somebody eventually forgets, and the main branch ends up with a build that fails in production. A continuous integration pipeline takes memory out of the equation: every pull request gets the same checks, and nothing merges until they pass. Continuous deployment then takes the merged code and ships it the same way every time.

GitHub Actions is the natural choice if your code already lives on GitHub. Workflows are YAML files in your repo, runners are free for public repositories and generously metered for private ones, and the marketplace has actions for nearly everything.

This guide builds a complete pipeline for a Next.js 16 app step by step: a CI workflow that lints, type-checks, tests, and builds with caching; an end-to-end test job with Playwright; and two deployment options, a Docker image pushed to a registry and a Vercel deployment. I'll also cover secrets, environments, and a few settings that make the whole thing faster and safer.

Prepare Your package.json Scripts

CI is easiest when every check is a script you can also run locally. Next.js 16 removed next lint and no longer lints during next build, so linting is its own step now:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint .",
    "typecheck": "next typegen && tsc --noEmit",
    "test": "vitest run",
    "test:e2e": "playwright test"
  }
}

A few notes:

  • eslint . uses your flat config (eslint.config.mjs) with eslint-config-next. If you use Biome instead, swap in biome check ..
  • next typegen generates the route types and next-env.d.ts without running a full build. Without it, tsc may fail on missing route types if you use typed routes or the generated PageProps helpers.
  • next build also type-checks by default, so typecheck might look redundant. Running it separately gives faster feedback (it finishes before the build would) and clearer error output.

Use whichever test runner you already have. The post on testing Next.js with Vitest covers the setup.

The CI Workflow

Create .github/workflows/ci.yml:

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  checks:
    name: Lint, typecheck, test
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm

      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test

  build:
    name: Build
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm

      - run: npm ci

      - name: Restore Next.js build cache
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**', 'app/**', 'public/**') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-

      - run: npm run build
        env:
          NEXT_TELEMETRY_DISABLED: 1

Let's go through the important parts.

Triggers

pull_request runs the workflow for every PR, against the merge of your branch into the target. push to main runs it again after merging, which catches problems from two PRs that each passed on their own but conflict when combined.

Concurrency

The concurrency block cancels an in-progress run when you push a new commit to the same branch. Without it, three quick pushes mean three full pipelines, and you only care about the last one. This alone can cut your Actions minutes noticeably.

Permissions

permissions: contents: read gives the workflow's GITHUB_TOKEN only read access. Jobs that need more (publishing packages, deploying) request it explicitly. Starting from least privilege limits the damage if a dependency in your pipeline is ever compromised.

Node.js Version and Dependency Caching

node-version-file: .nvmrc reads the version from the same file your team uses locally, so CI and laptops don't drift. Next.js 16 requires Node.js 20.9 or newer; a .nvmrc containing 22 or 24 is a good choice.

cache: npm in actions/setup-node caches the npm download cache, keyed by your lockfile. npm ci then installs from that cache instead of the network. It uses package-lock.json exactly and fails if it's out of sync with package.json, which is what you want in CI. For pnpm or Yarn, set cache: pnpm or cache: yarn (and for pnpm, add pnpm/action-setup before setup-node).

Parallel Jobs

checks and build run in parallel on separate runners. A lint error shows up in a minute or two instead of waiting behind a full build. The trade-off is installing dependencies twice, which the npm cache makes cheap.

The Next.js Build Cache

Next.js stores compilation results in .next/cache and reuses them between builds. On a fresh CI runner that folder is empty, so every build starts from scratch and Next.js prints a "No Cache Detected" warning. actions/cache saves the folder after a run and restores it on the next one.

The cache key has three parts: the OS, the lockfile hash, and a hash of your source files. An exact match restores a cache from an identical build. When source files change, restore-keys falls back to the most recent cache with the same dependencies, so Next.js still reuses most of its work. Adjust the hashFiles globs to match where your source lives.

End-to-End Tests with Playwright

Unit tests don't catch a broken production build or a page that crashes on hydration. An end-to-end job that builds the app, starts it, and drives a real browser does.

First, let Playwright start the production server for you:

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list",
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
  webServer: {
    command: "npm run build && npm run start",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 180_000,
  },
});

forbidOnly fails the run if someone commits a test.only. The github reporter annotates failures directly on the PR's diff. trace: "on-first-retry" records a full trace (DOM snapshots, network, console) only when a test fails and is retried, which keeps successful runs fast.

Then add a job to the CI workflow:

e2e:
  name: E2E (Playwright)
  runs-on: ubuntu-latest
  timeout-minutes: 25
  steps:
    - uses: actions/checkout@v4

    - uses: actions/setup-node@v4
      with:
        node-version-file: .nvmrc
        cache: npm

    - run: npm ci

    - name: Install Playwright browsers
      run: npx playwright install --with-deps chromium

    - name: Run Playwright tests
      run: npm run test:e2e
      env:
        DATABASE_URL: ${{ secrets.E2E_DATABASE_URL }}

    - name: Upload report
      if: ${{ !cancelled() }}
      uses: actions/upload-artifact@v4
      with:
        name: playwright-report
        path: playwright-report/
        retention-days: 14

--with-deps installs the system libraries the browser needs on the Ubuntu runner. Installing only chromium instead of all three browsers saves a minute or more per run. The report upload runs even when tests fail (!cancelled()), so you can download it from the run summary and open the traces locally. For the test-writing side, see end-to-end testing with Playwright.

Environment Variables and Secrets

Your build may need environment variables: a NEXT_PUBLIC_ analytics ID, a CMS token for prerendering, or a database URL for pages that read data at build time.

Add them in Settings → Secrets and variables → Actions. Use secrets for sensitive values (they're masked in logs) and variables for non-sensitive config. Reference them in a step's env:

- run: npm run build
  env:
    NEXT_PUBLIC_SITE_URL: ${{ vars.SITE_URL }}
    CMS_API_TOKEN: ${{ secrets.CMS_API_TOKEN }}

Two rules keep this safe:

  • Scope secrets to the step that needs them, not the whole workflow. A test step doesn't need your production deploy token.
  • Remember that NEXT_PUBLIC_ values end up in the client bundle. Putting a secret in one exposes it to every visitor, regardless of how carefully you stored it in GitHub.

Workflows triggered by pull requests from forks don't receive secrets at all. If your build requires them, either make the build tolerate missing values (skip CMS-dependent pages, use fixtures) or only run those steps for branches in your own repository.

Deployment Option 1: Docker Image to GitHub Container Registry

If you self-host, the natural artifact is a Docker image. This workflow builds the image on every push to main and publishes it to GitHub Container Registry (GHCR). It assumes a Dockerfile based on standalone output, like the one in self-hosting Next.js with Docker.

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

concurrency:
  group: release
  cancel-in-progress: false

permissions:
  contents: read
  packages: write

jobs:
  image:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=raw,value=latest

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            NEXT_PUBLIC_SITE_URL=${{ vars.SITE_URL }}
            DEPLOYMENT_VERSION=${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

What's going on:

  • packages: write lets the built-in GITHUB_TOKEN push to GHCR. No personal access token required.
  • docker/metadata-action produces tags: one with the short commit SHA (for precise rollbacks) and latest.
  • cache-from and cache-to with type=gha store Docker layer cache in the GitHub Actions cache. The dependency install layer is reused until your lockfile changes, which often cuts image builds from several minutes to under one.
  • build-args pass values needed at build time, such as NEXT_PUBLIC_ variables and a deployment ID. Your Dockerfile needs matching ARG lines. Never pass secrets this way; build args are visible in the image history.
  • cancel-in-progress: false on the release group means a newer push queues behind a running release instead of killing it halfway through.

Rolling Out the New Image

How you deploy the image depends on your infrastructure. For a single VPS running Docker Compose, a final job can SSH in and pull:

deploy:
  needs: image
  runs-on: ubuntu-latest
  environment: production
  steps:
    - name: Deploy over SSH
      env:
        SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        HOST: ${{ vars.DEPLOY_HOST }}
      run: |
        mkdir -p ~/.ssh
        echo "$SSH_KEY" > ~/.ssh/id_ed25519
        chmod 600 ~/.ssh/id_ed25519
        ssh-keyscan -H "$HOST" >> ~/.ssh/known_hosts
        ssh deploy@"$HOST" "cd /srv/app && docker compose pull web && docker compose up -d web"

For Kubernetes, ECS, Fly.io, or Cloud Run, replace that step with the platform's CLI or official action, pointing at the sha- tag the previous job pushed.

Protect Production with Environments

The environment: production line connects the job to a GitHub environment. In Settings → Environments, you can:

  • Require a reviewer to approve before the job runs.
  • Restrict which branches can deploy to it.
  • Store secrets that only jobs in that environment can read.

That last point is important: put DEPLOY_SSH_KEY in the production environment, not in repository secrets, and only the deploy job can access it.

Deployment Option 2: Vercel from GitHub Actions

Vercel's Git integration deploys automatically on push. If you want deployments to happen only after your own CI passes, disable the automatic Git deployments and drive Vercel from Actions with its CLI:

# .github/workflows/vercel.yml
name: Deploy to Vercel

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    env:
      VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
      VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm

      - run: npm install --global vercel@latest

      - run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}

      - run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}

      - run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}

vercel pull downloads the project settings and environment variables, vercel build builds locally on the runner, and vercel deploy --prebuilt uploads the result. Because the build happens in your workflow, you can place this job after your test jobs with needs: so a failing test blocks the deploy.

Make Checks Required

A pipeline that reports failures but lets people merge anyway is only half done. In Settings → Branches (or Rules → Rulesets), add a rule for main that:

  • Requires a pull request before merging.
  • Requires the status checks Lint, typecheck, test, Build, and E2E (Playwright) to pass.
  • Requires branches to be up to date before merging, if your team can tolerate the extra rebases.

Job name values become the check names, which is why it's worth giving them short, stable names.

Keeping Actions Up to Date

Actions are dependencies too. Let Dependabot open PRs when new versions come out:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    groups:
      next-and-react:
        patterns: ["next", "react", "react-dom", "eslint-config-next"]

Grouping next, react, and related packages keeps them upgrading together, since they're tightly coupled. Your CI then tests each update before you merge it. For extra supply-chain safety, you can pin third-party actions to a full commit SHA instead of a version tag.

Speed Tips

Once the pipeline works, a few changes keep it fast as the project grows:

  • Skip runs for docs-only changes with paths-ignore: ["**.md", "docs/**"] under the pull_request trigger. (If those checks are required, use a job-level condition instead, so skipped workflows don't block merges.)
  • Shard Playwright across several runners with a matrix and npx playwright test --shard=${{ matrix.shard }}/4 when the suite gets slow.
  • Watch the build cache size. .next/cache can grow large; GitHub evicts old caches automatically once a repository passes its storage limit, but tighter keys keep restores fast.
  • Use timeout-minutes on every job. A hung test can otherwise burn runner time until the default 6-hour limit.

Conclusion

A solid Next.js pipeline on GitHub Actions comes down to a few pieces: scripts you can run locally, a CI workflow that lints, type-checks, tests, and builds in parallel jobs with npm and .next/cache caching, an end-to-end job that exercises the production build, and a deploy workflow gated behind a protected environment. Whether you ship a Docker image to GHCR or deploy prebuilt output to Vercel, the principle is the same: the code that reaches production is exactly the code that passed every check. Make those checks required on main, let Dependabot keep everything current, and you'll rarely think about the pipeline again.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading