> ## Documentation Index
> Fetch the complete documentation index at: https://docs.talosjs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy a spa

> Build a spa module to static assets with Vite and host it on Cloudflare, Vercel, GitHub Pages, or Deno Deploy — with the SPA fallback and CORS wiring each one needs.

A [spa module](/spa/overview) 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](/spa/routing)), 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](/deployment/api) across origins (CORS).

## Build the assets

Each spa module ships its own `dev`, `build`, and `preview` Vite scripts. Build from the module directory:

```bash theme={null}
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:

```bash .env.production theme={null}
VITE_API_URL=https://api.example.com
```

```typescript theme={null}
// Read it wherever the spa talks to the backend (its hooks layer).
const apiUrl = import.meta.env.VITE_API_URL;
```

<Warning>
  `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.
</Warning>

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](#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.

### Workers static assets (recommended)

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:

```jsonc wrangler.jsonc theme={null}
{
  "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:

```bash theme={null}
bun run build
bunx wrangler deploy
```

### Pages

Deploy the `dist/` folder directly with Wrangler:

```bash theme={null}
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:

```text public/_redirects theme={null}
/*    /index.html    200
```

### Custom domain

For a Worker, add a custom-domain route (Cloudflare provisions DNS + TLS automatically):

```jsonc wrangler.jsonc theme={null}
{
  "routes": [{ "pattern": "app.example.com", "custom_domain": true }]
}
```

For Pages, add the domain under the project's **Custom domains** tab.

<Note>
  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.
</Note>

## 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`:

```json vercel.json theme={null}
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
```

Deploy with the CLI:

```bash theme={null}
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.

<Note>
  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 `/`.
</Note>

## 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:

   ```typescript vite.config.ts theme={null}
   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:

   ```typescript theme={null}
   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:

```yaml .github/workflows/deploy-spa.yml theme={null}
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
```

<Note>
  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.
</Note>

## Deno Deploy

Use the current **Deno Deploy** at [console.deno.com](https://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:

```json deno.json theme={null}
{
  "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:

```typescript serve.ts theme={null}
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;
});
```

```bash theme={null}
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.

| Host                   | Deploy                              | SPA fallback                                                        | Notes                                            |
| ---------------------- | ----------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------ |
| **Cloudflare Workers** | `wrangler deploy`                   | `not_found_handling: "single-page-application"` in `wrangler.jsonc` | 2026 default for new SPAs.                       |
| **Cloudflare Pages**   | `wrangler pages deploy dist` or Git | Automatic, or `_redirects` `/* /index.html 200`                     | Simple Git dashboard flow.                       |
| **Vercel**             | `vercel --prod` or Git              | `rewrites` in `vercel.json`                                         | Vite preset auto-detects `dist`.                 |
| **GitHub Pages**       | GitHub Actions                      | Copy `index.html` → `404.html`                                      | Needs Vite `base: "/<repo>/"` for project sites. |
| **Deno Deploy**        | Git or `deployctl`                  | `runtime.spa: true`, or `serveDir` fallback                         | Use `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:

```bash theme={null}
CORS_ORIGINS=https://app.example.com
CORS_METHODS=GET, POST, PUT, PATCH, DELETE
CORS_HEADERS=Content-Type, Authorization
CORS_CREDENTIALS=true
```

<Warning>
  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](/security/cors).
</Warning>

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.

## Related

* [Spa overview](/spa/overview) — what a spa module is and how it's built.
* [Deploy an API](/deployment/api) — ship the backend the spa calls.
* [CORS](/security/cors) — the cross-origin policy the API applies to spa requests.
* [Data fetching](/spa/data-fetching) — where the spa reads `VITE_API_URL` to call the API.
* [spa:create](/cli/commands/spa-create) — scaffold a spa module.
