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:
cd modules/dashboard
bun run build
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_API_URL=https://api.example.com
// Read it wherever the spa talks to the backend (its hooks layer).
const apiUrl = import.meta.env.VITE_API_URL;
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
{
  "name": "dashboard",
  "compatibility_date": "2026-07-01",
  "assets": {
    "directory": "./dist/",
    "not_found_handling": "single-page-application"
  }
}
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:
bun run build
bunx wrangler deploy

Pages

Deploy the dist/ folder directly with Wrangler:
bun run build
bunx wrangler pages deploy dist --project-name=dashboard
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
/*    /index.html    200

Custom domain

For a Worker, add a custom-domain route (Cloudflare provisions DNS + TLS automatically):
wrangler.jsonc
{
  "routes": [{ "pattern": "app.example.com", "custom_domain": true }]
}
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
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
Deploy with the CLI:
bun i vercel

# Preview deployment (prints the deployment URL)
vercel

# Production deployment
vercel --prod
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
    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    
    export default defineConfig({
      base: "/<repo>/",
      plugins: [react()],
    });
    
    Match the router to it so client-side routes live under the same prefix:
    createRouter({ basepath: import.meta.env.BASE_URL });
    
  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
name: Deploy spa to GitHub Pages

on:
  push:
    branches: ["main"]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest
      - run: bun install --frozen-lockfile
      - run: bun run build
        working-directory: modules/dashboard
      - name: SPA fallback (404 -> index)
        run: cp modules/dashboard/dist/index.html modules/dashboard/dist/404.html
      - uses: actions/configure-pages@v6
      - uses: actions/upload-pages-artifact@v5
        with:
          path: "modules/dashboard/dist"

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v5
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
{
  "deploy": {
    "install": "bun install",
    "build": "bun run build",
    "runtime": {
      "type": "static",
      "cwd": "./dist",
      "spa": true
    }
  }
}
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
import { serveDir } from "jsr:@std/http/file-server";

Deno.serve(async (req: Request) => {
  const res = await serveDir(req, { fsRoot: "dist", quiet: true });
  // SPA fallback: serve index.html when the asset isn't found.
  if (res.status === 404) {
    return await serveDir(new Request(new URL("/", req.url), req), {
      fsRoot: "dist",
      quiet: true,
    });
  }
  return res;
});
deno install -gArf jsr:@deno/deployctl
bun run build
deployctl deploy --project=dashboard --entrypoint=serve.ts --include=dist --include=serve.ts --prod

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.
HostDeploySPA fallbackNotes
Cloudflare Workerswrangler deploynot_found_handling: "single-page-application" in wrangler.jsonc2026 default for new SPAs.
Cloudflare Pageswrangler pages deploy dist or GitAutomatic, or _redirects /* /index.html 200Simple Git dashboard flow.
Vercelvercel --prod or Gitrewrites in vercel.jsonVite preset auto-detects dist.
GitHub PagesGitHub ActionsCopy index.html404.htmlNeeds Vite base: "/<repo>/" for project sites.
Deno DeployGit or deployctlruntime.spa: true, or serveDir fallbackUse console.deno.com, not Classic.

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:
CORS_ORIGINS=https://app.example.com
CORS_METHODS=GET, POST, PUT, PATCH, DELETE
CORS_HEADERS=Content-Type, Authorization
CORS_CREDENTIALS=true
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.