TanStack Start Codegen Specification
[!CAUTION] Historical / TanStack-upstream reference. Vox no longer emits
VoxTanStackRouter.tsx, generatedApp.tsx, orserverFns.ts/createServerFnboilerplate. Current product SSOT for outputs isroutes.manifest.ts+vox-client.ts+ user-owned adapters (seevox-web-stack.md,react-interop-migration-charter-2026.md). Keep this document for upstream TanStack Start mechanics and migration archaeology; treat §8 programmatic route emitter as superseded byroute_manifest.rs+ scaffold.
Status: Historical reference; production path is manifest-first (see truth table in
tanstack-start-implementation-backlog.md).
This document described how Vox compiler syntax was planned to map to TanStack Start output. For current codegen touchpoints read this before touching files in crates/vox-compiler/src/codegen_ts/, but prefer route_manifest / vox_client / scaffold paths over removed tanstack_programmatic_routes / tanstack_start modules.
Grammar note (deferred vs spec examples): Sections below may show layout(...) in virtual app/routes.ts, RouteEntry.layout_name, redirects, or wildcards. The shipped Vox parser today supports string paths, to, optional with loader: / pending:, nested { } children, and block-level not_found: / error: (see tail.rs). Teaching "/app" as layout Shell { }, under Layout, or parser-populated redirect / is_wildcard requires a follow-on language change — until then treat those spec fragments as target design, not copy-paste syntax.
1. What TanStack Start Actually Requires
TanStack Start is a full-stack meta-framework built on:
- TanStack Router (type-safe, code-based or file-based routing)
- Vinxi (Vite-based bundler with SSR split, server/client code separation)
- Server Functions (
createServerFnfrom@tanstack/react-start— typed network RPC) - Nitro (runtime underneath Vinxi — Node.js, Cloudflare, Bun, Deno)
A minimal runnable TanStack Start project requires exactly these files:
src/
├── routes/
│ └── __root.tsx ← Root layout: createRootRoute({head,component})
├── router.tsx ← getRouter() / createRouter({routeTree})
router.gen.ts (generated) ← Auto-generated by TanStack Router Vite plugin
vite.config.ts ← tanstackStart() + viteReact() plugins
package.json ← "dev": "vite dev", "build": "vite build"
tsconfig.json ← jsx: react-jsx, moduleResolution: Bundler
Each route is a separate file (e.g. src/routes/posts.tsx) exporting:
// vox:skip
export const Route = createFileRoute('/posts')({
loader: async () => await getPostsServerFn(),
pendingComponent: LoadingSpinner,
component: PostsComponent,
})
Server functions live co-located with routes (or in src/utils/), using createServerFn:
import { createServerFn } from '@tanstack/react-start'
export const getServerTime = createServerFn({ method: 'GET' })
.handler(async () => Date.now())
Critical: Server functions are the server boundary. In TanStack Start, they replace traditional API routes for data loading. The Vox Axum server still handles DB operations; server functions call Axum internally via HTTP (same VPC / localhost in dev).
2. Decorator Fate: KEEP, REPURPOSE, or RETIRE?
The question from prior sessions was: do we retire legacy decorators, or can we repurpose them?
Answer: Repurpose where TanStack has a direct analog. Retire only where there is no mapping.
| Decorator | Status | TanStack Analog | Action |
|---|---|---|---|
component Name() { ... } | KEEP — canonical | React component | Primary frontend declaration |
@component fn (classic) | RETIRE | — | No TanStack analog. Emit hard error, suggest migration |
@component Name() { ... } | KEEP as sugar | Same as above | Parser desugars to Decl::ReactiveComponent |
routes { "/" to Comp } | KEEP + EXTEND | createFileRoute + virtual file routes | Add loader:, pending:, not_found:, error: fields |
loading: fn Name() | KEEP + REPURPOSE | pendingComponent on route | Now maps to TanStack pendingComponent (already partially done) |
layout: fn Name() | REPURPOSE | Pathless layout route | Repurposed to emit TanStack layout(...) in virtual route config |
not_found: fn Name() | REPURPOSE | notFoundComponent | Applied to __root.tsx Route config |
error_boundary: fn Name() | REPURPOSE | errorComponent | Applied to __root.tsx Route config |
@island Name { prop: T } | KEEP | Client-only React component | Island system unchanged |
@v0 Name | KEEP | Island targeting v0.dev | Emits island stub with v0 download comment |
@query fn | KEEP + FIX | createServerFn({ method: 'GET' }) | Fix HTTP method (was POST, must be GET); fix double-fetch |
@mutation fn | KEEP + FIX | createServerFn({ method: 'POST' }) | Fix handler pattern (was (data) =>, must be ({ data }) =>) |
@server fn | KEEP + FIX | createServerFn({ method: 'POST' }) | Same fix as mutation |
context: Name { } | RETIRE | — | TanStack Router context is passed via router.context. No Vox analog needed. Hard error + docs. |
@hook fn | RETIRE | — | No TanStack analog. React hooks live in @island TS files. Hard error + docs. |
@provider fn | RETIRE | — | Superseded by __root.tsx providers wrapping <Outlet />. Hard error + docs. |
page: "path" { ... } | RETIRE | — | Use routes { } + TanStack static prerendering instead. Hard error + docs. |
Why these choices?
layout:is not retired because TanStack Router's pathless layout routes are a first-class concept. Alayout: fn Shell() { view: <div>...<Outlet/></div> }declaration has a clear 1:1 mapping to a layout file that wraps subroutes.not_found:anderror_boundary:are not retired because they have direct TanStack Router mappings (notFoundComponent,errorComponent) — we just need to wire them to the__root.tsxroute config instead of treating them as standalone page components.context:,@hook,@providerare retired because TanStack Router's own context injection model (router.context) and the island escape hatch (@islandin TypeScript) fully supersede them. They were always React-specific workarounds.page:is retired because TanStack Start has ISR/static prerendering as a framework feature, not a compiler concern.
3. What Vox Currently Emits vs What's Needed
Current State (Broken for TanStack Start)
VoxTanStackRouter.tsx ← Code-based route tree (NOT virtual file routes)
serverFns.ts ← createServerFn().handler(async (data) => fetch(...)) ← WRONG
App.tsx ← SPA mode only
vox-tanstack-query.tsx ← OK
types.ts ← OK
*.tsx ← Path C components as standalone files
Problems:
VoxTanStackRouter.tsxuses programmaticcreateRoute()— but TanStack Start's Vite plugin needs virtual file routes pointing at real.tsxfiles, each exportingRoute = createFileRoute(path)({...})- Server functions wrap another
fetch()call — this is a double network hop. Server functions should contain or invoke the Axum handler logic directly - Missing
app/client.tsx,app/router.tsx,app/ssr.tsx— TanStack Start cannot start without these - Missing
vite.config.ts— no bundle, no dev server - No route loader bindings —
@queryfns are emitted but never wired to routeloader:options
Target State (After This Plan)
dist/
├── __root.tsx ← createRootRoute({ head, component: RootLayout })
├── Home.tsx ← Path C component (existing)
├── index.route.tsx ← createFileRoute('/')({ loader, component: Home })
├── posts.route.tsx ← createFileRoute('/posts')({ loader, component: PostList })
├── Spinner.tsx ← loading: component (existing)
├── serverFns.ts ← FIXED: GET for @query, POST for @mutation, correct handler API
├── vox-tanstack-query.tsx ← OK (unchanged)
├── vox-islands-meta.ts ← OK (unchanged)
└── types.ts ← OK (unchanged)
app/
├── client.tsx ← NEW: StartClient({ router })
├── router.tsx ← NEW: createRouter({ routeTree }) + Register
├── ssr.tsx ← NEW: createStartHandler({ router })
└── routes.ts ← NEW: virtual route config pointing at dist/
vite.config.ts ← NEW: tanstackStart() + viteReact()
package.json ← NEW: vinxi + tanstack deps
tsconfig.json ← NEW: jsx, moduleResolution
4. Vox Syntax → Emitted TypeScript Mapping
4.1 component Name() { ... } (Path C — UNCHANGED)
Source:
// vox:skip
component PostList() {
view:
<div class="posts">
<h1>Posts</h1>
</div>
}
Emitted: PostList.tsx
// vox:skip
import React from "react";
export function PostList(): React.ReactElement {
return (
<div className="posts">
<h1>Posts</h1>
</div>
);
}
No change. Path C component emission is canonical and correct. The only addition is that route files now import from these component files.
4.2 routes { } → Virtual File Routes (REFACTORED)
Source:
// vox:skip
routes {
"/" to Home
"/posts" to PostList with loader: fetchPosts
"/posts/$id" to PostDetail with (loader: fetchPost, pending: Spinner)
not_found: NotFoundPage
error: ErrorFallback
}
Emitted files:
__root.tsx (NEW per-module, replaces VoxTanStackRouter.tsx):
// vox:skip
/// <reference types="vite/client" />
import React from "react";
import type { ReactNode } from "react";
import { createRootRoute, Outlet, HeadContent, Scripts } from "@tanstack/react-router";
import { NotFoundPage } from "./NotFoundPage.tsx";
import { ErrorFallback } from "./ErrorFallback.tsx";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
],
}),
notFoundComponent: NotFoundPage,
errorComponent: ErrorFallback,
component: RootLayout,
});
function RootLayout({ children }: { children?: ReactNode }) {
return (
<html>
<head><HeadContent /></head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}
index.route.tsx (one per routes: entry):
// vox:skip
import { createFileRoute } from "@tanstack/react-router";
import { Home } from "./Home.tsx";
export const Route = createFileRoute("/")({
component: Home,
});
posts.route.tsx (with loader):
// vox:skip
import { createFileRoute } from "@tanstack/react-router";
import { PostList } from "./PostList.tsx";
import { fetchPosts } from "./serverFns";
export const Route = createFileRoute("/posts")({
loader: () => fetchPosts(),
component: PostList,
});
posts-$id.route.tsx (with loader + pending):
// vox:skip
import { createFileRoute } from "@tanstack/react-router";
import { PostDetail } from "./PostDetail.tsx";
import { Spinner } from "./Spinner.tsx";
import { fetchPost } from "./serverFns";
export const Route = createFileRoute("/posts/$id")({
loader: ({ params }) => fetchPost({ data: { id: params.id } }),
pendingComponent: Spinner,
component: PostDetail,
});
app/routes.ts (NEW — virtual route config):
// Generated by Vox — do not edit. Regenerated on vox build.
import { rootRoute, route, index } from "@tanstack/virtual-file-routes";
export const routes = rootRoute("../dist/__root.tsx", [
index("../dist/index.route.tsx"),
route("/posts", "../dist/posts.route.tsx"),
route("/posts/$id", "../dist/posts-$id.route.tsx"),
]);
4.3 loading: fn Name() → pendingComponent (REPURPOSED)
Source:
// vox:skip
loading: fn PageSpinner() {
view: <div class="spinner">Loading…</div>
}
Emitted: PageSpinner.tsx (already works — no change to component emission)
Effect on routes: When a route entry has no explicit pending:, the global loading: component is used as pendingComponent. Preserve this in the manifest + adapter path (historically lived in the retired programmatic route emitter).
4.4 layout: fn Name() → Pathless Layout Route (REPURPOSED)
Source:
// vox:skip
layout: fn AppShell() {
view:
<div class="shell">
<Navbar />
<Outlet />
</div>
}
routes {
"/app/dashboard" to Dashboard under AppShell
"/app/settings" to Settings under AppShell
}
Emitted: AppShell.tsx (pathless layout component):
// vox:skip
import React from "react";
import { Outlet } from "@tanstack/react-router";
import { Navbar } from "./Navbar.tsx";
export function AppShell(): React.ReactElement {
return (
<div className="shell">
<Navbar />
<Outlet />
</div>
);
}
app/routes.ts (layout group in virtual route config):
import { rootRoute, route, index, layout } from "@tanstack/virtual-file-routes";
export const routes = rootRoute("../dist/__root.tsx", [
layout("../dist/AppShell.tsx", [
route("/app/dashboard", "../dist/app-dashboard.route.tsx"),
route("/app/settings", "../dist/app-settings.route.tsx"),
]),
]);
Parser extension required: routes { } entries need a new under: LayoutName clause:
// vox:skip
routes {
"/app/dashboard" to Dashboard under AppShell
}
4.5 @query fn → Server Function GET (FIXED)
Source:
// vox:skip
@query
fn fetchPosts() -> list[Post] {
db.query<Post>("SELECT * FROM posts")
}
Emitted in serverFns.ts (FIXED):
// Generated by Vox for TanStack Start.
import { createServerFn } from "@tanstack/react-start";
const VOX_API = process.env.VOX_API_URL ?? "http://localhost:4000";
export const fetchPosts = createServerFn({ method: "GET" })
.handler(async () => {
const res = await fetch(`${VOX_API}/api/query/fetchPosts`);
if (!res.ok) throw new Error(`fetchPosts failed: ${res.status}`);
return res.json() as Promise<Post[]>;
});
Key fixes from current broken state:
- Method:
'GET'not'POST'for@query - Handler signature: no
dataparameter for 0-arg queries - No double
.inputValidator(data => data)unless parameters exist - Uses
VOX_APIenv var (not hardcoded path)
4.6 @mutation fn → Server Function POST (FIXED)
Source:
// vox:skip
@mutation
fn createPost(title: str, body: str) -> Post {
db.table("posts").insert({ title: title, body: body })
}
Emitted in serverFns.ts (FIXED):
export const createPost = createServerFn({ method: "POST" })
.inputValidator((data: { title: string; body: string }) => data)
.handler(async ({ data }) => {
const res = await fetch(`${VOX_API}/api/mutation/createPost`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`createPost failed: ${res.status}`);
return res.json() as Promise<Post>;
});
4.7 @island Name { } → Island Registry (UNCHANGED)
No changes to island emission. Islands continue to:
- Record in
vox-islands-meta.ts - Get implemented by the user in
islands/src/<Name>/<Name>.tsx - Mount as
<div data-vox-island="Name" data-props='...' />inside Path C views
4.8 Scaffold Files (NEW)
app/client.tsx
// vox:skip
import React from "react";
import ReactDOM from "react-dom/client";
import { StartClient } from "@tanstack/react-start";
import { getRouter } from "./router";
const router = getRouter();
ReactDOM.hydrateRoot(document, <StartClient router={router} />);
app/router.tsx
// vox:skip
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "../src/routeTree.gen";
export function getRouter() {
return createRouter({ routeTree, scrollRestoration: true });
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
Note: routeTree.gen.ts is auto-generated by TanStack Router's Vite plugin from app/routes.ts + the virtual route config. It does not exist until the first vite dev or vite build run. This must be documented clearly.
app/ssr.tsx
// vox:skip
import {
createStartHandler,
defaultStreamHandler,
} from "@tanstack/react-start/server";
import { getRouter } from "./router";
export default createStartHandler({
createRouter: getRouter,
})(defaultStreamHandler);
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
export default defineConfig({
server: { port: 3000 },
resolve: { tsconfigPaths: true },
plugins: [
tanstackStart(),
react(), // react plugin must come AFTER tanstackStart
],
});
package.json
{
"name": "vox-app",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@tanstack/react-router": "^1.114.0",
"@tanstack/react-start": "^1.114.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/virtual-file-routes": "^1.114.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
Note: TanStack Start 1.x no longer requires Vinxi as a separate dependency — it's bundled within @tanstack/react-start.
tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"module": "ESNext",
"target": "ES2022",
"skipLibCheck": true,
"strictNullChecks": true,
"paths": { "~/*": ["./app/*"] }
},
"include": ["app", "dist", "src"]
}
5. Axum ↔ TanStack Start Topology
User Browser
│ HTTP
▼
┌─────────────────────────┐
│ TanStack Start (Nitro) │ :3000
│ SSR React pages │
│ createServerFn RPC │───────────► Vox Axum :4000
│ Static assets │ (GET /api/query/*)
└─────────────────────────┘ (POST /api/mutation/*)
(POST /api/server/*)
(All DB access via Turso)
In development: Two processes. vox run starts Axum. vite dev starts TanStack Start. Server functions call http://localhost:4000.
In production: TanStack Start builds to a Nitro server. Axum deploys separately. Both behind a reverse proxy (nginx/caddy/cloudflare). Server functions call $VOX_API_URL (internal hostname).
This topology is already described in tanstack-web-roadmap.md and the TanStack SSR how-to. This spec merely makes the server function architecture explicit.
6. AST Extensions Required
6.1 RouteEntry — Add loader, pending, under
File: crates/vox-compiler/src/ast/decl/ui.rs
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct RouteEntry { pub path: String, pub component_name: String, pub children: Vec<RouteEntry>, pub redirect: Option<String>, pub is_wildcard: bool, // NEW: /// Name of an @query or @server fn to use as TanStack Router route loader. pub loader: Option<String>, /// Per-route pending/suspense component (overrides module-level loading:). pub pending_component: Option<String>, /// Name of a layout: fn this route is nested under. pub layout_name: Option<String>, pub span: Span, } }
6.2 RoutesDecl — Add not_found, error
File: crates/vox-compiler/src/ast/decl/ui.rs
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct RoutesDecl { pub entries: Vec<RouteEntry>, // NEW: /// Component name for TanStack Router's notFoundComponent (global 404). pub not_found_component: Option<String>, /// Component name for TanStack Router's errorComponent (global error boundary). pub error_component: Option<String>, pub span: Span, } }
6.3 Parser Extension — with (...), under:, not_found:, error:
File: crates/vox-compiler/src/parser/descent/decl/tail.rs (routes parser)
New syntax in routes { } body:
"path" to Component
"path" to Component with loader: fnName
"path" to Component with (loader: fnName, pending: SpinnerName)
"path" to Component under LayoutName
"path" to Component with loader: fnName under LayoutName
not_found: ComponentName
error: ComponentName
7. HIR Changes Required
7.1 HirRoutes — Undeprecate and extend
The HirRoutes wrapper around HirModule::client_routes is currently #[deprecated]. This is wrong — it is the primary carrier for the TanStack route tree. Remove the deprecation.
File: crates/vox-compiler/src/hir/nodes/decl.rs
Remove #[deprecated] from:
HirModule::client_routesHirModule::islandsHirModule::loadings
These are canonical AppContract fields not legacy fields. Update field_ownership_map() accordingly.
7.2 HirRoutes internal struct — Mirror AST extensions
The HirRoutes(pub crate::ast::decl::RoutesDecl) wrapper means HIR changes flow from AST changes automatically for routes. However, the HirLoading, HirLayout, HirNotFound, HirErrorBoundary wrappers need their deprecation removed.
8. Codegen Changes Required
8.1 tanstack_programmatic_routes.rs — superseded
tanstack_programmatic_routes.rsCurrent: Programmatic VoxTanStackRouter.tsx emission was removed. routes.manifest.ts + user-owned TanStack file routes + scaffold.rs / CLI templates carry route metadata. The steps below are historical only:
dist/__root.tsx— root route file withcreateRootRoutedist/*.route.tsx— one file per routes entry withcreateFileRouteapp/routes.ts— virtual route config tree
8.2 emitter.rs — server fn / client SDK
Current: Typed vox-client.ts replaces createServerFn boilerplate; align GET/POST with vox_client.rs and Axum.
8.3 scaffold.rs — Scaffold file emitter
Implemented: crates/vox-compiler/src/codegen_ts/scaffold.rs
Emits: app/client.tsx, app/router.tsx, app/ssr.tsx, app/routes.ts, vite.config.ts, package.json, tsconfig.json
Policy: scaffold files are written once (never overwritten). Gate via --scaffold flag or vox init --web.
8.4 component.rs + reactive.rs — No changes
Path C component emission is correct. Do not touch.
9. CLI Changes Required
9.1 vox build — Add --scaffold flag
When --scaffold is passed (or when app/router.tsx does not exist), emit scaffold files before emitting component/route files.
9.2 vox init --web — Call scaffold emitter
vox init --web should call generate_scaffold_files() + npm install / pnpm install.
10. Documentation Changes Required
docs/src/architecture/tanstack-web-roadmap.md— Update Phase 4 status, link this specdocs/src/architecture/tanstack-web-backlog.md— Add Phase 7 tasks from this specdocs/src/reference/ref-web-model.md— Update route syntax examples withwith (loader:),under:,not_found:,error:docs/src/reference/ref-decorators.mdto describe TanStack mappingdocs/src/reference/ref-decorators.md— Mark retired with migration guide to TanStack router contextdocs/src/reference/ref-decorators.md— Mark retired with migration guide to islandsdocs/src/reference/ref-decorators.md— Mark retired with migration guide to__root.tsxexamples/golden/blog.vox— Full-stack golden example using all new syntax
Related Documents
tanstack-web-roadmap.md— Phase ladder overviewtanstack-web-backlog.md— Checkbox task decompositiontanstack-start-implementation-backlog.md— 200+ task implementation backlog (generated by implementation plan)web-architecture-analysis-2026.md— Historical analysisadr/010-tanstack-web-spine.md— ADR rationale