| name | tanstack-start-spa-prerender |
| description | TanStack Start configured for SPA mode + full prerender on Firebase Hosting — `@tanstack/react-start/plugin/vite`, `prerender.enabled: true`, `ssr: false` per-route opt-out semantics, no server functions, no server loaders. Triggers on: tanstack start spa, tanstack prerender, ssr false, spa mode, prerender route, server functions disabled, server loaders disabled. |
| license | MIT |
Sub-skill of tanstack. Owns the framework-level decision: TanStack Start runs in SPA mode and full-prerenders every route at build time for Firebase Hosting. There is no application server runtime in production. Nitro is driven internally by the tanstackStart Vite plugin — there is no apps/<app>/nitro.config.ts. The static artifact contract (BASE_PATH=/, dist/client/, and the repository-owned Hosting rewrite) lives in the nitro skill — this skill defers there.
When to invoke
- Authoring
apps/<app>/vite.config.ts for the TanStack Start plugin.
- Setting or fixing
prerender.enabled, crawlLinks, routes, or failOnError.
- Opting a route in or out of prerender.
- Diagnosing a "server function not allowed" or
dist/server/server.js produced when it shouldn't be.
- Fielding a request that wants
createServerFn/server loaders — surface the conflict with the static target.
Owns
TanStack Start in SPA mode with full prerender, the Firebase Hosting static target, no server functions, no server loaders, and the prerender-the-shell strategy.
Defers to
tanstack (parent) — version pin and routing.
nitro — for the Firebase Hosting artifact contract: BASE_PATH=/, the dist/client/ deploy directory, and the repository-owned SPA rewrite. There is no apps/<app>/nitro.config.ts — TanStack Start drives Nitro internally.
tanstack-router-routing — for the route files this skill prerenders.
tanstack-router-pwa-deep-links — for what the prerendered shell does at runtime when a deep link is hit offline.
react-19-primitives — for <Suspense> boundaries inside prerendered route components.
jotai + idb — for the runtime state hydration that fills the prerendered shell after JS executes.
Snapmatch stack rules
- Pillar 1 (Storybook-first) means: route files are integration sites that compose Storybook-built components — they are not the place to construct UI.
- Pillar 3 (remote-first, locally resilient state) means prerender only the shell of every route:
prerender never contacts Auth or Firestore. The generated local state hydrates from IDB through one
root promise; only in the browser and after local render may the enabled sync host start anonymous
Auth, validate server-confirmed Firestore snapshots, commit them to IDB, and reconcile Jotai.
Routes/components never call Firebase directly. Production domain sync remains disabled; App
Check is not implemented.
- Pillar 4 (CLI-gate-first) means:
prerender.failOnError: true (default in current Start) — a failed prerender fails bun run build and therefore bun run check.
- TanStack Start in SPA + prerender mode emits the deployable artifact at
dist/client/. A dist/server/server.js file ALSO appears as part of the prerender pipeline (it's used internally to render the shell), but ONLY dist/client/ ships to Firebase Hosting. Repository-owned Hosting configuration rewrites SPA navigations to index.html; any 404.html or .nojekyll compatibility files are not the production routing authority.
- Set
srcDirectory: "app" in tanstackStart({...}) — the plugin defaults to src/. Without this, the plugin can't resolve the router entry.
- Set
spa: { prerender: { outputPath: "/index" } } so the prerender writes index.html (not the default _shell.html) — Firebase Hosting serves it as the directory default and SPA rewrite destination, and Workbox's navigateFallback: "/index.html" matches.
- The router factory exports
getRouter() (not createRouter()); the plugin's auto-generated routeTree.gen.ts declares Register { router: Awaited<ReturnType<typeof getRouter>> } against that name.
- React Compiler is NOT wired through
tanstackStart({ react: { babel: ... } }) — that option does not exist. To enable, install @vitejs/plugin-react separately and pass babel.plugins: [["babel-plugin-react-compiler", { target: "19" }]] to it (deconfliction with the framework's own React handling required).
- The package is
@tanstack/react-start (not @tanstack/start); the Vite plugin is @tanstack/react-start/plugin/vite. The legacy app.config.ts and Vinxi setup is gone.
Patterns
vite.config.ts — SPA + full prerender
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
tailwindcss(),
tanstackStart({
spa: { enabled: true },
prerender: {
enabled: true,
crawlLinks: true,
autoSubfolderIndex: true,
failOnError: true,
},
}),
],
});
The tanstackStart plugin invokes Nitro under the hood — there is no apps/<app>/nitro.config.ts. Firebase Hosting artifact and rewrite concerns live in the nitro skill and repository root configuration. No app.config.ts. No vinxi.
Per-route opt-out of SSR (still prerenders the shell)
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard")({
ssr: false,
component: Dashboard,
});
function Dashboard() {
return <main>{/* … */}</main>;
}
In Snapmatch every route effectively behaves this way—the shell is static. The client fills local
state from IDB; shared state later reaches the route only through IDB/Jotai reconciliation after the
browser sync host starts. ssr: false makes that explicit per route when needed.
Build output (what to expect)
apps/example-game/dist/
└── client/
├── index.html # /
├── 404/index.html # /404 — prerendered not-found route
├── games/sample/index.html # /games/sample
├── games/sample/level-1/index.html
└── assets/ # hashed JS + CSS chunks
Only dist/client/ ships. If dist/server/ exists with a server.js, the build slipped a server function or server-only import — find and remove it before deploying. Firebase Hosting's repository-owned rewrite sends unmatched SPA routes to index.html.
Workspace install
bun add @tanstack/react-router @tanstack/react-start react@^19 react-dom@^19
bun add -D @tanstack/react-start vite@^7 @vitejs/plugin-react @tailwindcss/vite
The plugin import path is @tanstack/react-start/plugin/vite, not @tanstack/router-vite-plugin (legacy).
Env access inside a route
import { createFileRoute } from "@tanstack/react-router";
import { env } from "~/env";
export const Route = createFileRoute("/")({
component: () => <span>Build sha: {env.VITE_BUILD_SHA}</span>,
});
env.VITE_* is build-time validated by @t3-oss/env-core (see t3-env) — Firebase Hosting does not inject runtime environment values into the static bundle.
Anti-patterns
- Don't use
createServerFn / createServerOnlyFn — server functions need an application server runtime. There is none. Move genuinely static work to build time; shared runtime state belongs behind the browser state boundary, not in a route or direct Firestore call.
- Don't use server loaders — same reason. Use
loader-as-a-pure-build-time-fetch only when the data is genuinely static (e.g., a content collection); never read user state at build time.
- Don't import from
@tanstack/start — it's @tanstack/react-start now. Same for @tanstack/start-static-server-functions.
- Don't author
app.config.ts — gone in current TanStack Start. Configuration lives in vite.config.ts via the tanstackStart plugin.
- Don't use Vinxi — gone. The plugin uses Vite + Nitro directly.
- Don't set
prerender.failOnError: false to push a build through — the missing route is the bug. Add it to routes, fix the loader that throws, or remove the unreachable link.
- Don't import
Meta / Scripts from @tanstack/start — they come from @tanstack/react-router now (HeadContent, Scripts).
- Don't introduce
getRequest / getRequestHeader / setResponseHeaders from @tanstack/react-start/server — these are runtime-server-only and break SSG.
- Don't deploy
dist/server/ — if it exists, the build is wrong. Only dist/client/ is the Firebase Hosting artifact (see nitro).
Triggers on
tanstack start spa, tanstack prerender, ssr false, spa mode, prerender route, server functions disabled, server loaders disabled