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:
- Checkout — pull the repository code into the runner.
- Install dependencies — using
npm ci,pnpm install --frozen-lockfile, oryarn install --frozen-lockfile. - Lint — run ESLint (and often Prettier's
--checkmode) to catch style and correctness issues. - Type-check — run
tsc --noEmitif you're using TypeScript. - Test — run unit tests (Jest/Vitest) and optionally integration or E2E tests (Playwright/Cypress).
- Build — run
next buildto produce a production bundle. - 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:
concurrencycancels older, in-progress runs when new commits land on the same branch/PR, saving runner minutes.needs:chains jobs sobuildonly runs aftertestpasses, andtestonly runs after linting succeeds.- Caching
.next/cachespeeds 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 accountVERCEL_ORG_IDandVERCEL_PROJECT_ID— found in your project's.vercel/project.jsonafter runningvercel linklocally 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-assumewith 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-livedimmutablecache headers, while HTML and JSON files getmust-revalidateso 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:
- A developer opens a PR against
main. - The
ci.ymlworkflow triggers: lint → type-check → test → build, all visible as required status checks. - Vercel (via native Git integration) automatically generates a preview deployment with a unique URL, posted as a PR comment.
- Reviewers test the preview URL directly, catching visual or functional regressions before merge.
- Once approved and merged,
maintriggers the production deployment workflow — eitherdeploy-vercel.ymlordeploy-s3-cloudfront.yml. - The team gets a Slack notification (via a
slack-github-actionstep) 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, notnpm 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
.envfiles committed to the repo — and rememberNEXT_PUBLIC_*variables end up in client-side JavaScript, so they're not truly private even when pulled from a secret store. - Add a
test:e2ejob 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/ usingnpm installin 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_dispatchin 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-ignorefilter (e.g., ignore**/*.md) to your CI trigger so documentation-only changes don't burn runner minutes. - For monorepos, use
actions/cachecombined withturboornxremote 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.