2026-04-02 | PreviewProof Team
Preview-Ready Next.js + Separate Backend: Configuration That Survives Real Use
Vercel handles Next.js previews beautifully when the entire application is Next.js — frontend, API routes, edge functions, all in the same repo, all built and deployed together. For that shape, you don’t need this post.
This post is for the architecture where Next.js is the frontend and a separate API backend (Node, Python, Go, Rails) lives behind it. As soon as you make that split, the Vercel preview model stops being enough. The Next.js preview deploys fine, but the API URL it points at is either production, a stale shared staging environment, or nothing at all. The branch you actually want to test isn’t testable.
There are two viable architectures for previews of split-stack Next.js apps. Both are common. The configuration is different for each.
Architecture 1: Vercel frontend, externally hosted backend
In this shape, the Next.js frontend stays on Vercel for previews, and the backend runs somewhere else (a containerized preview platform, an existing staging environment, or per-PR backend deployments). The frontend’s preview URL is dynamic; it needs to discover or be told the backend’s preview URL.
The key decision is build-time vs. runtime API URL injection.
Build-time injection is the Vercel default. You set NEXT_PUBLIC_API_URL in Vercel’s preview environment settings, Next.js bakes it into the bundle at build time, and every preview hits the same hardcoded URL. This works exactly when you have a single shared backend across all previews — which is rarely what you actually want.
Runtime injection is what you need when each PR has its own backend preview. The frontend reads the API URL from a runtime source (window config, Next.js middleware injection, or an API route that proxies). Here’s a working pattern using a /config API route as the bridge:
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export async function GET() { return NextResponse.json({ apiUrl: process.env.API_URL, // server-side env, runtime environment: process.env.VERCEL_ENV, previewSha: process.env.VERCEL_GIT_COMMIT_SHA, });}The frontend fetches /api/config at boot and uses the returned apiUrl for all subsequent backend calls. The Vercel preview’s runtime API_URL env var is set per-deployment by your CI script — typically computed from the PR number — so each preview points to its own backend. This is more code than build-time injection but it’s the only way to keep frontend and backend versions in sync per-PR. See environment variables for ephemeral previews for the broader build-vs-runtime discussion.
Architecture 2: Self-hosted, both sides in one Compose stack
The other shape is fully self-hosted previews — both Next.js frontend and the backend run as services in the same Compose stack, behind the same preview hostname. This sidesteps the API URL discovery problem entirely (the frontend talks to the backend at a stable internal hostname like http://api:3001) but requires Next.js to run as a containerized service rather than on Vercel. The preview-shaped docker-compose.yml looks like:
services: web: image: ghcr.io/myorg/web:${GIT_SHA} command: node server.js environment: NODE_ENV: production INTERNAL_API_URL: http://api:3001 # server-side calls NEXT_PUBLIC_API_URL: https://${PREVIEW_HOST}/api # browser calls go through proxy ports: - "3000:3000" api: image: ghcr.io/myorg/api:${GIT_SHA} command: node dist/server.js environment: DATABASE_URL: postgres://postgres:postgres@db:5432/app_preview CORS_ORIGIN: https://${PREVIEW_HOST} db: image: postgres:16 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: app_previewNotice the two API URLs. INTERNAL_API_URL is for Next.js server-side calls (getServerSideProps, server components, route handlers proxying to the backend). NEXT_PUBLIC_API_URL is what the browser sees. The browser can’t reach http://api:3001 — it’s inside the container network — so browser calls go through a proxy at /api on the public preview hostname.
A reverse-proxy route in Next.js handles this:
import { NextRequest, NextResponse } from "next/server";
const INTERNAL = process.env.INTERNAL_API_URL!;
export async function GET(req: NextRequest, { params }: { params: { path: string[] } }) { const url = `${INTERNAL}/${params.path.join("/")}${req.nextUrl.search}`; const res = await fetch(url, { headers: req.headers }); return new NextResponse(res.body, { status: res.status, headers: res.headers });}// Same pattern for POST, PUT, DELETEThis pattern keeps CORS simple (everything is same-origin from the browser’s view), keeps the backend reachable internally, and survives any preview hostname.
Keeping frontend and backend in sync
The hardest problem in split-stack previews is version coordination. If frontend PR #420 expects API behavior from backend PR #515, and the preview spins up only frontend changes against a stale backend, the preview is misleading.
Two approaches:
Same-PR coordination (monorepo). Both frontend and backend live in the same repo. A single PR changes both. The preview platform builds and deploys both services from the same commit. Simplest. See monorepo vs polyrepo previews for the broader argument.
Cross-repo coordination. Frontend and backend are separate repos. You need a convention — either explicit (a small manifest in the frontend repo, e.g. preview-overrides.yaml, declaring “this PR depends on backend PR #515”) or implicit (frontend always pulls latest backend main, accepting that frontend PRs sometimes break against main).
Most teams underestimate how often this matters until they ship a regression caused by mismatched versions. Worth solving once.
CORS in dynamic-URL environments
Backend CORS allowlists need to include the dynamic preview hostname. The right pattern is regex-based:
import cors from "cors";
const allowedOrigins = [ /^https:\/\/[a-z0-9-]+\.previews\.example\.com$/, /^http:\/\/localhost:\d+$/,];
app.use(cors({ origin: (origin, cb) => { if (!origin) return cb(null, true); if (allowedOrigins.some((re) => re.test(origin))) return cb(null, true); return cb(new Error("Not allowed by CORS")); }, credentials: true,}));Wildcarding *.previews.example.com keeps the backend CORS-friendly without ever needing to update the allowlist as new PRs open. The same pattern applies to OAuth callbacks — see OAuth callback URLs in ephemeral previews.
Seeding the backend’s database
Seed scripts live with the backend. Frontend tests against whatever data the backend has. Two patterns to avoid:
- Don’t seed from the frontend. Tempting in monorepos. Always wrong — the frontend shouldn’t know about the backend’s schema.
- Don’t expect every frontend PR to need fresh seed data. The backend’s seed script is its responsibility; if frontend tests need specific data states, the backend exposes a
/test/setupendpoint guarded by a preview-only token. Cleaner than bespoke per-PR seeds.
Env template
# .env.preview.example for the frontendNEXT_PUBLIC_API_URL= # injected runtime, e.g. https://pr-123.previews.example.com/apiINTERNAL_API_URL= # injected runtime, e.g. http://api:3001NEXTAUTH_URL= # same as the preview hostnameNEXTAUTH_SECRET= # generated per preview
# .env.preview.example for the backendDATABASE_URL= # injected by platformCORS_ORIGIN= # https://pr-123.previews.example.comJWT_SECRET= # generated per previewWhich architecture to pick
If your frontend is the lion’s share of the work and backend changes are infrequent, Vercel-frontend-with-external-backend keeps the Vercel ergonomics for frontend reviewers. If frontend and backend change with similar frequency, all-self-hosted previews keep everything aligned and remove an entire category of “preview deployed but doesn’t actually represent the branch” failures. Either architecture works with whatever orchestration tool you choose, including your own CI scripts.
If you’d rather not script the per-PR coordination, PreviewProof supports both architectures — the Compose-shaped self-hosted version directly, and the Vercel-plus-external-backend version by orchestrating the backend half so it stays in sync with the frontend’s preview commit. It’s a fit for teams whose review process involves stakeholders other than engineers, or where per-PR sign-off needs to be tracked rather than assumed.