Skip to main content
Back to Blog
Next.jsCI/CDGitHub ActionsDevOpsVercelAWS

Setting Up a CI/CD Pipeline for Next.js on GitHub Actions

A complete, practical guide to automating linting, testing, building, and deploying a Next.js app to S3/CloudFront or Vercel using GitHub Actions.

August 17, 202614 min readNiraj Kumar

Introduction

Shipping a Next.js application manually — running npm run build, uploading files, clearing a CDN cache by hand — works fine when you're the only developer pushing code once a week. It falls apart the moment your team grows, your release cadence increases, or a teammate forgets to run tests before merging a "small fix" that breaks production.

This is exactly the problem CI/CD (Continuous Integration and Continuous Deployment) solves. Instead of trusting humans to remember every step, you encode those steps into a pipeline that runs automatically on every push or pull request. The pipeline lints your code, type-checks it, runs your test suite, builds the application, and — if everything passes — deploys it to production or a preview environment.

In this guide, we'll build a complete, production-grade CI/CD pipeline for a Next.js application using GitHub Actions. We'll cover:

  • The core concepts behind CI/CD pipelines
  • A full linting, testing, and build workflow
  • Two deployment strategies: Vercel (the "native" Next.js host) and AWS S3 + CloudFront (a common self-hosted static/SSR setup)
  • Caching, secrets management, and preview deployments
  • Common mistakes and how to avoid them

By the end, you'll have a pipeline you can drop into almost any Next.js repository with minor tweaks.


Why CI/CD Matters for Next.js Projects

Next.js sits in an interesting spot: it can be deployed as a fully static site, a server-rendered app, or a hybrid of both using the App Router's server components and edge functions. This flexibility is powerful, but it also means deployment isn't a one-size-fits-all process the way it might be for a plain static HTML site.

A solid CI/CD pipeline gives you:

  • Consistency — every build runs in a clean, identical environment, eliminating "works on my machine" bugs.
  • Fast feedback — contributors see lint/test/build failures within minutes of opening a pull request.
  • Safety nets — broken code never reaches production because the pipeline blocks merges on failing checks.
  • Repeatable deployments — the same automated steps run every time, whether it's your 1st or 1,000th deploy.
  • Audit trail — every deployment is tied to a commit, a workflow run, and a log you can inspect later.

For teams shipping frequently, this isn't a nice-to-have. It's the difference between deploying confidently multiple times a day and dreading every release.


Core Concepts: What a CI/CD Pipeline Actually Does

Before writing YAML, it helps to understand the mental model. A typical pipeline is a sequence of jobs, each made up of steps. Jobs can run in parallel or depend on each other.

For a Next.js app, a sensible pipeline looks like this:

  1. Checkout — pull the repository code into the runner.
  2. Install dependencies — using npm ci, pnpm install --frozen-lockfile, or yarn install --frozen-lockfile.
  3. Lint — run ESLint (and often Prettier's --check mode) to catch style and correctness issues.
  4. Type-check — run tsc --noEmit if you're using TypeScript.
  5. Test — run unit tests (Jest/Vitest) and optionally integration or E2E tests (Playwright/Cypress).
  6. Build — run next build to produce a production bundle.
  7. Deploy — push the built artifact to your hosting provider.

Each stage should fail fast. There's no point running a 10-minute E2E suite if the lint step already failed in 15 seconds.


Setting Up the Repository

Assume a standard Next.js 15+ App Router project with TypeScript, ESLint, and a test runner already configured. Your package.json scripts should look roughly like this:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint . --max-warnings=0",
    "type-check": "tsc --noEmit",
    "test": "vitest run",
    "test:e2e": "playwright test"
  }
}

Having these scripts standardized matters more than it sounds — your GitHub Actions workflow should never hardcode raw commands like npx eslint src/. Always call the npm run script so local development and CI stay in sync.

Create the workflows directory:

mkdir -p .github/workflows

The Core CI Workflow: Lint, Type-Check, Test, Build

Here's a complete ci.yml that runs on every push and pull request targeting main:

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

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

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

jobs:
  lint-and-typecheck:
    name: Lint & Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Run TypeScript check
        run: npm run type-check

  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: lint-and-typecheck
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests with coverage
        run: npm run test -- --coverage

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  build:
    name: Build Application
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: |
            ~/.npm
            ${{ github.workspace }}/.next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-

      - name: Build
        run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: nextjs-build
          path: |
            .next
            public
            package.json
            next.config.js

A few things worth calling out:

  • concurrency cancels older, in-progress runs when new commits land on the same branch/PR, saving runner minutes.
  • needs: chains jobs so build only runs after test passes, and test only runs after linting succeeds.
  • Caching .next/cache speeds up incremental builds significantly since Next.js reuses previously compiled chunks.
  • Environment variables prefixed with NEXT_PUBLIC_ are baked into the client bundle at build time — they must be present during the build step, not just at runtime.

Deployment Strategy 1: Vercel

Vercel is the company behind Next.js, and it offers the most seamless deployment experience — automatic preview URLs per pull request, edge network distribution, and zero-config support for server components, ISR, and middleware.

Option A: Native Git Integration (No Workflow Needed)

If you connect your GitHub repo directly in the Vercel dashboard, Vercel handles CI/CD itself — no GitHub Actions required for deployment. Every push gets a preview deployment, and merges to main deploy to production automatically.

Option B: Deploying via GitHub Actions (More Control)

Sometimes you want deployment gated behind your own custom checks (e.g., running a Lighthouse audit or a security scan before going live). In that case, deploy through the Vercel CLI inside your workflow:

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

on:
  push:
    branches: [main]

jobs:
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    environment: production
    steps:
      - 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 }}

      - name: Build project artifacts
        run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}

      - name: Deploy to Vercel
        run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}

You'll need three secrets stored in your repository (Settings → Secrets and variables → Actions):

  • VERCEL_TOKEN — a personal or team token from your Vercel account
  • VERCEL_ORG_ID and VERCEL_PROJECT_ID — found in your project's .vercel/project.json after running vercel link locally once

For pull requests, you can add a similar job without --prod to generate preview deployments and even post the preview URL as a PR comment using actions/github-script.


Deployment Strategy 2: AWS S3 + CloudFront

If you're self-hosting, or your organization standardizes on AWS, deploying a statically exported Next.js app to S3 (storage) fronted by CloudFront (CDN) is a common and cost-effective pattern. This works best for apps that don't rely on Next.js server-side features like API routes, middleware, or dynamic SSR — you'll be using output: "export".

Step 1: Configure Static Export

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export",
  images: {
    unoptimized: true, // required since S3/CloudFront can't run the Next.js Image Optimization API
  },
};

module.exports = nextConfig;

If your app genuinely needs SSR, API routes, or ISR, static export won't work — consider deploying to Vercel, AWS Amplify, or a container-based setup (ECS/Lambda via OpenNext) instead.

Step 2: The Deployment Workflow

# .github/workflows/deploy-s3-cloudfront.yml
name: Deploy to S3 & CloudFront

on:
  push:
    branches: [main]

permissions:
  id-token: write   # required for OIDC auth to AWS
  contents: read

jobs:
  deploy:
    name: Build and Deploy
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Build static export
        run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: us-east-1

      - name: Sync to S3
        run: |
          aws s3 sync ./out s3://${{ secrets.S3_BUCKET_NAME }} \
            --delete \
            --cache-control "public,max-age=31536000,immutable" \
            --exclude "*.html" \
            --exclude "*.json"

          aws s3 sync ./out s3://${{ secrets.S3_BUCKET_NAME }} \
            --delete \
            --cache-control "public,max-age=0,must-revalidate" \
            --exclude "*" \
            --include "*.html" \
            --include "*.json"

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/*"

Key details:

  • OIDC over long-lived keys — using role-to-assume with GitHub's OIDC provider means you never store static AWS access keys as secrets. This is significantly more secure and is the 2026 best practice for AWS + GitHub Actions integration.
  • Two-pass s3 sync — hashed static assets (JS/CSS chunks) get long-lived immutable cache headers, while HTML and JSON files get must-revalidate so users always get the latest page shell.
  • CloudFront invalidation — without this step, users may see stale HTML for hours after a deploy, since CloudFront caches at edge locations independently of S3.

Setting Up the OIDC Trust Relationship (One-Time AWS Setup)

In your AWS account, create an IAM role with a trust policy scoped to your GitHub repository:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

Attach a policy granting s3:PutObject, s3:DeleteObject, s3:ListBucket, and cloudfront:CreateInvalidation scoped to your specific bucket and distribution — never use AdministratorAccess for a deploy role.


Real-World Example: A Complete Pull Request Flow

Here's how these pieces fit together in practice for a team of five engineers:

  1. A developer opens a PR against main.
  2. The ci.yml workflow triggers: lint → type-check → test → build, all visible as required status checks.
  3. Vercel (via native Git integration) automatically generates a preview deployment with a unique URL, posted as a PR comment.
  4. Reviewers test the preview URL directly, catching visual or functional regressions before merge.
  5. Once approved and merged, main triggers the production deployment workflow — either deploy-vercel.yml or deploy-s3-cloudfront.yml.
  6. The team gets a Slack notification (via a slack-github-action step) confirming the deploy succeeded, along with the commit SHA and workflow run link.

This flow means no one manually runs next build on their laptop and uploads files by hand — ever.


Best Practices

  • Use npm ci, not npm install, in CI. It respects the lockfile exactly and fails if it's out of sync, preventing "it worked in CI but broke locally" drift.
  • Pin action versions (actions/checkout@v4, not @main) to avoid unexpected breakage when third-party actions publish updates.
  • Separate CI from CD. Keep lint/test/build in one workflow and deployment in another (or a separate job gated by needs: and branch conditions). This keeps logs cleaner and makes re-running deployments without re-running tests possible.
  • Use GitHub Environments for production deploys, which lets you require manual approval before a deploy proceeds — useful for regulated industries or high-stakes releases.
  • Cache aggressively but correctly. Cache node_modules (or the package manager's store) and .next/cache, but always key the cache off the lockfile hash so stale dependencies never leak between builds.
  • Fail the build on lint warnings in CI, even if you allow warnings locally (--max-warnings=0), to stop technical debt from silently accumulating.
  • Store secrets in GitHub Secrets or a secrets manager, never in .env files committed to the repo — and remember NEXT_PUBLIC_* variables end up in client-side JavaScript, so they're not truly private even when pulled from a secret store.
  • Add a test:e2e job on a schedule or before production deploys, not on every single commit, since E2E suites are slower and more prone to flakiness.

Common Mistakes to Avoid

  • Forgetting --frozen-lockfile / using npm install in CI, which can silently update dependencies mid-pipeline and cause non-reproducible builds.
  • Baking secrets into the Docker image or build artifact instead of injecting them at build/runtime, which risks leaking credentials if the artifact is ever made public.
  • Skipping CloudFront invalidation after an S3 deploy, leaving users on stale cached HTML for up to 24 hours.
  • Using output: "export" for an app that needs SSR/ISR/API routes — this silently breaks dynamic functionality with no clear error until users hit broken pages in production.
  • Running the entire test suite (including slow E2E tests) on every single push, which balloons CI time and frustrates contributors waiting on feedback.
  • Not pinning the Node.js version between local development, CI, and the hosting platform — subtle behavior differences between Node versions have caused more than a few "why does this only fail in production" incidents.
  • Granting overly broad AWS IAM permissions to the deploy role "just to be safe," turning a compromised CI token into a full account takeover risk.
  • No rollback plan. Always keep the last N successful build artifacts or Vercel deployments easily accessible so you can roll back in seconds, not minutes, when something goes wrong.

🚀 Pro Tips

  • Use workflow_dispatch in your workflow YAML to allow manually triggering a deployment from the GitHub UI — invaluable for hotfixes or re-deploying without a new commit.
  • Add a paths-ignore filter (e.g., ignore **/*.md) to your CI trigger so documentation-only changes don't burn runner minutes.
  • For monorepos, use actions/cache combined with turbo or nx remote caching to avoid rebuilding unaffected packages.
  • Post deployment summaries directly to your PR using $GITHUB_STEP_SUMMARY — it renders Markdown right in the Actions UI without needing a third-party action.
  • If your S3 bucket serves as an origin for CloudFront, use an Origin Access Control (OAC) instead of making the bucket public — it's the current AWS-recommended approach and replaces the older Origin Access Identity (OAI).
  • Combine next build's bundle analyzer with a CI step that fails the build if bundle size exceeds a threshold, catching bloat before it ships.
  • Use matrix builds (strategy.matrix.node-version: [20, 22]) if you support multiple Node versions across your team or deployment targets.

📌 Key Takeaways

  • CI/CD pipelines remove human error from the deployment process by encoding lint, test, build, and deploy steps into automated, repeatable workflows.
  • GitHub Actions' job dependencies (needs:) and caching let you build a fast, fail-fast pipeline that gives contributors quick feedback.
  • Vercel offers the path of least resistance for Next.js, especially for apps using SSR, ISR, or middleware — often requiring no custom GitHub Actions workflow at all.
  • S3 + CloudFront is a solid, cost-efficient choice for statically exported Next.js apps, but requires careful cache-control headers and CloudFront invalidation to avoid serving stale content.
  • Security matters as much as automation: use OIDC-based AWS authentication, scope IAM roles tightly, and never commit secrets to the repository.

Conclusion

A well-designed CI/CD pipeline turns deployment from a stressful, manual ritual into a boring, predictable non-event — which is exactly what you want in production engineering. Whether you choose Vercel for its zero-friction Next.js integration or S3/CloudFront for cost control and infrastructure ownership, the underlying principles stay the same: lint early, test thoroughly, build reproducibly, and deploy safely with proper secrets management and cache invalidation.

Start with the CI workflow in this guide, adapt the deployment strategy to your team's infrastructure, and iterate from there — adding E2E tests, Lighthouse audits, or Slack notifications as your project matures. The goal isn't a perfect pipeline on day one; it's a pipeline that gets a little better with every PR you merge.


References

All Articles
Next.jsCI/CDGitHub ActionsDevOpsVercelAWS

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.