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(mainbranch) Stack: TanStack Start 1.168 · Vite 8 · React 19 · Nitro 3 (already in devDependencies)
1. Overview
Section titled “1. Overview”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:
- Vercel cannot run
server.mjsas-is (customnode:httphost 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. - The root
server.mjsfile 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.
2. Prerequisites
Section titled “2. Prerequisites”- A Vercel account + access to the GitHub repo (already pushed).
- Node.js 22 on Vercel (aligned with the
Dockerfilenode:22-alpine). Set in Project Settings → Node.js Version (or viaenginesinpackage.json, optional). nitrois already installed (devDependencies, v3.0.260610-beta) — nothing to install.
3. Code changes
Section titled “3. Code changes”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:8Verified: with server.mjs renamed, the Nitro build passes
(.output/nitro.json generated, SSR embedded) and the dev server answers
HTTP 200.
Action:
git mv server.mjs prod-server.mjsThen update the two references in the Dockerfile:
COPY server.mjs ./server.mjsCOPY 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: replacedev.sim.strife-cyber.orgwith the production backend URL (the dev instance is not perennial).x-vercel-enable-rewrite-caching: 0: recent Vercel projects honor backendcache-controlheaders 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.
4. Environment variables
Section titled “4. Environment variables”| 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.
5. Importing the project on Vercel
Section titled “5. Importing the project on Vercel”- vercel.com/new → Import the
GLOBAL-SIM-GROUP/Global-Sim-Group-frontendrepo. - Framework Preset:
TanStack Start(auto-detected) — install and build commands are provided by default (npm install/npm run build), no settings needed. - Node.js Version:
22.xin project settings. - Deploy. Vercel reads
.vercel/output(Build Output API) generated by Nitro with itsvercelpreset: SSR → Node Function, assets → static.
The official Vercel guide: Deploy a TanStack Start app to Vercel.
6. Verification
Section titled “6. Verification”Before pushing
Section titled “Before pushing”npm run build # OK: dist/ (client + server) + .output/ (Nitro)Test the exact Vercel build locally (CLI):
npx vercel build # → generates .vercel/output with the vercel presetnpx vercel dev # → serves locally like productionAfter deployment
Section titled “After deployment”GET /→ SSR render (full HTML, not an empty shell);GET /login→ 200.POST /api/v1/auth/loginwith the demo account → validates the rewrite (POST verb + body +Authorizationheaders relayed, no CORS).- Navigate between authenticated routes (preload/router); refresh a deep page (SSR at the right route, no 404).
- Check deployment logs (Dashboard → Deployments → logs) for any 500/404.
7. Continuous deployments
Section titled “7. Continuous deployments”- Git: every push to
main→ production deployment; every PR → preview with a dedicated URL.vercel.jsonrewrites 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
Dockerfilekeeps working after §3.2 (both references updated).docker compose up --build→ stillhttp://localhost:3004. -
npm run buildadditionally produces.output/(Nitro bundle) → local builds slightly slower..outputis 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.
9. Troubleshooting
Section titled “9. Troubleshooting”| 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 |
10. Summary of touched files
Section titled “10. Summary of touched files”| 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).
