35% OFF
Ends14d 00:00:00
Shop
Back to Blog
nextjsdevopstypescriptwebdev

Deploying Next.js 15 — The CI/CD Setup I Actually Use

Pixel Anas··6 min read

How I deploy Next.js 15 to Vercel with GitHub Actions, environment variable management across environments, and preview deployments for every pull request.

For a long time deployment meant pushing to main and hoping nothing broke. No preview environment, no automated checks, just a direct line from my laptop to production. One bad push later, that stopped being an acceptable way to work.

Here is the deployment setup I actually use now, across solo projects and client work.


1. Vercel as the Default, and Why

For most Next.js projects, Vercel remains the simplest deploy target since it is built by the same team as the framework, meaning App Router features, Server Actions, and edge functions all work without extra configuration most other hosts need.

npm install -g vercel
vercel login
vercel link

Connecting a GitHub repo through the Vercel dashboard is usually enough to get automatic deployments on every push, no CI config required for the basic case. The setup below adds the layer on top of that: checks that must pass before a deployment is trusted.


2. Preview Deployments for Every Pull Request

This is the single most useful thing Vercel does automatically. Every pull request gets its own live URL, a real deployed version of that branch, not just a diff to review.

https://your-project-git-feature-branch-yourteam.vercel.app

Reviewing a pull request by clicking through the actual running app, instead of just reading a diff, catches visual and interaction bugs that code review alone misses. This happens automatically once the repo is connected, no extra configuration needed.


3. Running Checks Before Deployment with GitHub Actions

Vercel deploys on every push by default, checks or no checks. Adding a GitHub Actions workflow means broken code gets caught before it becomes a preview deployment someone might click into.

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

on:
  pull_request:
    branches: [main]

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Type check
        run: npx tsc --noEmit

      - name: Lint
        run: npm run lint

      - name: Test
        run: npm run test

      - name: Build
        run: npm run build

npm ci instead of npm install matters in CI specifically, it installs exactly what is in package-lock.json with no version drift, and fails outright if the lockfile and package.json are out of sync, which catches a whole class of "works on my machine" issues.


4. Blocking Merges on Failed Checks

The workflow above runs, but does nothing to stop a merge unless it is actually required. In the GitHub repo settings, under branch protection rules for main, require the checks job to pass before merging is allowed.

Without this step, the CI workflow is informational only, someone can see a red X and merge anyway. Making it a required check turns it into an actual gate.


5. Environment Variables Across Environments

This is where I have seen the most real mistakes happen, a development database URI accidentally used in production, or a Stripe test key shipped live. Vercel supports separate variables per environment, and it is worth actually using that separation.

# In the Vercel dashboard, set separately for each environment:

Production:
  MONGODB_URI=mongodb+srv://prod-cluster...
  STRIPE_SECRET_KEY=sk_live_...

Preview:
  MONGODB_URI=mongodb+srv://staging-cluster...
  STRIPE_SECRET_KEY=sk_test_...

Development:
  MONGODB_URI=mongodb+srv://dev-cluster...
  STRIPE_SECRET_KEY=sk_test_...

Preview deployments (from pull requests) should never point at the production database or live payment keys. A bug in a feature branch should not be able to touch real customer data, and testing a Stripe flow on a preview URL should never risk a real charge.


6. Validating Environment Variables at Build Time

A missing environment variable failing silently in production, rather than loudly at build time, is a specific kind of bad. Catching it during the build instead means a bad deploy never goes live in the first place.

// lib/env.ts
const requiredEnvVars = [
  'MONGODB_URI',
  'JWT_SECRET',
  'STRIPE_SECRET_KEY',
] as const;

for (const key of requiredEnvVars) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}
// next.config.ts
import './lib/env';

const nextConfig = {
  // your config
};

export default nextConfig;

Importing this at the top of next.config.ts means the build fails immediately if a required variable is missing, instead of deploying successfully and failing later at runtime when a real request hits the missing config.


7. Database Migrations in a Deploy Pipeline

Schema changes need to happen before the new code that depends on them goes live, not after. For a Mongoose-based project without a formal migration tool, a simple safeguard is running any pending index or schema updates as a build step:

// package.json
{
  "scripts": {
    "build": "npm run db:sync && next build"
  }
}

For anything beyond simple index changes, a real migration tool becomes worth adding, but even a basic sync-before-build step catches the common case of a new required index not existing yet when the new code expects it.


8. Rolling Back a Bad Deploy

Even with checks in place, something eventually ships broken. Vercel keeps every previous deployment live and instantly promotable back to production, no rebuild required.

vercel ls
vercel promote <deployment-url>

This is faster than reverting a commit and waiting for a fresh build, since the previous working deployment is already built and sitting there. Reverting the commit in git afterward keeps the repo history accurate, but the promote step is what actually stops the bleeding immediately.


Summary

| Piece | Solves | |-------|--------| | Vercel + GitHub integration | Automatic deployments, no manual deploy steps | | Preview deployments per PR | Reviewing a real running app, not just a diff | | GitHub Actions CI workflow | Catching type errors, lint issues, and failing tests before merge | | Required status checks | Turning CI from informational into an actual merge gate | | Separate env vars per environment | Preview and dev never touching production data or live keys | | Build-time env validation | Failing loud at build time instead of silent at runtime | | Instant rollback via promote | Fast recovery from a bad deploy without waiting on a rebuild |

None of this is complicated individually. What it adds up to is the difference between finding out something broke from a failed check versus finding out from a client message after it already broke in production.

I run this exact setup, CI checks, environment separation, preview deployments, across every client project and template I ship.

Get the templates: https://pixelanas.gumroad.com

What does your deployment pipeline look like, manual pushes or a full CI setup? Drop it below 👇


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751