"TanStack Start Codegen Specification"

TanStack Start Codegen Specification

[!CAUTION] Historical / TanStack-upstream reference. Vox no longer emits VoxTanStackRouter.tsx, generated App.tsx, or serverFns.ts / createServerFn boilerplate. Current product SSOT for outputs is routes.manifest.ts + vox-client.ts + user-owned adapters (see vox-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 by route_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 (createServerFn from @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.

DecoratorStatusTanStack AnalogAction
component Name() { ... }KEEP — canonicalReact componentPrimary frontend declaration
@component fn (classic)RETIRENo TanStack analog. Emit hard error, suggest migration
@component Name() { ... }KEEP as sugarSame as aboveParser desugars to Decl::ReactiveComponent
routes { "/" to Comp }KEEP + EXTENDcreateFileRoute + virtual file routesAdd loader:, pending:, not_found:, error: fields
loading: fn Name()KEEP + REPURPOSEpendingComponent on routeNow maps to TanStack pendingComponent (already partially done)
layout: fn Name()REPURPOSEPathless layout routeRepurposed to emit TanStack layout(...) in virtual route config
not_found: fn Name()REPURPOSEnotFoundComponentApplied to __root.tsx Route config
error_boundary: fn Name()REPURPOSEerrorComponentApplied to __root.tsx Route config
@island Name { prop: T }KEEPClient-only React componentIsland system unchanged
@v0 NameKEEPIsland targeting v0.devEmits island stub with v0 download comment
@query fnKEEP + FIXcreateServerFn({ method: 'GET' })Fix HTTP method (was POST, must be GET); fix double-fetch
@mutation fnKEEP + FIXcreateServerFn({ method: 'POST' })Fix handler pattern (was (data) =>, must be ({ data }) =>)
@server fnKEEP + FIXcreateServerFn({ method: 'POST' })Same fix as mutation
context: Name { }RETIRETanStack Router context is passed via router.context. No Vox analog needed. Hard error + docs.
@hook fnRETIRENo TanStack analog. React hooks live in @island TS files. Hard error + docs.
@provider fnRETIRESuperseded by __root.tsx providers wrapping <Outlet />. Hard error + docs.
page: "path" { ... }RETIREUse 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. A layout: fn Shell() { view: <div>...<Outlet/></div> } declaration has a clear 1:1 mapping to a layout file that wraps subroutes.
  • not_found: and error_boundary: are not retired because they have direct TanStack Router mappings (notFoundComponent, errorComponent) — we just need to wire them to the __root.tsx route config instead of treating them as standalone page components.
  • context:, @hook, @provider are retired because TanStack Router's own context injection model (router.context) and the island escape hatch (@island in 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:

  1. VoxTanStackRouter.tsx uses programmatic createRoute() — but TanStack Start's Vite plugin needs virtual file routes pointing at real .tsx files, each exporting Route = createFileRoute(path)({...})
  2. Server functions wrap another fetch() call — this is a double network hop. Server functions should contain or invoke the Axum handler logic directly
  3. Missing app/client.tsx, app/router.tsx, app/ssr.tsx — TanStack Start cannot start without these
  4. Missing vite.config.ts — no bundle, no dev server
  5. No route loader bindings — @query fns are emitted but never wired to route loader: 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 data parameter for 0-arg queries
  • No double .inputValidator(data => data) unless parameters exist
  • Uses VOX_API env 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:

  1. Record in vox-islands-meta.ts
  2. Get implemented by the user in islands/src/<Name>/<Name>.tsx
  3. 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_routes
  • HirModule::islands
  • HirModule::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

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

  1. dist/__root.tsx — root route file with createRootRoute
  2. dist/*.route.tsx — one file per routes entry with createFileRoute
  3. app/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 spec
  • docs/src/architecture/tanstack-web-backlog.md — Add Phase 7 tasks from this spec
  • docs/src/reference/ref-web-model.md — Update route syntax examples with with (loader:), under:, not_found:, error:
  • docs/src/reference/ref-decorators.md to describe TanStack mapping
  • docs/src/reference/ref-decorators.md — Mark retired with migration guide to TanStack router context
  • docs/src/reference/ref-decorators.md — Mark retired with migration guide to islands
  • docs/src/reference/ref-decorators.md — Mark retired with migration guide to __root.tsx
  • examples/golden/blog.vox — Full-stack golden example using all new syntax