If you are running a modern Next.js application inside a monorepo (like Turborepo), Vercel is undoubtedly the best place to host it. However, if your team practices strict trunk-based development—where main is your single source of truth for all environments—Vercel's default Git integration can feel a bit restrictive.
Many teams end up creating multiple separate Vercel projects (e.g., acme-web-staging and acme-web-prod) to handle different environments. This creates massive overhead: drifting environment variables, duplicated build minutes, and messy webhook configurations.
In this guide, we are going to configure a single Vercel project to perfectly support trunk-based development.
The Goal:
- PRs: Automatically build and deploy to a random preview URL.
- Staging: Automatically build and deploy to our staging domains (stage.acme.app) whenever a PR merges into main.
- Production: Manually triggered via GitHub Actions, building directly from main and deploying to our production domains (acme.app).
Here is the exact blueprint to set this up, including the gotchas that usually trip teams up.
---
Phase 1: Decoupling main from Production
By default, Vercel assumes your default branch (main) is your Production environment. Any push to main instantly updates your live production URLs. To make the main branch our Staging environment, we have to trick Vercel into demoting it.
The Gotcha: You cannot assign a "Production Branch" in Vercel unless that branch actually exists in your GitHub repository.
- Open your local terminal and create a dummy branch: git checkout -b vercel-do-not-deploy
- Push this empty branch to GitHub: git push origin vercel-do-not-deploy
- Go to your Vercel Project Settings > Git.
- Change the Production Branch from main to vercel-do-not-deploy. Save the changes.
- (Optional but recommended): Delete any vercel.json files that have "main": false deployment overrides. We want Vercel to auto-deploy main now.
Result: Vercel now considers main to be a Preview branch. Merges to main will no longer touch your live production users.
---
Phase 2: Domain Routing
Now that main is technically a Preview environment, we need to wire up our custom staging domains so they update automatically on merge.
- Go to Project Settings > Domains.
- Add your production domains (e.g., acme.app, dashboard.acme.app). Ensure their Git Branch mapping is left blank or points to your dummy production branch. These are now locked to Production.
- Add your staging domains (e.g., stage.acme.app, stage.dashboard.acme.app).
- Click Edit on your staging domains and set the Git Branch explicitly to main.
Result: When a PR merges, Vercel finishes the Preview build on main and automatically points stage.acme.app to that exact artifact.
---
Phase 3: Scoping Environment Variables
Because Next.js bakes NEXT_PUBLIC_ variables into the frontend code at build time, environment variable hygiene is critical.
The Gotcha: Leaving production variables set to "All Environments" is a massive security risk. If a staging variable is missing, Vercel falls back to the production key, and your staging app might accidentally process live Stripe transactions or hit production databases.
- Go to Project Settings > Environment Variables.
- Lock down Production: Edit every production variable (API keys, DB URLs) and uncheck "Preview" and "Development". Leave only "Production" checked.
-
Scope Staging to main: Add your staging variables. Check the Preview box. A dropdown will appear asking for a specific branch. Type exactly
main. - Handle PR Previews: If you want PRs to use staging data, add the staging variables again, check Preview, but leave the branch selection completely empty. This acts as the fallback for any branch that isn't main.
---
Phase 4: The CI/CD Pipeline (GitHub Actions)
Since main is no longer the Production branch, a standard Vercel Webhook triggered against main will only build stage. To trigger a true Production build directly from main, we must use the Vercel CLI inside GitHub Actions.
The Gotcha: Do not use a sparse checkout to only pull your Next.js app folder (apps/acme-web). Vercel is heavily optimized for monorepos (like Turborepo). If you don't hand Vercel the entire monorepo with the root package.json and lockfiles, the build will crash because it cannot resolve your shared workspaces (e.g., @acme/ui, @acme/tailwind-config).
Note: You can use sparse checkout with file path directives but it’s typically not needed.
First, add these two Repository Variables to GitHub:
- VERCEL_ORG_ID: Found in your Vercel Team Settings.
- VERCEL_PROJECT_ID: Found in your Vercel Project Settings.
Second, add this Repository Secret to Github:
- VERCEL_TOKEN: Generated from your Vercel Personal Account settings.
Then, create this Workflow Dispatch file (.github/workflows/manual-deploy-vercel.yml):
name: Manual Vercel Deploy
on:
workflow_dispatch:
inputs:
environment:
description: 'Which environment do you want to deploy?'
required: true
default: 'production'
type: choice
options:
- production
- staging
env:
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
- name: Install Vercel CLI
run: npm install --global vercel@latest
# --- STAGING DEPLOYMENT STEPS ---
- name: Deploy to Vercel (Staging)
if: github.event.inputs.environment == 'staging'
run: |
# Pass the Git SHA so our app knows its version
DEPLOY_URL=$(vercel deploy --yes --token=${{ secrets.VERCEL_TOKEN }} --build-env APP_COMMIT_SHA=${{ github.sha }})
echo "Successfully deployed to: $DEPLOY_URL"
# Explicitly alias our staging domains to this manual build
DOMAINS=("stage.acme.app" "stage.dashboard.acme.app" "stage.api.acme.app")
for DOMAIN in "${DOMAINS[@]}"; do
vercel alias set $DEPLOY_URL $DOMAIN --token=${{ secrets.VERCEL_TOKEN }}
done
# --- PRODUCTION DEPLOYMENT STEPS ---
- name: Deploy to Vercel (Production)
if: github.event.inputs.environment == 'production'
run: vercel deploy --prod --yes --token=${{ secrets.VERCEL_TOKEN }} --build-env APP_COMMIT_SHA=${{ github.sha }}
---
Phase 5: Pro-Tips & Quality of Life
1. Injecting the Commit SHA as an HTTP Header
Ever wonder if your deployment actually went through? You can permanently stamp your Next.js builds with their exact Git commit hash.
In your next.config.js:
// Grab Vercel's automatic PR variable, or the one we passed via CLI
const commitSha = process.env.VERCEL_GIT_COMMIT_SHA || process.env.APP_COMMIT_SHA || 'unknown';
const shortSha = commitSha.length > 7 ? commitSha.substring(0, 7) : commitSha;
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [{ key: 'X-App-Version', value: shortSha }],
},
];
},
};
module.exports = nextConfig;
Now, just open the Network tab in your browser, click the document request, and look for X-App-Version: 1a2b3c4.
2. Sentry Environments
You do not need separate Sentry DSNs for Staging and Production. Leave your NEXT_PUBLIC_SENTRY_DSN as an "All Environments" variable in Vercel. Instead, rely on a variable like NEXT_PUBLIC_VERCEL_ENV to pass the environment tag into your Sentry.init() call. This keeps all your errors in one Sentry project, perfectly filterable by environment.
Top comments (0)