In modern software development, Continuous Integration (CI) and Continuous Deployment (CD) are key practices that allow for faster and more reliable application delivery. Next.js, as a modern framework, integrates perfectly with various CI/CD tools, which allows automating the process of testing, building, and deploying applications.
Continuous Integration is a practice where developers regularly integrate their code changes into the main branch of the repository. Each integration is automatically verified by:
CI allows early detection of errors and ensures that the code in the main branch remains stable.
Continuous Deployment goes one step further - after successfully passing the CI stage, it automatically deploys changes to the production or test environment. The CD process includes:
For Next.js applications, we can use various CI/CD tools:
The easiest way to implement CI/CD for a Next.js project is to use GitHub Actions if your repository is hosted on GitHub.
Create the file
.github/workflows/ci.yml in your repository:1name: CI
2
3on:
4 push:
5 branches: [main, development]
6 pull_request:
7 branches: [main, development]
8
9jobs:
10 test:
11 runs-on: ubuntu-latest
12
13 strategy:
14 matrix:
15 node-version: [18.x]
16
17 steps:
18 - uses: actions/checkout@v3
19
20 - name: Use Node.js ${{ matrix.node-version }}
21 uses: actions/setup-node@v3
22 with:
23 node-version: ${{ matrix.node-version }}
24 cache: 'npm'
25
26 - name: Install dependencies
27 run: npm ci
28
29 - name: Lint
30 run: npm run lint
31
32 - name: Type check
33 run: npm run type-check
34
35 - name: Run tests
36 run: npm test
37
38 - name: Build
39 run: npm run buildThis workflow runs on every push to the
main and development branches and when a Pull Request is created to these branches.Create the file
.github/workflows/deploy.yml:1name: Deploy to Production
2
3on:
4 push:
5 branches: [main]
6
7jobs:
8 deploy:
9 runs-on: ubuntu-latest
10
11 steps:
12 - uses: actions/checkout@v3
13
14 - name: Deploy to Vercel
15 uses: amondnet/vercel-action@v20
16 with:
17 vercel-token: ${{ secrets.VERCEL_TOKEN }}
18 vercel-org-id: ${{ secrets.ORG_ID }}
19 vercel-project-id: ${{ secrets.PROJECT_ID }}
20 vercel-args: '--prod'For this to work, you need to configure the following secrets in your repository settings:
VERCEL_TOKEN - Vercel API tokenORG_ID - Vercel organization IDPROJECT_ID - Vercel project ID1name: Deploy to Staging
2
3on:
4 push:
5 branches: [development]
6
7jobs:
8 deploy:
9 runs-on: ubuntu-latest
10
11 steps:
12 - uses: actions/checkout@v3
13
14 - name: Deploy to Vercel (Preview)
15 uses: amondnet/vercel-action@v20
16 with:
17 vercel-token: ${{ secrets.VERCEL_TOKEN }}
18 vercel-org-id: ${{ secrets.ORG_ID }}
19 vercel-project-id: ${{ secrets.PROJECT_ID }}
20 github-comment: trueIf you use GitLab, you can configure CI/CD using
.gitlab-ci.yml:1image: node:18-alpine
2
3stages:
4 - test
5 - build
6 - deploy_staging
7 - deploy_production
8
9cache:
10 key: ${CI_COMMIT_REF_SLUG}
11 paths:
12 - node_modules/
13 - .next/cache/
14
15test:
16 stage: test
17 script:
18 - npm ci
19 - npm run lint
20 - npm run type-check
21 - npm test
22 except:
23 - tags
24
25build:
26 stage: build
27 script:
28 - npm ci
29 - npm run build
30 artifacts:
31 paths:
32 - .next/
33 except:
34 - tags
35
36deploy_staging:
37 stage: deploy_staging
38 script:
39 - npm install -g vercel
40 - vercel --token ${VERCEL_TOKEN} --confirm
41 environment:
42 name: staging
43 url: ${CI_PROJECT_NAME}-git-${CI_COMMIT_REF_NAME}-${CI_PROJECT_NAMESPACE}.vercel.app
44 only:
45 - development
46
47deploy_production:
48 stage: deploy_production
49 script:
50 - npm install -g vercel
51 - vercel --token ${VERCEL_TOKEN} --prod --confirm
52 environment:
53 name: production
54 url: ${CI_PROJECT_NAME}.vercel.app
55 only:
56 - mainTo configure CircleCI, create the file
.circleci/config.yml:1version: 2.1
2
3orbs:
4 node: circleci/node@5.0.3
5
6jobs:
7 test:
8 docker:
9 - image: cimg/node:18.15.0
10 steps:
11 - checkout
12 - node/install-packages:
13 pkg-manager: npm
14 - run:
15 name: Run tests
16 command: npm test
17 - run:
18 name: Run linter
19 command: npm run lint
20 - run:
21 name: Check types
22 command: npm run type-check
23
24 build:
25 docker:
26 - image: cimg/node:18.15.0
27 steps:
28 - checkout
29 - node/install-packages:
30 pkg-manager: npm
31 - run:
32 name: Build application
33 command: npm run build
34 - persist_to_workspace:
35 root: .
36 paths:
37 - .next
38 - node_modules
39 - package.json
40
41 deploy-staging:
42 docker:
43 - image: cimg/node:18.15.0
44 steps:
45 - checkout
46 - attach_workspace:
47 at: .
48 - run:
49 name: Install Vercel CLI
50 command: npm install -g vercel
51 - run:
52 name: Deploy to Staging
53 command: vercel --token ${VERCEL_TOKEN} --confirm
54
55 deploy-production:
56 docker:
57 - image: cimg/node:18.15.0
58 steps:
59 - checkout
60 - attach_workspace:
61 at: .
62 - run:
63 name: Install Vercel CLI
64 command: npm install -g vercel
65 - run:
66 name: Deploy to Production
67 command: vercel --token ${VERCEL_TOKEN} --prod --confirm
68
69workflows:
70 version: 2
71 test-build-deploy:
72 jobs:
73 - test
74 - build:
75 requires:
76 - test
77 - deploy-staging:
78 requires:
79 - build
80 filters:
81 branches:
82 only: development
83 - deploy-production:
84 requires:
85 - build
86 filters:
87 branches:
88 only: mainJenkins is a powerful CI/CD tool, but requires more configuration. Here is an example
Jenkinsfile for a CI/CD pipeline for a Next.js application:1pipeline {
2 agent {
3 docker {
4 image 'node:18-alpine'
5 }
6 }
7
8 stages {
9 stage('Install Dependencies') {
10 steps {
11 sh 'npm ci'
12 }
13 }
14
15 stage('Lint') {
16 steps {
17 sh 'npm run lint'
18 }
19 }
20
21 stage('Type Check') {
22 steps {
23 sh 'npm run type-check'
24 }
25 }
26
27 stage('Test') {
28 steps {
29 sh 'npm test'
30 }
31 }
32
33 stage('Build') {
34 steps {
35 sh 'npm run build'
36 }
37 }
38
39 stage('Deploy to Staging') {
40 when {
41 branch 'development'
42 }
43 steps {
44 sh 'npm install -g vercel'
45 sh 'vercel --token ${VERCEL_TOKEN} --confirm'
46 }
47 }
48
49 stage('Deploy to Production') {
50 when {
51 branch 'main'
52 }
53 steps {
54 sh 'npm install -g vercel'
55 sh 'vercel --token ${VERCEL_TOKEN} --prod --confirm'
56 }
57 }
58 }
59
60 post {
61 always {
62 cleanWs()
63 }
64 }
65}An important element of the CI/CD process is automatic testing. For Next.js applications, we can configure different types of tests:
1// __tests__/components/Header.test.tsx
2import { render, screen } from '@testing-library/react';
3import Header from '@/components/Header';
4
5describe('Header Component', () => {
6 it('renders the logo', () => {
7 render(<Header />);
8 const logo = screen.getByAltText('Logo');
9 expect(logo).toBeInTheDocument();
10 });
11
12 it('has the correct navigation links', () => {
13 render(<Header />);
14 expect(screen.getByText('Home')).toBeInTheDocument();
15 expect(screen.getByText('About')).toBeInTheDocument();
16 expect(screen.getByText('Contact')).toBeInTheDocument();
17 });
18});1// __tests__/api/user.test.ts
2import { createMocks } from 'node-mocks-http';
3import userHandler from '@/app/api/user/route';
4
5describe('/api/user endpoint', () => {
6 it('returns a user when provided with a valid ID', async () => {
7 const { req, res } = createMocks({
8 method: 'GET',
9 query: { id: '123' },
10 });
11
12 await userHandler(req, res);
13
14 expect(res._getStatusCode()).toBe(200);
15 expect(JSON.parse(res._getData())).toEqual(
16 expect.objectContaining({
17 id: '123',
18 name: expect.any(String),
19 })
20 );
21 });
22
23 it('returns 404 for non-existent user', async () => {
24 const { req, res } = createMocks({
25 method: 'GET',
26 query: { id: 'non-existent' },
27 });
28
29 await userHandler(req, res);
30
31 expect(res._getStatusCode()).toBe(404);
32 });
33});1// cypress/integration/navigation.spec.js
2describe('Navigation', () => {
3 it('should navigate to the about page', () => {
4 cy.visit('/');
5
6 cy.get('a[href*="about"]').click();
7
8 cy.url().should('include', '/about');
9 cy.get('h1').contains('About');
10 });
11
12 it('should navigate to the contact page', () => {
13 cy.visit('/');
14
15 cy.get('a[href*="contact"]').click();
16
17 cy.url().should('include', '/contact');
18 cy.get('h1').contains('Contact');
19 });
20});Add Cypress configuration to GitHub Actions:
1# .github/workflows/e2e-tests.yml
2name: E2E Tests
3
4on:
5 deployment_status:
6
7jobs:
8 e2e-tests:
9 if: github.event.deployment_status.state == 'success'
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v3
13
14 - name: Install dependencies
15 run: npm ci
16
17 - name: Cypress run
18 uses: cypress-io/github-action@v5
19 with:
20 record: true
21 env:
22 CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
23 CYPRESS_BASE_URL: ${{ github.event.deployment_status.target_url }}Beyond CI/CD, we can automate various other development tasks:
Install the required packages:
1npm install --save-dev husky lint-staged prettierUpdate
package.json:1{
2 "scripts": {
3 "prepare": "husky install"
4 },
5 "lint-staged": {
6 "*.{js,jsx,ts,tsx}": [
7 "prettier --write",
8 "eslint --fix"
9 ],
10 "*.{json,md}": [
11 "prettier --write"
12 ]
13 }
14}Create a pre-commit hook:
1npx husky add .husky/pre-commit "npx lint-staged"Install the required packages:
1npm install --save-dev semantic-release @semantic-release/git @semantic-release/changelogCreate the file
.releaserc.json:1{
2 "branches": ["main"],
3 "plugins": [
4 "@semantic-release/commit-analyzer",
5 "@semantic-release/release-notes-generator",
6 "@semantic-release/changelog",
7 "@semantic-release/npm",
8 ["@semantic-release/git", {
9 "assets": ["package.json", "CHANGELOG.md"],
10 "message": "chore(release): ${nextRelease.version} [skip ci]
11
12${nextRelease.notes}"
13 }],
14 "@semantic-release/github"
15 ]
16}Add a workflow for semantic-release:
1# .github/workflows/release.yml
2name: Release
3
4on:
5 push:
6 branches: [main]
7
8jobs:
9 release:
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v3
13 - name: Setup Node.js
14 uses: actions/setup-node@v3
15 with:
16 node-version: '18'
17 cache: 'npm'
18 - name: Install dependencies
19 run: npm ci
20 - name: Release
21 env:
22 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
24 run: npx semantic-releaseFor TypeScript projects, you can automate documentation generation:
1# .github/workflows/docs.yml
2name: Generate Documentation
3
4on:
5 push:
6 branches: [main]
7
8jobs:
9 docs:
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v3
13 - name: Setup Node.js
14 uses: actions/setup-node@v3
15 with:
16 node-version: '18'
17 cache: 'npm'
18 - name: Install dependencies
19 run: npm ci
20 - name: Generate docs
21 run: npx typedoc --out docs src
22 - name: Deploy docs to GitHub Pages
23 uses: peaceiris/actions-gh-pages@v3
24 with:
25 github_token: ${{ secrets.GITHUB_TOKEN }}
26 publish_dir: ./docsWhen deploying applications, it's worth implementing safe deployment strategies:
Blue-Green deployment involves maintaining two identical production environments, of which only one is active. In the case of Vercel, you can use their built-in "Preview Deployments" feature and domain aliasing:
1// scripts/blue-green-deploy.js
2const { execSync } = require('child_process');
3
4// Deploy to new environment (but not live yet)
5const output = execSync('vercel --prod').toString();
6const deploymentUrl = output.match(/https://[w.-]+/)[0];
7
8// Run smoke tests against new deployment
9const smokeTestResult = execSync(`npm run test:smoke -- --url=${deploymentUrl}`).toString();
10
11if (smokeTestResult.includes('All tests passed')) {
12 // Switch traffic to new deployment
13 execSync(`vercel alias set ${deploymentUrl} your-production-domain.com`);
14 console.log('Successfully switched to new deployment');
15} else {
16 console.error('Smoke tests failed, aborting deployment');
17 process.exit(1);
18}In the Canary strategy, a new version is gradually released, initially only for a small percentage of users. In Next.js, you can achieve this using feature flags and traffic splitting:
1// lib/feature-flags.ts
2import { useEffect, useState } from 'react';
3
4const CANARY_PERCENTAGE = 10; // 10% of users will get the new version
5
6export function useCanaryFeature(featureName: string): boolean {
7 const [isEnabled, setIsEnabled] = useState(false);
8
9 useEffect(() => {
10 // Generate a random number for the user and save it in local storage
11 let userBucket = localStorage.getItem('userBucket');
12 if (!userBucket) {
13 userBucket = Math.floor(Math.random() * 100).toString();
14 localStorage.setItem('userBucket', userBucket);
15 }
16
17 // Check if the user is in the canary group
18 const inCanaryGroup = parseInt(userBucket) < CANARY_PERCENTAGE;
19
20 // Check if the feature is active for this user
21 fetch('/api/feature-flags')
22 .then(res => res.json())
23 .then(flags => {
24 // If the flag exists and the user is in the canary group, enable the feature
25 if (flags[featureName] && inCanaryGroup) {
26 setIsEnabled(true);
27 }
28 });
29 }, [featureName]);
30
31 return isEnabled;
32}Automatic rollback deployment when issues arise is a key practice:
1# .github/workflows/deploy-monitor-rollback.yml
2name: Deploy, Monitor & Rollback
3
4on:
5 push:
6 branches: [main]
7
8jobs:
9 deploy:
10 runs-on: ubuntu-latest
11 outputs:
12 deployment_id: ${{ steps.deploy.outputs.deployment_id }}
13 steps:
14 - uses: actions/checkout@v3
15 - name: Deploy to Vercel
16 id: deploy
17 uses: amondnet/vercel-action@v20
18 with:
19 vercel-token: ${{ secrets.VERCEL_TOKEN }}
20 vercel-org-id: ${{ secrets.ORG_ID }}
21 vercel-project-id: ${{ secrets.PROJECT_ID }}
22 vercel-args: '--prod'
23
24
25 monitor:
26 needs: deploy
27 runs-on: ubuntu-latest
28 steps:
29 - name: Wait for 2 minutes
30 run: sleep 120
31
32 - name: Check for errors
33 id: check_errors
34 run: |
35 ERROR_COUNT=$(curl -s "https://api.example.com/monitoring/errors?since=$(date -u -d '2 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" | jq '.count')
36 echo "::set-output name=error_count::$ERROR_COUNT"
37 if [ "$ERROR_COUNT" -gt "10" ]; then
38 echo "Error count too high ($ERROR_COUNT). Failing job."
39 exit 1
40 fi
41
42 - name: Monitor Core Web Vitals
43 id: web_vitals
44 run: |
45 LCP=$(curl -s "https://api.example.com/monitoring/web-vitals" | jq '.lcp')
46 if [ "$LCP" -gt "2500" ]; then
47 echo "LCP too high ($LCP ms). Failing job."
48 exit 1
49 fi
50
51 rollback:
52 needs: [deploy, monitor]
53 runs-on: ubuntu-latest
54 if: ${{ failure() }}
55 steps:
56 - name: Rollback to previous deployment
57 uses: amondnet/vercel-action@v20
58 with:
59 vercel-token: ${{ secrets.VERCEL_TOKEN }}
60 vercel-org-id: ${{ secrets.ORG_ID }}
61 vercel-project-id: ${{ secrets.PROJECT_ID }}
62 vercel-args: '--prod --scope myorg' # Rollback to previous deploymentSecure management of environment variables is a critical aspect of CI/CD:
Most CI/CD systems offer secure storage of secrets, e.g., GitHub Secrets:
1# .github/workflows/deploy.yml
2name: Deploy
3
4on:
5 push:
6 branches: [main]
7
8jobs:
9 deploy:
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v3
13
14 - name: Create .env file
15 run: |
16 cat > .env.production << EOL
17 DATABASE_URL=${{ secrets.DATABASE_URL }}
18 API_KEY=${{ secrets.API_KEY }}
19 NEXT_PUBLIC_ANALYTICS_ID=${{ secrets.NEXT_PUBLIC_ANALYTICS_ID }}
20 EOL
21
22 - name: Deploy
23 run: npm run deployFor critical applications, it's worth implementing automatic rotation of secrets:
1# .github/workflows/rotate-secrets.yml
2name: Rotate Secrets
3
4on:
5 schedule:
6 - cron: '0 0 1 * *' # Every month
7
8jobs:
9 rotate:
10 runs-on: ubuntu-latest
11 steps:
12 - name: Generate new API key
13 id: new_key
14 run: echo "::set-output name=api_key::$(openssl rand -base64 32)"
15
16 - name: Update API service with new key
17 run: |
18 curl -X POST -H "Authorization: Bearer ${{ secrets.ADMIN_TOKEN }}" -d '{"api_key": "${{ steps.new_key.outputs.api_key }}"}' https://api.example.com/admin/update-key
19
20 - name: Update GitHub secret
21 uses: gliech/create-github-secret-action@v1
22 with:
23 name: API_KEY
24 value: ${{ steps.new_key.outputs.api_key }}
25 pa_token: ${{ secrets.PA_TOKEN }}A key element of the CI/CD process is deployment monitoring to quickly detect and respond to potential problems:
1# .github/workflows/deploy-and-monitor.yml
2name: Deploy and Monitor
3
4on:
5 push:
6 branches: [main]
7
8jobs:
9 deploy:
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v3
13 - name: Deploy to Vercel
14 uses: amondnet/vercel-action@v20
15 with:
16 vercel-token: ${{ secrets.VERCEL_TOKEN }}
17 vercel-org-id: ${{ secrets.ORG_ID }}
18 vercel-project-id: ${{ secrets.PROJECT_ID }}
19 vercel-args: '--prod'
20
21 - name: Notify Datadog of deployment
22 run: |
23 curl -X POST "https://api.datadoghq.com/api/v1/events" -H "Content-Type: application/json" -H "DD-API-KEY: ${{ secrets.DATADOG_API_KEY }}" -d '{"title":"Deployment to production","text":"Deployed commit ${{ github.sha }} to production","tags":["environment:production","team:frontend"],"alert_type":"info"}'1# .github/workflows/post-deploy-tests.yml
2name: Post-deployment Tests
3
4on:
5 deployment_status:
6
7jobs:
8 smoke-tests:
9 if: github.event.deployment_status.state == 'success'
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v3
13
14 - name: Run smoke tests
15 run: |
16 npm ci
17 npm run test:smoke -- --url=${{ github.event.deployment_status.target_url }}
18
19 - name: Post results to Slack
20 if: always()
21 uses: slackapi/slack-github-action@v1
22 with:
23 payload: |
24 {
25 "text": "Smoke test results: ${{ job.status }}
26Deployment URL: ${{ github.event.deployment_status.target_url }}"
27 }
28 env:
29 SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Implementing CI/CD for Next.js applications is a key element of the modern software development process that brings many benefits:
A properly configured CI/CD pipeline is an investment that pays off in time savings, higher code quality, and more reliable deployments. Regardless of the chosen platform (GitHub Actions, GitLab CI, CircleCI, Jenkins), the key is to adapt the process to the specific needs of the project and team.