On this page
Deployment (page 1 of this section) got you a live URL where main is production and every PR gets a preview. That is enough for a toy. For anything you charge money for, you want three environments that mean three different things, and a gate that makes a red commit physically unable to reach production. This page is that gate.
The model is fixed: three environments, mapped to branches, with one GitHub Actions check that has to pass before anything merges. You run the same five verifications locally that CI runs remotely, so CI is a backstop, not a surprise.
Three environments, three jobs
Stop thinking "dev and prod". Think three environments, each answering a different question.
- Local (
npm run dev, Turbopack, hot reload). Answers "does my change work at all". Talks to dev data you can wipe without a second thought. - Staging answers "does this work in a production-like build, with production-like data, before a real user sees it". This is where you click through a feature end to end and where stakeholders sign off. Same
next buildas production, separate database, separate secrets. - Production answers nothing. It just serves real users. By the time a change lands here it has already passed CI, passed staging, and been reviewed. Production is the boring end of the pipe on purpose.
Map them to Git so the path is mechanical, not a judgment call every time:
feature/* --PR--> staging --PR--> main
| | |
local staging env production env
(npm dev) (preview/project) (main = prod)You branch off staging for every unit of work, open a PR back into staging, and let CI plus a Vercel preview vet it. When staging looks right, you open a second PR from staging into main, which promotes the batch to production. Two branches you protect, one branch (feature/*) you treat as disposable.
The one check that gates everything
Vercel builds your app, but a green Vercel build only proves it compiled. It does not run your types, your linter, your content guard, or your tests. That is what GitHub Actions is for. One workflow, one job, five steps, run on every PR and every push to main.
This is the workflow this repo actually ships. Copy it verbatim into a new project.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Content guard
run: npm run check:content
- name: Unit tests
run: npm test
- name: Build
run: npm run buildThe steps are deliberately ordered cheapest-and-most-likely-to-fail first, so a broken PR fails in seconds, not after a full build:
npm run typecheck(tsc --noEmit) catches the Next.js 16 traps the dev server ignores.paramsis a Promise you mustawait; a route that forgets is a type error caught here.npm run lint(eslint) is its own step on purpose:next buildno longer runs the linter for you in Next.js 16. If you skip this step, lint never runs in CI.npm run check:contentis the project's content guard (scripts/check-content.mjs): it fails the build on a stray em-dash or a leaked scaffolding placeholder in shipped copy.npm test(vitest run) runs the unit suite once and exits. Usevitest run, not barevitest, or CI hangs forever in watch mode.npm run build(next build) is the real production compile. Green here with the same Node major version means Vercel is almost certainly green too.
Match the CI Node version to the version you pin in Vercel project settings and develop with locally. The workflow sets node-version: 20; your Vercel project should be on 20.x. Three environments, one Node major.
Do not run next lint in CI. It is removed in Next.js 16. Call eslint directly through your lint script, exactly as the workflow above does.
Persist the build cache across runs
actions/setup-node with cache: npm (above) caches your npm downloads, which speeds up npm ci. It does not cache the Next.js compiler output. Next.js writes a build cache to .next/cache and reuses it across builds; if CI throws it away every run, you rebuild from cold every time and you will see a No Cache Detected warning. Persist it with actions/cache, keyed so a new cache is generated whenever packages or source files change.
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: |
~/.npm
${{ github.workspace }}/.next/cache
# New cache whenever packages or source files change.
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
# If only source changed, rebuild from a prior cache.
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-Make the gate mandatory with branch protection
A workflow that runs but does not block is theater. Turn it into a wall. In GitHub, go to Settings, Branches, Add branch ruleset (or the classic Branch protection rules) and protect both main and staging.
Require the status check to pass
Enable Require status checks to pass before merging and select the verify job from your workflow. The check name is the job id (verify) from ci.yml. Now GitHub physically disables the merge button until that job is green. A failing typecheck, a leaked placeholder, or a broken test all hold the PR open.
Also tick Require branches to be up to date before merging so a PR must rebase onto the latest target before it can land. This stops the classic case where two PRs each pass alone but break once combined.
Require a pull request and a review
Enable Require a pull request before merging so nobody can push straight to main or staging. Set Required approvals to 1. Even as a solo developer this is worth keeping on for main: it forces every production change to travel through a PR where the CI check and the Vercel preview both have to report in before you click merge.
Lock the door for everyone
Enable Do not allow bypassing the above settings (or Include administrators on the classic UI). If admins can bypass the gate, the gate does not exist, because the one time you bypass it at midnight is the one time you ship the broken build. Make the rule apply to you.
Avoid
# No protection. A broken commit goes straight to production.
git push origin mainPrefer
# Protected. Work flows through a PR that CI and the preview must clear.
git switch -c feature/pricing-table staging
git push -u origin feature/pricing-table
gh pr create --base staging --fill
# merge only after the `verify` check is green and the preview looks rightYour local loop mirrors CI exactly
The whole pipeline only works if you run the same checks before you push. This repo's verify script is CI in one command, so there are no surprises remotely.
{
"scripts": {
"verify": "npm run typecheck && npm run lint && npm run check:content && npm run test && npm run build"
}
}npm run verifySame five steps, same order, same outcome. If npm run verify is green locally, the verify job will be green in CI, and you merge with confidence instead of pushing and praying.