Skip to main content
A spa module is a Vite + React single-page app. It carries no server: bun run build compiles it to a folder of static assets (dist/ by default) that any static host — or CDN — can serve. Because routing is client-side (TanStack Router), every host needs one thing in common: a SPA fallback so that a deep link like /dashboard serves index.html instead of a 404, letting the router take over in the browser. This page covers building the assets, the fallback and configuration each host needs, and how the deployed spa talks to your API across origins (CORS).

Build the assets

Each spa module ships its own dev, build, and preview Vite scripts. Build from the module directory:
Vite writes the production bundle to dist/ (hashed JS/CSS, plus everything from public/ copied verbatim). That directory is what you upload to any of the hosts below.

Point the spa at your API

The spa runs in the browser on its own origin and calls your API on another, so it needs the API’s URL at build time. Vite exposes variables prefixed with VITE_ on import.meta.env, so set the base URL there and read it in your data-fetching hooks:
.env.production
VITE_* values are inlined into the built bundle and shipped to the browser — they are public. Never put secrets (API keys, tokens) in a VITE_ variable; only public, per-environment values like the API base URL.
Because the spa and API are on different origins, the API must allow the spa’s origin in its CORS policy — set CORS_ORIGINS on the API to the deployed spa URL. See Configure CORS on the API at the end of this page.

Cloudflare

Cloudflare serves static SPAs two ways. As of 2026 Cloudflare steers new projects to Workers static assets (the promoted default for React); Pages remains fully supported and is the simpler choice for a dashboard-driven Git flow. Both are free for static-asset requests. Deploy with Cloudflare’s wrangler CLI. Add a wrangler.jsonc next to the spa that points at the build output and turns on single-page-application fallback:
wrangler.jsonc
not_found_handling: "single-page-application" returns index.html with a 200 for any path that doesn’t match a built asset — exactly what TanStack Router needs. Build, then deploy:

Pages

Deploy the dist/ folder directly with Wrangler:
Or connect the Git repo in the Cloudflare dashboard and set Build command to bun run build and Build output directory to dist. Pages auto-detects an SPA (when there’s no top-level 404.html) and serves the root index.html for unmatched paths. To make the fallback explicit, add a _redirects file to public/ so it ships in the build:
public/_redirects

Custom domain

For a Worker, add a custom-domain route (Cloudflare provisions DNS + TLS automatically):
wrangler.jsonc
For Pages, add the domain under the project’s Custom domains tab.
Workers and Pages use different config keys — Workers uses the assets block (directory + not_found_handling); Pages uses pages_build_output_dir and the _redirects file. Don’t mix them.

Vercel

Vercel auto-detects the Vite preset, so the build command and output directory (dist) are inferred — you don’t configure them. The one thing the preset does not add is the SPA fallback, so add a vercel.json at the repo root that rewrites every path to index.html:
vercel.json
Deploy with the CLI:
The first vercel run links the local directory to a Vercel project. Alternatively, connect the repo in the dashboard: Vercel then deploys automatically on every push, promoting the production branch (main by default) to production and giving every other branch and PR its own preview URL.
Use rewrites, not the legacy routes array, for the SPA fallback — and don’t mix the two in one vercel.json. If you also enable cleanUrls, change the rewrite destination to /.

GitHub Pages

GitHub Pages serves a project site under a sub-path — https://<user>.github.io/<repo>/ — so two things need adjusting before it works:
  1. Set Vite’s base to the repo name so asset URLs resolve under the sub-path:
    vite.config.ts
    Match the router to it so client-side routes live under the same prefix:
  2. SPA fallback: Pages has no server rewrite, but it serves 404.html for any unmatched path. Copy the built index.html to 404.html so the app boots and the router handles the route.
Deploy via GitHub Actions. In the repo’s Settings → Pages, set Source to GitHub Actions, then add:
.github/workflows/deploy-spa.yml
A user/organization site served at the domain root (https://<user>.github.io/) needs no base — use / (or omit it). The sub-path base is only for project pages.

Deno Deploy

Use the current Deno Deploy at console.deno.com. Deno Deploy Classic (dash.deno.com) is deprecated and shuts down on July 20, 2026, so don’t start there. The new platform has first-class static-site support with SPA fallback built in — no serve entrypoint needed. Connect the Git repo and add a deploy block to deno.json so builds are reproducible:
deno.json
runtime.spa: true serves index.html for paths that don’t match a static file, which is the client-side-routing fallback. Vite isn’t in Deno Deploy’s auto-detected framework list, so set the install/build/runtime values yourself as above.

With deployctl (CLI alternative)

If you deploy from the CLI instead of Git, the bundled file server does not fall back to index.html, so ship a tiny entrypoint that does:
serve.ts

Choosing a host

All four serve the same dist/ bundle. They differ only in how you configure the SPA fallback and where the build runs.

Configure CORS on the API

A deployed spa loads from its own origin and calls the API on another, so the browser enforces CORS. The spa side needs nothing beyond the correct API URL (VITE_API_URL); the API must be told to allow the spa’s origin. Set this on the API, not the spa:
Use an explicit CORS_ORIGINS allowlist that names the deployed spa’s origin — never * for a production API, and never * together with CORS_CREDENTIALS=true (browsers reject a credentialed response whose allow-origin is the wildcard). The full policy reference is in the CORS guide.
If auth uses cookies, also set the cookies SameSite=None; Secure so they’re sent on cross-origin requests, and keep CORS_CREDENTIALS=true.

Checklist

  • Build with bun run build; upload the dist/ folder.
  • Configure the SPA fallback for your host — every client-side route depends on it.
  • Set VITE_API_URL (and other VITE_* values) at build time; keep secrets out of them.
  • On GitHub Pages, set Vite base and the router basepath for project sites.
  • Add the spa’s origin to the API’s CORS_ORIGINS allowlist.
  • Spa overview — what a spa module is and how it’s built.
  • Deploy an API — ship the backend the spa calls.
  • CORS — the cross-origin policy the API applies to spa requests.
  • Data fetching — where the spa reads VITE_API_URL to call the API.
  • spa:create — scaffold a spa module.