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