Tools
Tools: Next.js CI/CD Pipeline: Complete Implementation Guide for Automated Deployments
2026-01-26
0 views
admin
Why CI/CD Matters for Next.js Applications ## Prerequisites ## Setting Up the Foundation ## Project Structure Best Practices ## Environment Variables Management ## Building the CI Pipeline ## Adding Test Scripts to package.json ## Implementing Automated Testing ## Setting Up Jest for Next.js ## Writing Component Tests ## Creating the CD Pipeline ## Deploying to Vercel ## Deploying to AWS or DigitalOcean ## Advanced CI/CD Features ## Parallel Testing for Faster Builds ## Automated Lighthouse Performance Checks ## Automatic Dependency Updates ## Preview Deployments for Pull Requests ## Security Best Practices ## Protecting Secrets ## Dependency Scanning ## Monitoring and Notifications ## Slack Notifications for Deployments ## Discord Notifications ## Troubleshooting Common Issues ## Build Failures ## Deployment Timeouts ## The Complete Workflow ## Conclusion Build a production-ready CI/CD workflow for Next.js applications with GitHub Actions, automated testing, and zero-downtime deployments Manually deploying your Next.js application every time you push code is tedious, error-prone, and slows down your development velocity. A proper CI/CD (Continuous Integration/Continuous Deployment) pipeline automates testing, building, and deploying your application, allowing you to ship features faster with confidence. In this comprehensive guide, you'll learn how to implement a complete CI/CD pipeline for Next.js applications using GitHub Actions, including automated testing, linting, building, and deployment to popular platforms. Modern Next.js applications are complex. They use TypeScript, require build optimization, leverage server-side rendering, and often integrate with multiple services. A robust CI/CD pipeline ensures: Let's build one from scratch. Before implementing CI/CD, ensure you have: First, organize your Next.js project for CI/CD success: Create a .env.example file documenting all required environment variables: Never commit actual .env files. Store secrets in GitHub Secrets or your deployment platform's secret management. Create .github/workflows/ci.yml for continuous integration: Update your package.json with necessary scripts: Install testing dependencies: Create jest.config.js: Example test for a Next.js component: Create .github/workflows/deploy-vercel.yml: For custom server deployments, create .github/workflows/deploy-production.yml: Speed up your CI pipeline by running tests in parallel: Add performance testing to your pipeline: Use Dependabot to keep dependencies updated. Create .github/dependabot.yml: Deploy every PR to a preview environment: Store all sensitive data in GitHub Secrets: Add automated security scanning: Add deployment notifications: If builds fail in CI but work locally, check: For large Next.js apps, increase timeout limits: Here's what your production-ready CI/CD pipeline accomplishes: Implementing CI/CD for your Next.js application transforms your development workflow. You ship faster, catch bugs earlier, and deploy with confidence. Start with the basic CI pipeline, add automated testing, then progressively enhance with preview deployments, performance checks, and security scanning. The investment in setting up CI/CD pays dividends immediately. Your team focuses on building features while automation handles the repetitive, error-prone deployment tasks. Ready to implement CI/CD? Start with the workflows in this guide, customize them for your needs, and watch your deployment velocity skyrocket. What CI/CD challenges have you faced with Next.js? Share your experiences and solutions in the comments below. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
nextjs-app/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
├── src/
├── tests/
├── package.json
├── next.config.js
└── .env.example Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
nextjs-app/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
├── src/
├── tests/
├── package.json
├── next.config.js
└── .env.example CODE_BLOCK:
nextjs-app/
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
├── src/
├── tests/
├── package.json
├── next.config.js
└── .env.example COMMAND_BLOCK:
# .env.example
NEXT_PUBLIC_API_URL=
DATABASE_URL=
AUTH_SECRET=
STRIPE_SECRET_KEY= Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# .env.example
NEXT_PUBLIC_API_URL=
DATABASE_URL=
AUTH_SECRET=
STRIPE_SECRET_KEY= COMMAND_BLOCK:
# .env.example
NEXT_PUBLIC_API_URL=
DATABASE_URL=
AUTH_SECRET=
STRIPE_SECRET_KEY= CODE_BLOCK:
name: CI Pipeline on: pull_request: branches: [main, develop] push: branches: [main, develop] jobs: lint-and-test: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run ESLint run: npm run lint - name: Run TypeScript check run: npm run type-check - name: Run unit tests run: npm run test - name: Run build run: npm run build env: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
name: CI Pipeline on: pull_request: branches: [main, develop] push: branches: [main, develop] jobs: lint-and-test: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run ESLint run: npm run lint - name: Run TypeScript check run: npm run type-check - name: Run unit tests run: npm run test - name: Run build run: npm run build env: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} CODE_BLOCK:
name: CI Pipeline on: pull_request: branches: [main, develop] push: branches: [main, develop] jobs: lint-and-test: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run ESLint run: npm run lint - name: Run TypeScript check run: npm run type-check - name: Run unit tests run: npm run test - name: Run build run: npm run build env: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} CODE_BLOCK:
{ "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" }
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
{ "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" }
} CODE_BLOCK:
{ "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "type-check": "tsc --noEmit", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" }
} COMMAND_BLOCK:
npm install -D jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
npm install -D jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom COMMAND_BLOCK:
npm install -D jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom CODE_BLOCK:
const nextJest = require('next/jest') const createJestConfig = nextJest({ dir: './',
}) const customJestConfig = { setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', '!src/**/*.stories.{js,jsx,ts,tsx}', ],
} module.exports = createJestConfig(customJestConfig) Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const nextJest = require('next/jest') const createJestConfig = nextJest({ dir: './',
}) const customJestConfig = { setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', '!src/**/*.stories.{js,jsx,ts,tsx}', ],
} module.exports = createJestConfig(customJestConfig) CODE_BLOCK:
const nextJest = require('next/jest') const createJestConfig = nextJest({ dir: './',
}) const customJestConfig = { setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', }, collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/*.d.ts', '!src/**/*.stories.{js,jsx,ts,tsx}', ],
} module.exports = createJestConfig(customJestConfig) COMMAND_BLOCK:
// __tests__/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import Button from '@/components/Button' describe('Button Component', () => { it('renders button with correct text', () => { render(<Button>Click me</Button>) expect(screen.getByText('Click me')).toBeInTheDocument() }) it('calls onClick handler when clicked', () => { const handleClick = jest.fn() render(<Button onClick={handleClick}>Click me</Button>) fireEvent.click(screen.getByText('Click me')) expect(handleClick).toHaveBeenCalledTimes(1) })
}) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// __tests__/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import Button from '@/components/Button' describe('Button Component', () => { it('renders button with correct text', () => { render(<Button>Click me</Button>) expect(screen.getByText('Click me')).toBeInTheDocument() }) it('calls onClick handler when clicked', () => { const handleClick = jest.fn() render(<Button onClick={handleClick}>Click me</Button>) fireEvent.click(screen.getByText('Click me')) expect(handleClick).toHaveBeenCalledTimes(1) })
}) COMMAND_BLOCK:
// __tests__/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import Button from '@/components/Button' describe('Button Component', () => { it('renders button with correct text', () => { render(<Button>Click me</Button>) expect(screen.getByText('Click me')).toBeInTheDocument() }) it('calls onClick handler when clicked', () => { const handleClick = jest.fn() render(<Button onClick={handleClick}>Click me</Button>) fireEvent.click(screen.getByText('Click me')) expect(handleClick).toHaveBeenCalledTimes(1) })
}) CODE_BLOCK:
name: Deploy to Vercel on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Deploy to Vercel uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: '--prod' Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
name: Deploy to Vercel on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Deploy to Vercel uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: '--prod' CODE_BLOCK:
name: Deploy to Vercel on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Deploy to Vercel uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: '--prod' CODE_BLOCK:
name: Deploy to Production on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20.x' cache: 'npm' - name: Install dependencies run: npm ci - name: Build application run: npm run build env: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - name: Deploy to server uses: appleboy/ssh-action@master with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /var/www/nextjs-app git pull origin main npm ci npm run build pm2 reload nextjs-app Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
name: Deploy to Production on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20.x' cache: 'npm' - name: Install dependencies run: npm ci - name: Build application run: npm run build env: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - name: Deploy to server uses: appleboy/ssh-action@master with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /var/www/nextjs-app git pull origin main npm ci npm run build pm2 reload nextjs-app CODE_BLOCK:
name: Deploy to Production on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20.x' cache: 'npm' - name: Install dependencies run: npm ci - name: Build application run: npm run build env: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - name: Deploy to server uses: appleboy/ssh-action@master with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /var/www/nextjs-app git pull origin main npm ci npm run build pm2 reload nextjs-app CODE_BLOCK:
test: runs-on: ubuntu-latest strategy: matrix: shard: [1, 2, 3, 4] steps: - name: Run tests run: npm run test -- --shard=${{ matrix.shard }}/4 Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
test: runs-on: ubuntu-latest strategy: matrix: shard: [1, 2, 3, 4] steps: - name: Run tests run: npm run test -- --shard=${{ matrix.shard }}/4 CODE_BLOCK:
test: runs-on: ubuntu-latest strategy: matrix: shard: [1, 2, 3, 4] steps: - name: Run tests run: npm run test -- --shard=${{ matrix.shard }}/4 CODE_BLOCK:
- name: Run Lighthouse CI run: | npm install -g @lhci/cli lhci autorun env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
- name: Run Lighthouse CI run: | npm install -g @lhci/cli lhci autorun env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} CODE_BLOCK:
- name: Run Lighthouse CI run: | npm install -g @lhci/cli lhci autorun env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} CODE_BLOCK:
version: 2
updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 10 Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
version: 2
updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 10 CODE_BLOCK:
version: 2
updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 10 CODE_BLOCK:
name: Preview Deployment on: pull_request: types: [opened, synchronize] jobs: preview: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Deploy Preview uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
name: Preview Deployment on: pull_request: types: [opened, synchronize] jobs: preview: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Deploy Preview uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} CODE_BLOCK:
name: Preview Deployment on: pull_request: types: [opened, synchronize] jobs: preview: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Deploy Preview uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} CODE_BLOCK:
- name: Run security audit run: npm audit --audit-level=high - name: Check for vulnerabilities uses: snyk/actions/node@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
- name: Run security audit run: npm audit --audit-level=high - name: Check for vulnerabilities uses: snyk/actions/node@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} CODE_BLOCK:
- name: Run security audit run: npm audit --audit-level=high - name: Check for vulnerabilities uses: snyk/actions/node@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} CODE_BLOCK:
- name: Notify Slack uses: 8398a7/action-slack@v3 if: always() with: status: ${{ job.status }} webhook_url: ${{ secrets.SLACK_WEBHOOK }} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
- name: Notify Slack uses: 8398a7/action-slack@v3 if: always() with: status: ${{ job.status }} webhook_url: ${{ secrets.SLACK_WEBHOOK }} CODE_BLOCK:
- name: Notify Slack uses: 8398a7/action-slack@v3 if: always() with: status: ${{ job.status }} webhook_url: ${{ secrets.SLACK_WEBHOOK }} CODE_BLOCK:
- name: Discord notification uses: Ilshidur/action-discord@master env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: args: 'Deployment to production completed!' Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
- name: Discord notification uses: Ilshidur/action-discord@master env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: args: 'Deployment to production completed!' CODE_BLOCK:
- name: Discord notification uses: Ilshidur/action-discord@master env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: args: 'Deployment to production completed!' CODE_BLOCK:
- name: Build application run: npm run build timeout-minutes: 20 Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
- name: Build application run: npm run build timeout-minutes: 20 CODE_BLOCK:
- name: Build application run: npm run build timeout-minutes: 20 - Consistent builds across all environments
- Automated testing catches bugs before production
- Faster deployment cycles with zero manual intervention
- Rollback capabilities when issues arise
- Team collaboration without deployment bottlenecks - A Next.js application in a Git repository
- A GitHub account (we'll use GitHub Actions)
- A deployment target (Vercel, AWS, DigitalOcean, etc.)
- Basic understanding of YAML syntax - Go to repository Settings → Secrets and variables → Actions
- Add secrets like VERCEL_TOKEN, DATABASE_URL, etc.
- Reference them in workflows using ${{ secrets.SECRET_NAME }} - Node version consistency
- Environment variables are properly set
- Dependencies are locked in package-lock.json - On every commit: Lint, type-check, and test
- On pull requests: Run full CI suite + deploy preview
- On merge to main: Build, test, and deploy to production
- Continuously: Scan for security vulnerabilities
- Weekly: Update dependencies automatically
how-totutorialguidedev.toaimlubuntuservernodedatabasegitgithub