// LOADING
// LOADING
// LOADING_ARTICLE
Shipping Next.js applications without automated quality gates is a gamble that pays off right up until it doesn't. A well-structured CI/CD pipeline costs a few hours to set up and saves those hours back every sprint. This guide walks through a practical workflow covering lint, type checking, testing, build verification, and deployment.
The pipeline's job is simple: run every check that could catch a problem faster than a human code review. That means:
A pipeline that takes longer than ten minutes will be bypassed. Keep it fast by parallelizing jobs and caching aggressively.
Create .github/workflows/ci.yml in your repo:
yamlname: CI on: push: branches: [main, 'fix/**', 'feat/**'] pull_request: branches: [main] jobs: quality: name: Lint, Type Check & Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up 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 --noEmit - name: Lint run: npm run lint - name: Run tests run: npm test -- --ci --passWithNoTests build: name: Production Build runs-on: ubuntu-latest needs: quality env: # Provide all required env vars as secrets MONGODB_URI: ${{ secrets.MONGODB_URI }} NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} NEXTAUTH_URL: https://example.com steps: - uses: actions/checkout@v4 - name: Set up 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 - name: Upload build artifact uses: actions/upload-artifact@v4 with: name: nextjs-build path: .next/ retention-days: 1
The needs: quality ensures the build job only runs if linting and tests pass—no point burning minutes on a build you know is broken.
actions/setup-node with cache: 'npm' caches the npm cache directory, not node_modules. Combined with npm ci (which installs from package-lock.json), this gives you reproducible installs in under 30 seconds on warm caches.
If you're using pnpm or yarn, swap the cache key accordingly:
yaml- uses: pnpm/action-setup@v3 with: version: 9 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'pnpm'
For Next.js's .next/cache, cache it between builds to speed up incremental compilation:
yaml- name: Cache Next.js build cache uses: actions/cache@v4 with: path: .next/cache key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }} restore-keys: | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- ${{ runner.os }}-nextjs-
Your build likely needs environment variables—database URLs, API keys, NextAuth secrets. Never hardcode these. Store them in GitHub repository secrets (Settings → Secrets and variables → Actions) and reference them with ${{ secrets.SECRET_NAME }}.
For the distinction between build-time and runtime environment variables in Next.js, see Environment Variables and Secrets Management in Next.js. The short version: NEXT_PUBLIC_* variables are baked into the client bundle at build time, so they must be present during CI's npm run build.
For Vercel deployments, the Vercel CLI integrates cleanly:
yamldeploy: name: Deploy to Vercel runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - uses: actions/checkout@v4 - name: Deploy to Vercel run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }} env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
The if condition restricts production deploys to direct pushes to main. PRs get preview deployments via Vercel's GitHub integration without needing this job at all.
Catching bundle regressions early prevents the slow creep that leads to a bloated application. size-limit is a lightweight option:
bashnpm install --save-dev @size-limit/preset-app size-limit
json// package.json { "size-limit": [ { "path": ".next/static/chunks/pages/**/*.js", "limit": "200 kB" } ] }
yaml- name: Check bundle size run: npx size-limit
This pairs well with the techniques in Reducing JavaScript Bundle Size in Next.js.
If your app needs to run on multiple Node versions (common for open-source projects or when your host and local environments differ):
yamlquality: strategy: matrix: node: ['18', '20', '22'] runs-on: ubuntu-latest steps: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }}
For most production apps targeting a single host, this isn't necessary—pin to the Node version your hosting platform uses.
Docs changes and typo fixes don't need a full build. Use the [skip ci] commit message convention or path filters:
yamlon: push: paths-ignore: - '**.md' - 'public/**' - '.github/ISSUE_TEMPLATE/**'
This keeps your Actions minute usage reasonable without weakening the safety net on code changes.
A pipeline that catches errors but doesn't block deployments is half the solution. For feature flag-driven deploys where you want the code in production before enabling the feature, see Feature Flags and Safer Deploys.
For a self-hosted or Dockerized deployment workflow, the pipeline feeds directly into a Docker build and push step—covered in Dockerizing a Node.js + Next.js App.
A solid Next.js CI pipeline has three jobs: quality gates (lint, types, tests), a production build verification, and deployment gated on the first two. Keep each job focused, cache aggressively, and use GitHub secrets for sensitive values. The upfront investment in automation pays back every time a regression is caught before it reaches your users.