Skip to content

Deploying to Vercel

Guide to deploying the TanStack Start frontend on Vercel. All changes below were verified locally on the repository (production build + dev server) before writing.

Repository: GLOBAL-SIM-GROUP/Global-Sim-Group-frontend (main branch) Stack: TanStack Start 1.168 · Vite 8 · React 19 · Nitro 3 (already in devDependencies)

Vercel hosts only the frontend (SSR rendering). The NestJS backend (/api/v1) and PostgreSQL stay on their current host (dev.sim.strife-cyber.org in dev, to be replaced by the production instance).

Browser
│ same origin (the browser only calls the Vercel domain)
▼
Vercel ──> Node Function (TanStack Start SSR, via Nitro)
│
└── /api/* ──rewrite──> NestJS backend (https://…/api/v1)

Two blockers to know:

  1. Vercel cannot run server.mjs as-is (custom node:http host meant for Docker). The official 2026 path is the Nitro plugin (nitro/vite): Vercel detects TanStack Start / Nitro and automatically generates a Vercel Function (Fluid Compute) that performs SSR.
  2. The root server.mjs file collides with Nitro: Nitro picks it up as its server entry and fails (MISSING_EXPORT). It must be renamed. Details in §4.2.

The /api/* relay (done by server.mjs under Docker) is replaced by a Vercel rewrite: same relative calls (/api/v1), no CORS to manage.

  • A Vercel account + access to the GitHub repo (already pushed).
  • Node.js 22 on Vercel (aligned with the Dockerfile node:22-alpine). Set in Project Settings → Node.js Version (or via engines in package.json, optional).
  • nitro is already installed (devDependencies, v3.0.260610-beta) — nothing to install.

Three changes, listed with their before/after state. The diff examples assume you are at the frontend root (Code/Frontend/global-sim-group).

3.1 — vite.config.ts: register the Nitro plugin

Section titled “3.1 — vite.config.ts: register the Nitro plugin”

Add the import and plugin, right after tanstackStart():

import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact, { reactCompilerPreset } from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { nitro } from "nitro/vite";
const config = defineConfig({
...
plugins: [
// devtools-vite must stay the first array entry.
devtools(),
tailwindcss(),
tanstackStart(),
nitro(),
viteReact(),
babel({ presets: [reactCompilerPreset()] }),
],
});

3.2 — Rename server.mjs → prod-server.mjs

Section titled “3.2 — Rename server.mjs → prod-server.mjs”

Why. Nitro auto-detects any root server.* file as its server entry. server.mjs (the Docker host) exports nothing → the build fails:

[MISSING_EXPORT] "default" is not exported by "server.mjs".
at #nitro/virtual/routing:14:8

Verified: with server.mjs renamed, the Nitro build passes (.output/nitro.json generated, SSR embedded) and the dev server answers HTTP 200.

Action:

Fenêtre de terminal
git mv server.mjs prod-server.mjs

Then update the two references in the Dockerfile:

COPY server.mjs ./server.mjs
COPY prod-server.mjs ./prod-server.mjs
...
CMD ["node", "server.mjs"]
CMD ["node", "prod-server.mjs"]

The file still imports ./dist/server/server.js (still produced by vite build), so the Docker runtime is unchanged.

3.3 — vercel.json: /api/* relay + anti-cache

Section titled “3.3 — vercel.json: /api/* relay + anti-cache”

Create vercel.json at the frontend root:

{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "tanstack-start",
"rewrites": [
{
"source": "/api/:path*",
"destination": "https://dev.sim.strife-cyber.org/api/:path*"
}
],
"headers": [
{
"source": "/api/:path*",
"headers": [
{ "key": "x-vercel-enable-rewrite-caching", "value": "0" }
]
}
]
}
  • destination: replace dev.sim.strife-cyber.org with the production backend URL (the dev instance is not perennial).
  • x-vercel-enable-rewrite-caching: 0: recent Vercel projects honor backend cache-control headers on external rewrites by default. But responses from a JWT API (login, refresh, per-user data) must never be cached at the CDN. This header disables caching on /api/*.
  • framework: "tanstack-start": makes the build deterministic even if auto-detection fails. Can be removed once the import is confirmed.
  • Vercel external rewrites relay all verbs (POST/PUT/DELETE with body) — login/refresh works.
  • Limit to know: request/response body ≤ 4.5 MB for a Function (heavy uploads will need Vercel Blob, cf. §9).

Alternative to the rewrite: absolute URL + CORS

Section titled “Alternative to the rewrite: absolute URL + CORS”

If you prefer the browser calling the backend directly:

VITE_API_URL=https://my-backend.example.com/api/v1

…and add the Vercel domain to the NestJS backend CORS list (backend CORS currently rejects localhost:*; it would accept the Vercel domain). Downside: the backend becomes directly reachable (it already is) and CORS config must be maintained. The rewrite is recommended.

Variable Usage On Vercel
VITE_API_URL API URL, inlined at build. Default /api/v1 (same origin) No value required if the rewrite is kept. The default works.
API_TARGET /api/* relay target in server.mjs (Docker) Not used on Vercel (the rewrite replaces it). Can stay absent.
VITE_APP_TITLE App title, optional Optional

No secret is required on Vercel. Reminder: never put a secret in a VITE_ variable — it is public in the browser bundle.

Variables are set in Project Settings → Environment Variables (Production / Preview / Development), then a redeploy is needed. VITE_API_URL being inlined at build, changing it means rebuilding.

  1. vercel.com/new → Import the GLOBAL-SIM-GROUP/Global-Sim-Group-frontend repo.
  2. Framework Preset: TanStack Start (auto-detected) — install and build commands are provided by default (npm install / npm run build), no settings needed.
  3. Node.js Version: 22.x in project settings.
  4. Deploy. Vercel reads .vercel/output (Build Output API) generated by Nitro with its vercel preset: SSR → Node Function, assets → static.

The official Vercel guide: Deploy a TanStack Start app to Vercel.

Fenêtre de terminal
npm run build # OK: dist/ (client + server) + .output/ (Nitro)

Test the exact Vercel build locally (CLI):

Fenêtre de terminal
npx vercel build # → generates .vercel/output with the vercel preset
npx vercel dev # → serves locally like production
  1. GET / → SSR render (full HTML, not an empty shell); GET /login → 200.
  2. POST /api/v1/auth/login with the demo account → validates the rewrite (POST verb + body + Authorization headers relayed, no CORS).
  3. Navigate between authenticated routes (preload/router); refresh a deep page (SSR at the right route, no 404).
  4. Check deployment logs (Dashboard → Deployments → logs) for any 500/404.
  • Git: every push to main → production deployment; every PR → preview with a dedicated URL. vercel.json rewrites apply to previews too.
  • CLI: npx vercel (preview), npx vercel --prod (production), npx vercel promote <url> (promote a preview).
  • Rollback: Dashboard → Deployments → ⋮ → Promote previous / Redeploy.

8. Impact on the existing Docker deployment

Section titled “8. Impact on the existing Docker deployment”
  • The Dockerfile keeps working after §3.2 (both references updated). docker compose up --build → still http://localhost:3004.

  • npm run build additionally produces .output/ (Nitro bundle) → local builds slightly slower. .output is already in .gitignore (line 11).

  • Advanced variant: only enable nitro() on Vercel to keep fast Docker builds:

    vite.config.ts
    const isVercel = process.env.VERCEL === "1";
    // ... plugins: [...tanstackStart(), ...(isVercel ? [nitro()] : []), viteReact()]

    The configuration documented here (Nitro always on) is the simplest and was tested with both the Docker build and dev. The conditional variant is fine if you want to preserve local build speed.

Symptom Cause Fix
[MISSING_EXPORT] "default" is not exported by "server.mjs" Nitro takes root server.mjs as its entry Rename to prod-server.mjs (§3.2)
404 routes on the deploy nitro() missing from vite.config.ts (static build without SSR) Add nitro() and redeploy
POST /login fails / network error Incorrect /api rewrite or CDN cache on /api Check vercel.json (§3.3), especially x-vercel-enable-rewrite-caching: 0
CORS error VITE_API_URL absolute without backend CORS Go back to the rewrite, or add the Vercel domain to NestJS CORS
413 FUNCTION_PAYLOAD_TOO_LARGE 4.5 MB Function limit Upload large files via Vercel Blob (not through the /api proxy)
Build OK locally, fails on Vercel Different Node version Pin Node 22.x in Project Settings
/api/* returns app HTML Rewrite shadowed by the SSR route More specific rewrite source, or framework present in vercel.json
Port 3000 captured by WSL locally Windows/WSL environment Use the configured dev port (3001) — unrelated to Vercel
File Change
vite.config.ts + nitro() import and plugin
server.mjs renamed → prod-server.mjs
Dockerfile COPY + CMD pointed at prod-server.mjs
vercel.json created: /api/* rewrite + anti-cache
README.md server.mjs → prod-server.mjs mentions

Changes verified locally: npm run build passes (dist + .output/nitro.json, node-server preset locally, vercel on Vercel) · npm run dev answers HTTP 200 · server.mjs present + nitro() → confirmed MISSING_EXPORT error (hence the rename).