Compare commits

...
Author SHA1 Message Date
Chris Tate 8ada149395 fix(tanstack-start): preserve encoded percent params
- Preserve encoded percent signs through pathname normalization.
- Cover dynamic, splat, and static-path round trips.
2026-09-08 15:25:08 -05:00
Chris Tate 70d7383394 fix(tanstack-start): handle route recovery edge cases
- Retry failed loaders by invalidating the router.
- Normalize trailing-slash and encoded route pathnames.
- Add regression coverage and document corrected behavior.
2026-09-08 15:08:00 -05:00
Chris Tate bfab0c4a6c fix(tanstack-start): harden route transitions
- Scope page state to the rendered route during pending navigation.
- Normalize route matching across empty splats and trailing slashes.
- Preserve client boundaries in CommonJS and ESM builds.
2026-09-08 11:16:25 -05:00
Chris Tate 1d29a71d8c fix(tanstack-start): match empty splats
- Align root and trailing-slash splat matching with TanStack Router.
- Cover empty splat paths with regression tests.
2026-09-08 10:39:25 -05:00
Chris Tate e5f97d4dec fix(tanstack-start): align runtime contracts
- Merge layout state into page rendering with explicit precedence.
- Match TanStack Router string splat parameters across runtime, types, and docs.
- Preserve the client boundary and add regression coverage.
2026-09-08 10:25:28 -05:00
Chris Tate e13ffc53fc fix(tanstack-start): address review findings 2026-09-08 10:11:46 -05:00
Chris Tate 6b095856f2 feat(tanstack-start): add renderer
- Add full-app routing, SSR data helpers, metadata, layouts, and navigation.
- Validate with focused tests and the current TanStack Router type contract.
- Document the package across the API reference, renderer guide, and agent skill.
2026-09-08 09:58:13 -05:00
38 changed files with 3424 additions and 2 deletions
+49
View File
@@ -131,6 +131,7 @@ function Dashboard({ spec }) {
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (20 built-in components, including GaussianSplat) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/next` | Next.js renderer — JSON becomes full apps with routes, layouts, SSR |
| `@json-render/tanstack-start` | TanStack Start renderer — full apps with routes, layouts, SSR, and head metadata |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
@@ -536,6 +537,54 @@ const app = createNextApp({ spec });
// </NextAppProvider>
```
### TanStack Start (Full Apps)
```tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
type StartAppSpec,
} from "@json-render/tanstack-start";
import { createStartApp } from "@json-render/tanstack-start/server";
const spec: StartAppSpec = {
metadata: { title: { default: "My App", template: "%s | My App" } },
routes: {
"/": {
metadata: { title: "Home" },
page: {
root: "hero",
elements: {
hero: { type: "Card", props: { title: "Welcome" }, children: [] },
},
},
},
},
};
const { getPageData, getHead } = createStartApp({ spec });
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: () => <PageRenderer {...Route.useLoaderData()} />,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
```
Wrap the root route's outlet with `<StartAppProvider spec={spec}>` so route
fallback components can resolve the current route. Pass named `$computed`
implementations through its `functions` prop.
### shadcn-svelte (Svelte)
```typescript
@@ -0,0 +1,393 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/api/tanstack-start");
# @json-render/tanstack-start
TanStack Start renderer for JSON-defined applications with routes, layouts,
head metadata, SSR loaders, prerender paths, and client navigation.
## Installation
```bash
npm install @json-render/core @json-render/react @json-render/tanstack-start
```
## schema
Use the Start application schema to generate full multi-page specs.
```typescript
import { defineCatalog } from "@json-render/core";
import {
schema,
startComponentDefinitions,
} from "@json-render/tanstack-start/server";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...startComponentDefinitions,
Card: {
props: z.object({ title: z.string() }),
description: "Card container",
},
NavBar: {
props: z.object({}),
slots: ["default"],
description: "Application navigation",
},
},
actions: {},
});
```
The generation prompt teaches TanStack Router's `$param` and `$` splat route
syntax, reusable layouts, escaped JSON Patch route keys, and the built-in
`Slot`, `Link`, and `navigate` capabilities.
Include `startComponentDefinitions` in the catalog so generated `Slot` and
`Link` elements pass validation. `PageRenderer` supplies their React
implementations automatically.
## createStartApp
Create helpers for a TanStack Start splat route.
```typescript
import { createStartApp } from "@json-render/tanstack-start/server";
export const { getPageData, getHead, getStaticPaths } = createStartApp({
spec,
loaders: {
post: async ({ slug }) => ({
post: await getPost(slug as string),
}),
},
});
```
### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>spec</code>
</td>
<td>
<code>
{"StartAppSpec | (() => StartAppSpec | Promise<StartAppSpec>)"}
</code>
</td>
<td>A static application spec or an async spec factory</td>
</tr>
<tr>
<td>
<code>loaders</code>
</td>
<td>
<code>{"Record<string, LoaderFn>"}</code>
</td>
<td>Named data loaders referenced by route specs</td>
</tr>
</tbody>
</table>
### Returns
<table>
<thead>
<tr>
<th>Helper</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>getPageData</code>
</td>
<td>
Matches a pathname, runs its loader, and returns serializable page and
layout data
</td>
</tr>
<tr>
<td>
<code>getHead</code>
</td>
<td>
Returns TanStack Router <code>meta</code> and <code>links</code>{" "}
descriptors
</td>
</tr>
<tr>
<td>
<code>getStaticPaths</code>
</td>
<td>Returns concrete paths for TanStack Start prerendering</td>
</tr>
</tbody>
</table>
State is merged in this order: application state, layout state, page state,
then loader data. Later sources override earlier values.
## StartAppSpec
```typescript
interface StartAppSpec {
metadata?: StartMetadata;
routes: Record<string, StartRouteSpec>;
layouts?: Record<string, Spec>;
state?: Record<string, unknown>;
}
```
Each route requires a `page` spec and can select a layout, metadata, a named
loader, loading/error/not-found specs, and static parameters.
### Route Patterns
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Example</th>
<th>Params</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>/</code>
</td>
<td>
<code>/</code>
</td>
<td>
<code>{"{}"}</code>
</td>
</tr>
<tr>
<td>
<code>/about</code>
</td>
<td>
<code>/about</code>
</td>
<td>
<code>{"{}"}</code>
</td>
</tr>
<tr>
<td>
<code>{"/blog/$slug"}</code>
</td>
<td>
<code>/blog/hello</code>
</td>
<td>
<code>{'{ slug: "hello" }'}</code>
</td>
</tr>
<tr>
<td>
<code>{"/docs/$"}</code>
</td>
<td>
<code>/docs/guides/intro</code>
</td>
<td>
<code>{'{ _splat: "guides/intro" }'}</code>
</td>
</tr>
</tbody>
</table>
Loader parameters are URL-decoded before they reach named loaders. Splat
content is a slash-delimited string under `_splat`. Parameter values supplied
through `staticParams` are URL-encoded in the paths returned by
`getStaticPaths()`.
Route matching treats trailing slashes as optional and accepts both encoded and
decoded pathname representations. This keeps loader data and route metadata in
sync for static paths containing spaces or non-ASCII characters.
For prerendered dynamic routes, provide `staticParams`:
```typescript
routes: {
'/blog/$slug': {
page,
staticParams: [{ slug: 'hello' }, { slug: 'world' }],
},
'/docs/$': {
page: docsPage,
staticParams: [{ _splat: 'guides/intro' }],
},
}
```
Map `getStaticPaths()` into TanStack Start's top-level `pages` configuration:
```typescript
const pages = (await getStaticPaths()).map((path) => ({ path }));
```
## TanStack Route Setup
Wire the helpers to a file-based `$` splat route:
```tsx
// src/routes/$.tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
} from "@json-render/tanstack-start";
import { getHead, getPageData } from "@/lib/json-app";
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: Page,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
function Page() {
return <PageRenderer {...Route.useLoaderData()} />;
}
```
TanStack Router loaders are isomorphic. When a spec factory or named loader
uses database clients, credentials, or server-only imports, invoke
`getPageData` and `getHead` inside a TanStack Start `createServerFn` and call
that server function from the route loader.
## StartAppProvider
Provide component implementations and action handlers around the root
`Outlet`. Render `HeadContent` for route metadata.
```tsx
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router";
import { StartAppProvider } from "@json-render/tanstack-start";
import { spec } from "@/lib/spec";
export const Route = createRootRoute({
component: () => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<StartAppProvider
registry={registry}
handlers={handlers}
spec={spec}
>
<Outlet />
</StartAppProvider>
<Scripts />
</body>
</html>
),
});
```
Passing `spec` lets `StartLoading`, `StartErrorBoundary`, and `StartNotFound`
automatically select the matched route's fallback specs. Their explicit
`loadingSpec`, `errorSpec`, and `notFoundSpec` props take precedence. For a
server-only application spec, omit `spec` and pass client-safe fallback specs
explicitly.
Pass named functions through `functions` when props use `$computed`:
```tsx
<StartAppProvider
registry={registry}
spec={spec}
functions={{ uppercase: ({ value }) => String(value).toUpperCase() }}
>
<Outlet />
</StartAppProvider>
```
## Built-ins
- `Slot` inserts page content into a JSON-defined layout.
- `Link` wraps TanStack Router's `Link`; generated specs use an `href` prop.
- `navigate` performs client-side navigation from action bindings.
- `StartLoading`, `StartErrorBoundary`, and `StartNotFound` resolve the matched
route's fallback specs when they are used as TanStack Router boundary
components and the provider receives `spec`.
The default `StartErrorBoundary` fallback invalidates the router and reruns the
failed loader when the user selects **Try again**.
`Slot` and `Link` are automatically added to the page registry.
## Server Utilities
```typescript
import {
collectStaticPaths,
matchRoute,
metadataToHead,
resolveMetadata,
splatToPath,
} from "@json-render/tanstack-start/server";
```
## Entry Points
<table>
<thead>
<tr>
<th>Import</th>
<th>Contents</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>@json-render/tanstack-start</code>
</td>
<td>Provider, page renderer, Link, and route fallback components</td>
</tr>
<tr>
<td>
<code>@json-render/tanstack-start/server</code>
</td>
<td>App factory, schema, matcher, metadata, and prerender helpers</td>
</tr>
<tr>
<td>
<code>@json-render/tanstack-start/catalog</code>
</td>
<td>Server-safe definitions for built-in Slot and Link components</td>
</tr>
</tbody>
</table>
@@ -62,6 +62,15 @@ All renderers share the same workflow:
</td>
<td>Native mobile views</td>
</tr>
<tr>
<td>TanStack Start</td>
<td>
<code>@json-render/tanstack-start</code>
</td>
<td>
Full React applications with routes, layouts, SSR, and head metadata
</td>
</tr>
<tr>
<td>Image</td>
<td>
@@ -213,6 +222,32 @@ const { registry } = defineRegistry(catalog, { components: {} });
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
## TanStack Start
Define complete TanStack Start applications with route specs, reusable layouts,
loader-backed state, head metadata, and prerender paths. The integration uses
the React renderer for each page and TanStack Router for navigation.
```tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import { PageRenderer } from "@json-render/tanstack-start";
import { createStartApp } from "@json-render/tanstack-start/server";
const { getPageData, getHead } = createStartApp({ spec });
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: () => <PageRenderer {...Route.useLoaderData()} />,
});
```
See the [@json-render/tanstack-start API reference](/docs/api/tanstack-start) for details.
## Image
Generate SVG and PNG images from JSON specs using Satori. Ideal for OG images, social cards, and banners.
+6
View File
@@ -10,6 +10,7 @@ json-render ships with skills that teach AI coding agents how to use each packag
- **core** — Core schemas, catalogs, and AI prompt generation.
- **react** — React renderer that turns JSON specs into React component trees.
- **tanstack-start** — Full TanStack Start applications with routes, layouts, SSR loaders, and head metadata.
- **react-pdf** — PDF renderer using `@react-pdf/renderer`.
- **react-email** — Email renderer that produces HTML or plain-text emails.
- **react-native** — React Native renderer for native mobile UIs.
@@ -31,6 +32,7 @@ json-render ships with skills that teach AI coding agents how to use each packag
```bash
npx skills add vercel-labs/json-render --skill core
npx skills add vercel-labs/json-render --skill react
npx skills add vercel-labs/json-render --skill tanstack-start
npx skills add vercel-labs/json-render --skill react-pdf
npx skills add vercel-labs/json-render --skill react-email
npx skills add vercel-labs/json-render --skill react-native
@@ -58,6 +60,10 @@ The foundational skill. Teaches agents how to define catalogs, create schemas, b
Teaches agents how to render JSON specs as React component trees using `JsonRender`, `JsonRenderClient`, and `useJsonRender`. Covers custom component registries, client-side interactivity, state management, and streaming integration.
## tanstack-start
Teaches agents how to build JSON-defined TanStack Start applications with splat routes, reusable layouts, SSR-safe loaders, head metadata, prerender paths, and client navigation.
## react-pdf
Teaches agents how to generate PDFs from JSON specs using `@react-pdf/renderer`. Covers the PDF-specific component registry, page layout, and styling.
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/directives, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, directives, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/tanstack-start, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/directives, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, tanstack-start, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, directives, codegen, devtools, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
+4
View File
@@ -72,6 +72,10 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/next", href: "/docs/api/next" },
{
title: "@json-render/tanstack-start",
href: "/docs/api/tanstack-start",
},
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
+1
View File
@@ -46,6 +46,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/next": "@json-render/next API",
"docs/api/tanstack-start": "@json-render/tanstack-start API",
"docs/api/vue": "@json-render/vue API",
"docs/api/solid": "@json-render/solid API",
"docs/api/react-pdf": "@json-render/react-pdf API",
+8
View File
@@ -0,0 +1,8 @@
# @json-render/tanstack-start
## 0.20.0
### Minor Changes
- Add TanStack Start support for full JSON-defined applications with routes,
layouts, head metadata, SSR loaders, prerender paths, and client navigation.
+240
View File
@@ -0,0 +1,240 @@
# @json-render/tanstack-start
TanStack Start renderer for [@json-render/core](https://json-render.dev).
Define routes, layouts, head metadata, state, and loader-backed pages as JSON,
then render them through TanStack Router and Start SSR.
## Installation
```bash
npm install @json-render/core @json-render/react @json-render/tanstack-start
```
## Quick Start
### 1. Define the catalog
Include the definitions for the built-in `Slot` and `Link` components. Their
React implementations are added automatically by `PageRenderer`.
```typescript
import { defineCatalog } from "@json-render/core";
import {
schema,
startComponentDefinitions,
} from "@json-render/tanstack-start/server";
export const catalog = defineCatalog(schema, {
components: {
...startComponentDefinitions,
Card: cardDefinition,
Container: containerDefinition,
NavBar: navBarDefinition,
Post: postDefinition,
},
actions: {},
});
```
### 2. Define the application
```typescript
import type { StartAppSpec } from "@json-render/tanstack-start";
export const spec: StartAppSpec = {
metadata: {
title: { default: "My App", template: "%s | My App" },
},
layouts: {
main: {
root: "shell",
elements: {
shell: {
type: "Container",
props: {},
children: ["nav", "slot"],
},
nav: { type: "NavBar", props: {}, children: [] },
slot: { type: "Slot", props: {}, children: [] },
},
},
},
routes: {
"/": {
layout: "main",
metadata: { title: "Home" },
page: {
root: "hero",
elements: {
hero: {
type: "Card",
props: { title: "Welcome" },
children: [],
},
},
},
},
"/blog/$slug": {
layout: "main",
loader: "post",
page: {
root: "post",
elements: {
post: {
type: "Post",
props: { post: { $state: "/post" } },
children: [],
},
},
},
},
},
};
```
### 3. Create the route helpers
```typescript
// src/lib/json-app.ts
import { createStartApp } from "@json-render/tanstack-start/server";
import { spec } from "./spec";
export const { getPageData, getHead, getStaticPaths } = createStartApp({
spec,
loaders: {
post: async ({ slug }) => ({ post: await getPost(slug as string) }),
},
});
```
### 4. Wire a splat route
```tsx
// src/routes/$.tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
} from "@json-render/tanstack-start";
import { getHead, getPageData } from "@/lib/json-app";
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: Page,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
function Page() {
return <PageRenderer {...Route.useLoaderData()} />;
}
```
TanStack Router loaders are isomorphic. If your spec factory or named loaders
contain secrets or server-only imports, call `getPageData` and `getHead` from a
TanStack Start `createServerFn` and return the resulting data from the route
loader.
### 5. Provide the registry
```tsx
// src/routes/__root.tsx
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router";
import { StartAppProvider } from "@json-render/tanstack-start";
import { registry, handlers } from "@/lib/registry";
import { spec } from "@/lib/spec";
export const Route = createRootRoute({ component: Root });
function Root() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<StartAppProvider
registry={registry}
handlers={handlers}
spec={spec}
>
<Outlet />
</StartAppProvider>
<Scripts />
</body>
</html>
);
}
```
Passing `spec` lets the Router boundary components automatically render the
matched route's `loading`, `error`, and `notFound` specs. An explicit
`loadingSpec`, `errorSpec`, or `notFoundSpec` prop overrides this lookup. If the
application spec is server-only, omit `spec` from the provider and supply those
explicit props from client-safe fallback specs.
The default `StartErrorBoundary` fallback invalidates the router and reruns the
failed loader when the user selects **Try again**.
Pass named functions through the provider when generated props use
`$computed`:
```tsx
<StartAppProvider
registry={registry}
spec={spec}
functions={{ uppercase: ({ value }) => String(value).toUpperCase() }}
>
<Outlet />
</StartAppProvider>
```
## Route Patterns
| Pattern | Matches | Loader params |
| ------------- | ------------- | ------------------------ |
| `/` | `/` | `{}` |
| `/about` | `/about` | `{}` |
| `/blog/$slug` | `/blog/hello` | `{ slug: "hello" }` |
| `/docs/$` | `/docs/a/b` | `{ _splat: "a/b" }` |
Static routes are included in `getStaticPaths()`. Dynamic routes are included
when their route spec supplies `staticParams`. Loader parameters are URL-decoded,
and splat content is a slash-delimited string under `_splat`. Parameter values
emitted by `getStaticPaths()` are URL-encoded.
Route matching treats trailing slashes as optional and accepts both encoded and
decoded pathname representations. This keeps loader data and route metadata in
sync for static paths containing spaces or non-ASCII characters.
Initial state is merged in this order: application state, layout state, page
state, then loader data. Later sources override earlier values.
Map the paths to TanStack Start's top-level `pages` option when prerendering:
```typescript
const pages = (await getStaticPaths()).map((path) => ({ path }));
```
## Entry Points
| Import | Description |
| ------------------------------------- | ------------------------------------------------------------- |
| `@json-render/tanstack-start` | Provider, page renderer, Link, and route fallback components |
| `@json-render/tanstack-start/server` | App factory, schema, matcher, metadata, and prerender helpers |
| `@json-render/tanstack-start/catalog` | Server-safe definitions for built-in Slot and Link components |
See the [full API reference](https://json-render.dev/docs/api/tanstack-start).
+79
View File
@@ -0,0 +1,79 @@
{
"name": "@json-render/tanstack-start",
"version": "0.20.0",
"license": "Apache-2.0",
"description": "TanStack Start renderer for @json-render/core. JSON becomes full TanStack Start applications with routes, layouts, head metadata, and SSR.",
"keywords": [
"json",
"ui",
"tanstack",
"tanstack-start",
"tanstack-router",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"ssr",
"pages",
"routing"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/tanstack-start"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.mjs",
"require": "./dist/server.js"
},
"./catalog": {
"types": "./dist/catalog.d.ts",
"import": "./dist/catalog.mjs",
"require": "./dist/catalog.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "rm -rf dist && tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@tanstack/react-router": "1.170.32",
"@types/react": "19.2.14",
"tsup": "^8.5.1",
"typescript": "^5.4.5",
"zod": "^4.3.6"
},
"peerDependencies": {
"@tanstack/react-router": "^1.170.32",
"react": "^19.2.3",
"zod": "^4.0.0"
}
}
@@ -0,0 +1,12 @@
/** Catalog-aware types shared with the React renderer. */
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "@json-render/react";
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
/**
* Server-safe catalog definitions for components built into PageRenderer.
* Include these in every TanStack Start catalog that generates layouts or links.
*/
export const startComponentDefinitions = {
Slot: {
props: z.object({}),
slots: ["default"],
description:
"Layout placeholder where the matched route's page content is rendered.",
example: {},
},
Link: {
props: z.object({
href: z.string(),
replace: z.boolean().optional(),
prefetch: z.boolean().optional(),
className: z.string().optional(),
style: z.record(z.string(), z.unknown()).optional(),
}),
slots: ["default"],
description: "Client-side link to another route in the application.",
example: { href: "/about" },
},
};
export type StartComponentDefinitions = typeof startComponentDefinitions;
@@ -0,0 +1,65 @@
import React from "react";
import {
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
} from "@tanstack/react-router";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { StartErrorBoundary } from "./error-boundary";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
beforeAll(() => {
window.scrollTo = () => {};
});
describe("StartErrorBoundary", () => {
it("reruns a failed loader when the user tries again", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
let attempts = 0;
const rootRoute = createRootRoute({ component: Outlet });
const route = createRoute({
getParentRoute: () => rootRoute,
path: "$",
loader: () => {
attempts++;
if (attempts === 1) throw new Error("Temporary failure");
return { message: "Loaded" };
},
component: Page,
errorComponent: StartErrorBoundary,
});
function Page() {
return <div>{route.useLoaderData().message}</div>;
}
const router = createRouter({
routeTree: rootRoute.addChildren([route]),
history: createMemoryHistory({ initialEntries: ["/retry"] }),
});
await router.load();
render(<RouterProvider router={router} />);
expect(attempts).toBe(1);
expect(screen.getByText("Temporary failure")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() => expect(screen.getByText("Loaded")).toBeTruthy());
expect(attempts).toBe(2);
});
});
@@ -0,0 +1,57 @@
import React from "react";
import { useRouter } from "@tanstack/react-router";
import type { Spec } from "@json-render/core";
import { PageRenderer } from "./page-renderer";
import { resolveRouteFallback } from "./route-fallback";
import { useOptionalStartApp } from "./provider";
/** Props accepted by TanStack Router's `errorComponent`. */
export interface StartErrorBoundaryProps {
error: Error;
reset: () => void;
/** Explicit fallback override; otherwise the matched route's spec is used. */
errorSpec?: Spec | null;
}
/** Render a route-specific error spec or a small default error view. */
export function StartErrorBoundary({
error,
reset,
errorSpec,
}: StartErrorBoundaryProps) {
const router = useRouter();
const context = useOptionalStartApp();
const retry = React.useCallback(() => {
void router.invalidate().then(reset, reset);
}, [reset, router]);
const resolvedSpec = resolveRouteFallback(
context?.spec,
context?.pathname,
"error",
errorSpec,
);
if (resolvedSpec && context) {
return <PageRenderer spec={resolvedSpec} />;
}
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<h2 style={{ marginBottom: "1rem" }}>Something went wrong</h2>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
{error.message || "An unexpected error occurred."}
</p>
<button
onClick={retry}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "1px solid #ccc",
background: "#fff",
cursor: "pointer",
}}
>
Try again
</button>
</div>
);
}
@@ -0,0 +1,30 @@
import React from "react";
import { Link as RouterLink } from "@tanstack/react-router";
import type { ComponentRenderProps } from "@json-render/react";
export interface LinkProps {
href: string;
replace?: boolean;
/** Preload the target route on intent. */
prefetch?: boolean;
className?: string;
style?: React.CSSProperties;
}
/** Built-in client navigation component for generated specs. */
export function Link({ element, children }: ComponentRenderProps<LinkProps>) {
const { href, replace, prefetch, className, style } =
element.props as LinkProps;
return (
<RouterLink
to={href}
replace={replace}
preload={prefetch === undefined ? undefined : prefetch ? "intent" : false}
className={className}
style={style}
>
{children}
</RouterLink>
);
}
@@ -0,0 +1,47 @@
import React from "react";
import type { Spec } from "@json-render/core";
import { PageRenderer } from "./page-renderer";
import { resolveRouteFallback } from "./route-fallback";
import { useOptionalStartApp } from "./provider";
export interface StartLoadingProps {
/** Explicit fallback override; otherwise the matched route's spec is used. */
loadingSpec?: Spec | null;
}
/** Render a route-specific pending spec or a small default spinner. */
export function StartLoading({ loadingSpec }: StartLoadingProps = {}) {
const context = useOptionalStartApp();
const resolvedSpec = resolveRouteFallback(
context?.spec,
context?.pathname,
"loading",
loadingSpec,
);
if (resolvedSpec && context) {
return <PageRenderer spec={resolvedSpec} loading />;
}
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "200px",
}}
>
<div
style={{
width: "2rem",
height: "2rem",
border: "2px solid #e5e7eb",
borderTopColor: "#3b82f6",
borderRadius: "50%",
animation: "jr-spin 0.6s linear infinite",
}}
/>
<style>{`@keyframes jr-spin { to { transform: rotate(360deg) } }`}</style>
</div>
);
}
@@ -0,0 +1,44 @@
import React from "react";
import type { NotFoundRouteProps } from "@tanstack/react-router";
import type { Spec } from "@json-render/core";
import { PageRenderer } from "./page-renderer";
import { resolveRouteFallback } from "./route-fallback";
import { useOptionalStartApp } from "./provider";
export interface StartNotFoundProps extends Partial<NotFoundRouteProps> {
/** Explicit fallback override; otherwise the matched route's spec is used. */
notFoundSpec?: Spec | null;
}
/** Render a route-specific not-found spec or a small default 404 view. */
export function StartNotFound({ notFoundSpec }: StartNotFoundProps = {}) {
const context = useOptionalStartApp();
const resolvedSpec = resolveRouteFallback(
context?.spec,
context?.pathname,
"notFound",
notFoundSpec,
);
if (resolvedSpec && context) {
return <PageRenderer spec={resolvedSpec} />;
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "400px",
padding: "2rem",
textAlign: "center",
}}
>
<h1 style={{ fontSize: "4rem", fontWeight: 700, margin: 0 }}>404</h1>
<p style={{ color: "#666", marginTop: "0.5rem", fontSize: "1.125rem" }}>
This page could not be found.
</p>
</div>
);
}
@@ -0,0 +1,117 @@
import React, { useMemo, type ReactNode } from "react";
import { useMatch } from "@tanstack/react-router";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRegistry,
type ComponentRenderProps,
} from "@json-render/react";
import { Link } from "./link";
import { useStartApp } from "./provider";
export interface PageRendererProps {
spec: Spec;
initialState?: Record<string, unknown>;
layoutSpec?: Spec | null;
loading?: boolean;
}
function Slot({ children }: ComponentRenderProps) {
return <>{children}</>;
}
/** Render page data returned by `createStartApp` inside an optional layout. */
export function PageRenderer({
spec,
initialState,
layoutSpec,
loading,
}: PageRendererProps) {
const {
registry,
handlers,
spec: appSpec,
functions,
navigate,
} = useStartApp();
const renderedPathname = useMatch({
strict: false,
select: (match) => match.pathname,
});
const augmentedRegistry: ComponentRegistry = useMemo(
() => ({ ...registry, Link, Slot }),
[registry],
);
const actionHandlers = useMemo(
() => ({
...handlers,
navigate: (params: Record<string, unknown>) => {
const href = params.href;
if (typeof href === "string") navigate(href);
},
}),
[handlers, navigate],
);
const resolvedInitialState = useMemo(() => {
if (initialState !== undefined) return initialState;
if (!appSpec?.state && !layoutSpec?.state && !spec.state) return undefined;
return { ...appSpec?.state, ...layoutSpec?.state, ...spec.state };
}, [appSpec?.state, initialState, layoutSpec?.state, spec.state]);
const page = (
<Renderer spec={spec} registry={augmentedRegistry} loading={loading} />
);
// Key from the rendered match rather than the global location. During a
// pending navigation, TanStack advances the location while keeping the
// previous match mounted until the next page is ready.
return (
<JSONUIProvider
key={renderedPathname}
registry={augmentedRegistry}
initialState={resolvedInitialState}
handlers={actionHandlers}
navigate={navigate}
functions={functions}
>
{layoutSpec ? (
<LayoutWithSlot
layoutSpec={layoutSpec}
registry={augmentedRegistry}
loading={loading}
>
{page}
</LayoutWithSlot>
) : (
page
)}
</JSONUIProvider>
);
}
function LayoutWithSlot({
layoutSpec,
registry,
loading,
children,
}: {
layoutSpec: Spec;
registry: ComponentRegistry;
loading?: boolean;
children: ReactNode;
}) {
const layoutRegistry: ComponentRegistry = useMemo(
() => ({
...registry,
Slot: function LayoutSlot() {
return <>{children}</>;
},
}),
[children, registry],
);
return (
<Renderer spec={layoutSpec} registry={layoutRegistry} loading={loading} />
);
}
@@ -0,0 +1,258 @@
import React from "react";
import {
Outlet,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import type { ComponentRenderProps } from "@json-render/react";
import { createStartApp } from "../create-app";
import type { StartAppSpec } from "../types";
import { StartLoading } from "./loading-renderer";
import { PageRenderer } from "./page-renderer";
import { StartAppProvider } from "./provider";
afterEach(cleanup);
beforeAll(() => {
window.scrollTo = () => {};
});
function Text({ element }: ComponentRenderProps<{ value: unknown }>) {
return <span>{String(element.props.value)}</span>;
}
function Container({ children }: ComponentRenderProps) {
return <div>{children}</div>;
}
function Button({ emit }: ComponentRenderProps) {
return <button onClick={() => emit("press")}>Change</button>;
}
function LabeledText({
element,
}: ComponentRenderProps<{ label: string; value: unknown }>) {
return <span>{`${element.props.label}:${String(element.props.value)}`}</span>;
}
async function renderInRouter(component: React.ReactNode) {
const rootRoute = createRootRoute({ component: () => component });
const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory({ initialEntries: ["/"] }),
});
await router.load();
return render(<RouterProvider router={router} />);
}
describe("StartAppProvider", () => {
it("forwards named functions to $computed expressions", async () => {
const page: Spec = {
root: "root",
elements: {
root: {
type: "Text",
props: {
value: { $computed: "uppercase", args: { value: "hello" } },
},
children: [],
},
},
};
await renderInRouter(
<StartAppProvider
registry={{ Text }}
functions={{ uppercase: ({ value }) => String(value).toUpperCase() }}
>
<PageRenderer spec={page} />
</StartAppProvider>,
);
expect(screen.getByText("HELLO")).toBeTruthy();
});
it("renders the matched route's loading spec", async () => {
const loading: Spec = {
root: "root",
state: { message: "Loading route" },
elements: {
root: {
type: "Text",
props: { value: { $state: "/message" } },
children: [],
},
},
};
const spec: StartAppSpec = {
state: { message: "Application" },
routes: {
"/": {
page: loading,
loading,
},
},
};
await renderInRouter(
<StartAppProvider registry={{ Text }} spec={spec}>
<StartLoading />
</StartAppProvider>,
);
expect(screen.getByText("Loading route")).toBeTruthy();
});
it("uses layout state when page data is rendered directly", async () => {
const page: Spec = {
root: "page",
elements: {
page: { type: "Text", props: { value: "Page" }, children: [] },
},
};
const layout: Spec = {
root: "layout",
state: { message: "Layout state" },
elements: {
layout: {
type: "Container",
props: {},
children: ["message", "slot"],
},
message: {
type: "Text",
props: { value: { $state: "/message" } },
children: [],
},
slot: { type: "Slot", props: {}, children: [] },
},
};
await renderInRouter(
<StartAppProvider registry={{ Container, Text }}>
<PageRenderer spec={page} layoutSpec={layout} />
</StartAppProvider>,
);
expect(screen.getByText("Layout state")).toBeTruthy();
expect(screen.getByText("Page")).toBeTruthy();
});
it("resets page state when the catch-all route changes", async () => {
let resolveNextPage!: () => void;
const nextPage = new Promise<void>((resolve) => {
resolveNextPage = resolve;
});
let markNextPageStarted!: () => void;
const nextPageStarted = new Promise<void>((resolve) => {
markNextPageStarted = resolve;
});
const spec: StartAppSpec = {
routes: {
"/a": {
page: {
root: "container",
state: { count: 0 },
elements: {
container: {
type: "Container",
props: {},
children: ["text", "button"],
},
text: {
type: "LabeledText",
props: { label: "A", value: { $state: "/count" } },
children: [],
},
button: {
type: "Button",
props: {},
on: {
press: {
action: "setState",
params: { statePath: "/count", value: 5 },
},
},
children: [],
},
},
},
},
"/b": {
page: {
root: "text",
state: { count: 0 },
elements: {
text: {
type: "LabeledText",
props: { label: "B", value: { $state: "/count" } },
children: [],
},
},
},
},
},
};
const app = createStartApp({ spec });
const rootRoute = createRootRoute({
component: () => (
<StartAppProvider registry={{ Button, Container, LabeledText }}>
<Outlet />
</StartAppProvider>
),
});
const route = createRoute({
getParentRoute: () => rootRoute,
path: "$",
loader: async ({ location }) => {
if (location.pathname === "/b") {
markNextPageStarted();
await nextPage;
}
return app.getPageData({ pathname: location.pathname });
},
component: Page,
});
function Page() {
const data = route.useLoaderData();
return data ? <PageRenderer {...data} /> : null;
}
const router = createRouter({
routeTree: rootRoute.addChildren([route]),
history: createMemoryHistory({ initialEntries: ["/a"] }),
});
await router.load();
render(<RouterProvider router={router} />);
fireEvent.click(screen.getByRole("button", { name: "Change" }));
expect(screen.getByText("A:5")).toBeTruthy();
let navigation!: Promise<void>;
await act(async () => {
navigation = router.navigate({ to: "/b" });
await nextPageStarted;
});
const pendingPageValue = screen.getByText(/^A:/).textContent;
await act(async () => {
resolveNextPage();
await navigation;
});
expect(pendingPageValue).toBe("A:5");
expect(screen.getByText("B:0")).toBeTruthy();
});
});
@@ -0,0 +1,76 @@
import React, { createContext, useContext, type ReactNode } from "react";
import { useLocation, useRouter } from "@tanstack/react-router";
import type { ComputedFunction } from "@json-render/core";
import type { ComponentRegistry } from "@json-render/react";
import type { StartAppSpec } from "../types";
export interface StartAppContextValue {
registry: ComponentRegistry;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
spec?: StartAppSpec;
functions?: Record<string, ComputedFunction>;
pathname: string;
navigate: (href: string) => void;
}
const StartAppContext = createContext<StartAppContextValue | null>(null);
export interface StartAppProviderProps {
registry: ComponentRegistry;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
/** Application spec used to resolve route-specific fallback components. */
spec?: StartAppSpec;
/** Named functions available to `$computed` prop expressions. */
functions?: Record<string, ComputedFunction>;
children: ReactNode;
}
/** Provide rendering dependencies, route fallbacks, and TanStack navigation. */
export function StartAppProvider({
registry,
handlers,
spec,
functions,
children,
}: StartAppProviderProps) {
const router = useRouter();
const pathname = useLocation({ select: (location) => location.pathname });
const navigate = React.useCallback(
(href: string) => {
void router.navigate({ to: href });
},
[router],
);
const value = React.useMemo(
() => ({ registry, handlers, spec, functions, pathname, navigate }),
[registry, handlers, spec, functions, pathname, navigate],
);
return (
<StartAppContext.Provider value={value}>
{children}
</StartAppContext.Provider>
);
}
/** Access the current TanStack Start json-render application context. */
export function useStartApp(): StartAppContextValue {
const context = useContext(StartAppContext);
if (!context) {
throw new Error(
"[json-render/tanstack-start] useStartApp must be used within a " +
"<StartAppProvider>.",
);
}
return context;
}
export function useOptionalStartApp(): StartAppContextValue | null {
return useContext(StartAppContext);
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import type { StartAppSpec } from "../types";
import { resolveRouteFallback } from "./route-fallback";
function page(type: string): Spec {
return {
root: "root",
elements: { root: { type, props: {}, children: [] } },
};
}
describe("resolveRouteFallback", () => {
const loading = page("Loading");
const error = page("Error");
const notFound = page("NotFound");
const spec: StartAppSpec = {
routes: {
"/blog/$slug": {
page: page("Page"),
loading,
error,
notFound,
},
},
};
it("resolves each fallback from the matched route", () => {
expect(resolveRouteFallback(spec, "/blog/post", "loading", undefined)).toBe(
loading,
);
expect(resolveRouteFallback(spec, "/blog/post", "error", undefined)).toBe(
error,
);
expect(
resolveRouteFallback(spec, "/blog/post", "notFound", undefined),
).toBe(notFound);
});
it("prefers an explicit fallback and allows null to disable one", () => {
const explicit = page("Explicit");
expect(resolveRouteFallback(spec, "/blog/post", "loading", explicit)).toBe(
explicit,
);
expect(
resolveRouteFallback(spec, "/blog/post", "loading", null),
).toBeNull();
});
});
@@ -0,0 +1,17 @@
import type { Spec } from "@json-render/core";
import { matchRoute } from "../router";
import type { StartAppSpec } from "../types";
export type RouteFallbackKind = "loading" | "error" | "notFound";
/** Resolve an explicit fallback or the fallback on the currently matched route. */
export function resolveRouteFallback(
spec: StartAppSpec | undefined,
pathname: string | undefined,
kind: RouteFallbackKind,
explicitSpec: Spec | null | undefined,
): Spec | null | undefined {
if (explicitSpec !== undefined) return explicitSpec;
if (!spec || pathname === undefined) return undefined;
return matchRoute(spec, pathname)?.route[kind];
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { createStartApp } from "./create-app";
import type { StartAppSpec } from "./types";
function page(state?: Record<string, unknown>): Spec {
return {
root: "root",
elements: { root: { type: "Card", props: {}, children: [] } },
...(state ? { state } : {}),
};
}
describe("createStartApp", () => {
it("resolves page and layout data", async () => {
const layout = page();
const spec: StartAppSpec = {
layouts: { main: layout },
routes: { "/": { page: page(), layout: "main" } },
};
const data = await createStartApp({ spec }).getPageData({ pathname: "/" });
expect(data?.spec).toEqual(page());
expect(data?.layoutSpec).toEqual(layout);
});
it("returns null for an unmatched pathname", async () => {
const spec: StartAppSpec = { routes: { "/": { page: page() } } };
const data = await createStartApp({ spec }).getPageData({
pathname: "/missing",
});
expect(data).toBeNull();
});
it("merges global, layout, page, and loader state in order", async () => {
const spec: StartAppSpec = {
state: { a: 1, b: 1, c: 1 },
layouts: { main: page({ b: 2, c: 2, layout: true }) },
routes: {
"/blog/$slug": {
page: page({ c: 3, page: true }),
layout: "main",
loader: "post",
},
},
};
const app = createStartApp({
spec,
loaders: {
post: (params) => ({ c: 4, slug: params.slug }),
},
});
expect(
(await app.getPageData({ pathname: "/blog/hello" }))?.initialState,
).toEqual({
a: 1,
b: 2,
c: 4,
layout: true,
page: true,
slug: "hello",
});
});
it("supports async spec factories", async () => {
const spec: StartAppSpec = { routes: { "/": { page: page() } } };
const data = await createStartApp({ spec: async () => spec }).getPageData({
pathname: "/",
});
expect(data).not.toBeNull();
});
it("resolves head metadata and static paths", async () => {
const spec: StartAppSpec = {
metadata: { title: { default: "Site", template: "%s | Site" } },
routes: {
"/about": {
page: page(),
metadata: { title: "About", description: "About us" },
},
"/blog/$slug": {
page: page(),
staticParams: [{ slug: "hello" }],
},
},
};
const app = createStartApp({ spec });
const head = await app.getHead({ pathname: "/about" });
expect(head.meta).toContainEqual({ title: "About | Site" });
expect(head.meta).toContainEqual({
name: "description",
content: "About us",
});
expect(await app.getStaticPaths()).toEqual(["/about", "/blog/hello"]);
});
it("resolves route metadata from an encoded static pathname", async () => {
const spec: StartAppSpec = {
metadata: { title: "Global" },
routes: {
"/café": {
page: page(),
metadata: { title: "Café" },
},
},
};
const app = createStartApp({ spec });
expect(await app.getPageData({ pathname: "/café" })).not.toBeNull();
expect((await app.getHead({ pathname: "/caf%C3%A9" })).meta).toContainEqual(
{
title: "Café",
},
);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { metadataToHead, resolveMetadata } from "./metadata";
import { collectStaticPaths, matchRoute } from "./router";
import type {
CreateStartAppOptions,
HeadDescriptors,
PageData,
StartAppExports,
StartAppSpec,
} from "./types";
async function resolveSpec(
specOrFactory: StartAppSpec | (() => StartAppSpec | Promise<StartAppSpec>),
): Promise<StartAppSpec> {
return typeof specOrFactory === "function"
? await specOrFactory()
: specOrFactory;
}
function mergeState(
...sources: (Record<string, unknown> | null | undefined)[]
): Record<string, unknown> {
return Object.assign({}, ...sources.filter((source) => source != null));
}
/**
* Create the route-loader, head, and prerender helpers for a TanStack Start
* catch-all route.
*/
export function createStartApp(
options: CreateStartAppOptions,
): StartAppExports {
const { spec: specOrFactory, loaders } = options;
async function getPageData({
pathname,
}: {
pathname: string;
}): Promise<PageData | null> {
const spec = await resolveSpec(specOrFactory);
const matched = matchRoute(spec, pathname);
if (!matched) return null;
const { route } = matched;
const loader = route.loader ? loaders?.[route.loader] : undefined;
const loaderData = loader ? await loader(matched.params) : undefined;
const layoutSpec =
route.layout && spec.layouts
? (spec.layouts[route.layout] ?? null)
: null;
const initialState = mergeState(
spec.state,
layoutSpec?.state,
route.page.state,
loaderData,
);
return {
spec: route.page,
initialState:
Object.keys(initialState).length > 0 ? initialState : undefined,
layoutSpec,
};
}
async function getHead({
pathname,
}: {
pathname: string;
}): Promise<HeadDescriptors> {
const spec = await resolveSpec(specOrFactory);
const matched = matchRoute(spec, pathname);
return metadataToHead(resolveMetadata(spec, matched?.route));
}
async function getStaticPaths(): Promise<string[]> {
return collectStaticPaths(await resolveSpec(specOrFactory));
}
return { getPageData, getHead, getStaticPaths };
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
// React components for TanStack Start applications.
export {
StartAppProvider,
useStartApp,
type StartAppContextValue,
type StartAppProviderProps,
} from "./components/provider";
export {
PageRenderer,
type PageRendererProps,
} from "./components/page-renderer";
export {
StartErrorBoundary,
type StartErrorBoundaryProps,
} from "./components/error-boundary";
export {
StartLoading,
type StartLoadingProps,
} from "./components/loading-renderer";
export {
StartNotFound,
type StartNotFoundProps,
} from "./components/not-found-renderer";
export { Link, type LinkProps } from "./components/link";
export type {
CreateStartAppOptions,
HeadDescriptors,
LoaderFn,
MatchedRoute,
PageData,
StartAppExports,
StartAppSpec,
StartMetadata,
StartRouteSpec,
} from "./types";
export type {
ActionFn,
Actions,
BaseComponentProps,
ComponentContext,
ComponentFn,
Components,
EventHandle,
SetState,
StateModel,
} from "./catalog-types";
export type { ComputedFunction, Spec, StateStore } from "@json-render/core";
export { createStateStore } from "@json-render/core";
export type {
ComponentRegistry,
ComponentRenderProps,
} from "@json-render/react";
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { metadataToHead, resolveMetadata } from "./metadata";
import type { StartAppSpec, StartMetadata } from "./types";
const page: Spec = {
root: "root",
elements: { root: { type: "Card", props: {}, children: [] } },
};
function withMetadata(global?: StartMetadata, route?: StartMetadata) {
const routeSpec = { page, metadata: route };
return {
spec: { metadata: global, routes: { "/": routeSpec } } as StartAppSpec,
route: routeSpec,
};
}
describe("metadata", () => {
it("applies title templates safely to every placeholder", () => {
const { spec, route } = withMetadata(
{ title: { default: "Site", template: "%s | Site | %s" } },
{ title: "Cash $& Carry" },
);
expect(resolveMetadata(spec, route).title).toBe(
"Cash $& Carry | Site | Cash $& Carry",
);
});
it("honors absolute titles and shallow-merges social metadata", () => {
const { spec, route } = withMetadata(
{
title: { default: "Site", template: "%s | Site" },
openGraph: { siteName: "Site", type: "website" },
},
{
title: { default: "Page", absolute: "Standalone" },
openGraph: { title: "Page", type: "article" },
},
);
expect(resolveMetadata(spec, route)).toMatchObject({
title: "Standalone",
openGraph: { siteName: "Site", title: "Page", type: "article" },
});
});
it("builds TanStack head meta and link descriptors", () => {
const head = metadataToHead({
title: "Home",
description: "Welcome",
keywords: ["json", "render"],
openGraph: { images: ["/a.png", "/b.png"] },
twitter: { card: "summary_large_image", images: "/c.png" },
robots: { index: false },
alternates: { canonical: "https://example.com" },
icons: { icon: "/favicon.ico", apple: "/apple.png" },
});
expect(head.meta).toContainEqual({ title: "Home" });
expect(head.meta).toContainEqual({
property: "og:image",
content: "/a.png",
});
expect(head.meta).toContainEqual({
name: "robots",
content: "noindex, follow",
});
expect(head.links).toEqual([
{ rel: "canonical", href: "https://example.com" },
{ rel: "icon", href: "/favicon.ico" },
{ rel: "apple-touch-icon", href: "/apple.png" },
]);
});
});
+196
View File
@@ -0,0 +1,196 @@
import type {
HeadDescriptors,
StartAppSpec,
StartMetadata,
StartRouteSpec,
} from "./types";
export type ResolvedMetadata = Record<string, unknown>;
/** Merge application metadata with a route's overrides. */
export function resolveMetadata(
spec: StartAppSpec,
route?: StartRouteSpec | null,
): ResolvedMetadata {
const globalMetadata = spec.metadata;
const routeMetadata = route?.metadata;
if (!globalMetadata && !routeMetadata) return {};
const result: ResolvedMetadata = {};
const title = resolveTitle(globalMetadata?.title, routeMetadata?.title);
if (title !== undefined) result.title = title;
const description = routeMetadata?.description ?? globalMetadata?.description;
if (description) result.description = description;
const keywords = routeMetadata?.keywords ?? globalMetadata?.keywords;
if (keywords) result.keywords = keywords;
const openGraph = mergeObject(
globalMetadata?.openGraph,
routeMetadata?.openGraph,
);
if (openGraph) result.openGraph = openGraph;
const twitter = mergeObject(globalMetadata?.twitter, routeMetadata?.twitter);
if (twitter) result.twitter = twitter;
const robots = routeMetadata?.robots ?? globalMetadata?.robots;
if (robots) result.robots = robots;
const alternates = routeMetadata?.alternates ?? globalMetadata?.alternates;
if (alternates) result.alternates = alternates;
const icons = routeMetadata?.icons ?? globalMetadata?.icons;
if (icons) result.icons = icons;
return result;
}
/** Convert resolved metadata to TanStack Router `head` descriptors. */
export function metadataToHead(metadata: ResolvedMetadata): HeadDescriptors {
const meta: Record<string, string>[] = [];
const links: Record<string, string>[] = [];
const title = titleToString(metadata.title);
if (title) meta.push({ title });
if (typeof metadata.description === "string") {
meta.push({ name: "description", content: metadata.description });
}
if (Array.isArray(metadata.keywords) && metadata.keywords.length > 0) {
meta.push({ name: "keywords", content: metadata.keywords.join(", ") });
}
const openGraph = metadata.openGraph as
| StartMetadata["openGraph"]
| undefined;
if (openGraph) {
if (openGraph.title) {
meta.push({ property: "og:title", content: openGraph.title });
}
if (openGraph.description) {
meta.push({ property: "og:description", content: openGraph.description });
}
if (openGraph.type) {
meta.push({ property: "og:type", content: openGraph.type });
}
if (openGraph.url)
meta.push({ property: "og:url", content: openGraph.url });
if (openGraph.siteName) {
meta.push({ property: "og:site_name", content: openGraph.siteName });
}
if (openGraph.locale) {
meta.push({ property: "og:locale", content: openGraph.locale });
}
for (const image of toArray(openGraph.images)) {
meta.push({ property: "og:image", content: image });
}
}
const twitter = metadata.twitter as StartMetadata["twitter"] | undefined;
if (twitter) {
if (twitter.card) {
meta.push({ name: "twitter:card", content: twitter.card });
}
if (twitter.title) {
meta.push({ name: "twitter:title", content: twitter.title });
}
if (twitter.description) {
meta.push({ name: "twitter:description", content: twitter.description });
}
if (twitter.creator) {
meta.push({ name: "twitter:creator", content: twitter.creator });
}
if (twitter.site) {
meta.push({ name: "twitter:site", content: twitter.site });
}
for (const image of toArray(twitter.images)) {
meta.push({ name: "twitter:image", content: image });
}
}
const robots = metadata.robots as StartMetadata["robots"] | undefined;
if (robots) {
const content =
typeof robots === "string"
? robots
: [
robots.index === false ? "noindex" : "index",
robots.follow === false ? "nofollow" : "follow",
].join(", ");
meta.push({ name: "robots", content });
}
const alternates = metadata.alternates as
| StartMetadata["alternates"]
| undefined;
if (alternates?.canonical) {
links.push({ rel: "canonical", href: alternates.canonical });
}
const icons = metadata.icons as StartMetadata["icons"] | undefined;
if (typeof icons === "string") {
links.push({ rel: "icon", href: icons });
} else if (icons) {
if (icons.icon) links.push({ rel: "icon", href: icons.icon });
if (icons.apple) {
links.push({ rel: "apple-touch-icon", href: icons.apple });
}
if (icons.shortcut) {
links.push({ rel: "shortcut icon", href: icons.shortcut });
}
}
return { meta, links };
}
function resolveTitle(
globalTitle: StartMetadata["title"],
routeTitle: StartMetadata["title"],
): unknown {
if (!routeTitle && !globalTitle) return undefined;
if (!routeTitle) {
if (typeof globalTitle === "string") return globalTitle;
return globalTitle?.default;
}
const template =
typeof globalTitle === "object" ? globalTitle.template : undefined;
if (typeof routeTitle === "object") {
if (routeTitle.absolute) return routeTitle.absolute;
return applyTitleTemplate(template, routeTitle.default);
}
return applyTitleTemplate(template, routeTitle);
}
function applyTitleTemplate(
template: string | undefined,
title: string,
): string {
return template ? template.replace(/%s/g, () => title) : title;
}
function titleToString(title: unknown): string | undefined {
if (typeof title === "string") return title;
if (typeof title === "object" && title !== null) {
const value = title as { absolute?: string; default?: string };
return value.absolute ?? value.default;
}
return undefined;
}
function toArray(value: string | string[] | undefined): string[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function mergeObject(
base: Record<string, unknown> | undefined,
override: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!base && !override) return undefined;
return { ...base, ...override };
}
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { collectStaticPaths, matchRoute, splatToPath } from "./router";
import type { StartAppSpec, StartRouteSpec } from "./types";
function page(): Spec {
return {
root: "root",
elements: { root: { type: "Card", props: {}, children: [] } },
};
}
function specWith(
routes: Record<string, Partial<StartRouteSpec>>,
): StartAppSpec {
return {
routes: Object.fromEntries(
Object.entries(routes).map(([pattern, route]) => [
pattern,
{ page: page(), ...route },
]),
),
};
}
describe("matchRoute", () => {
it("matches root and static routes", () => {
const spec = specWith({ "/": {}, "/about": {} });
expect(matchRoute(spec, "")?.pattern).toBe("/");
expect(matchRoute(spec, "/about")?.pattern).toBe("/about");
});
it("extracts dynamic parameters", () => {
const matched = matchRoute(specWith({ "/blog/$slug": {} }), "/blog/hello");
expect(matched?.params).toEqual({ slug: "hello" });
});
it("matches static and dynamic routes with trailing slashes", () => {
const spec = specWith({ "/about": {}, "/blog/$slug": {} });
expect(matchRoute(spec, "/about/")?.pattern).toBe("/about");
expect(matchRoute(spec, "/blog/hello/")?.params).toEqual({
slug: "hello",
});
});
it("matches route patterns that end in trailing slashes", () => {
const spec = specWith({ "/about/": {}, "/blog/$slug/": {} });
expect(matchRoute(spec, "/about/")?.pattern).toBe("/about/");
expect(matchRoute(spec, "/blog/hello/")?.params).toEqual({
slug: "hello",
});
});
it("matches encoded and decoded static pathnames", () => {
const spec = specWith({ "/café": {} });
expect(matchRoute(spec, "/café")?.pattern).toBe("/café");
expect(matchRoute(spec, "/caf%C3%A9")?.pattern).toBe("/café");
});
it("decodes dynamic and splat parameters", () => {
expect(
matchRoute(specWith({ "/blog/$slug": {} }), "/blog/hello%20world")
?.params,
).toEqual({ slug: "hello world" });
expect(
matchRoute(specWith({ "/docs/$": {} }), "/docs/guides/hello%20world")
?.params,
).toEqual({ _splat: "guides/hello world" });
});
it("decodes percent signs in dynamic and splat parameters exactly once", () => {
expect(
matchRoute(specWith({ "/coupon/$code": {} }), "/coupon/100%25")?.params,
).toEqual({ code: "100%" });
expect(
matchRoute(specWith({ "/coupon/$code": {} }), "/coupon/%2525")?.params,
).toEqual({ code: "%25" });
expect(
matchRoute(specWith({ "/docs/$": {} }), "/docs/rates/100%25")?.params,
).toEqual({ _splat: "rates/100%" });
});
it("does not match malformed encoded parameters", () => {
expect(
matchRoute(specWith({ "/blog/$slug": {} }), "/blog/%E0%A4%A"),
).toBeNull();
});
it("captures zero or more splat segments under _splat", () => {
const spec = specWith({ "/docs/$": {} });
expect(matchRoute(spec, "/docs")?.params).toEqual({ _splat: "" });
expect(matchRoute(spec, "/docs/")?.params).toEqual({ _splat: "" });
expect(matchRoute(spec, "/docs/guides/intro")?.params).toEqual({
_splat: "guides/intro",
});
});
it("matches an empty top-level splat at the root pathname", () => {
expect(matchRoute(specWith({ "/$": {} }), "/")?.params).toEqual({
_splat: "",
});
});
it("ranks static, dynamic, then splat routes", () => {
const spec = specWith({
"/blog/$": {},
"/blog/$slug": {},
"/blog/featured": {},
});
expect(matchRoute(spec, "/blog/featured")?.pattern).toBe("/blog/featured");
expect(matchRoute(spec, "/blog/post")?.pattern).toBe("/blog/$slug");
expect(matchRoute(spec, "/blog/2026/post")?.pattern).toBe("/blog/$");
});
it("ranks earlier static segments ahead of later static segments", () => {
const spec = specWith({
"/$type/edit": {},
"/posts/$id": {},
});
expect(matchRoute(spec, "/posts/edit")?.pattern).toBe("/posts/$id");
});
it("lets an earlier static segment outrank a later splat", () => {
const spec = specWith({
"/$type/edit": {},
"/posts/$": {},
});
expect(matchRoute(spec, "/posts/edit")?.pattern).toBe("/posts/$");
});
it("matches static segments case-insensitively like TanStack Router", () => {
expect(matchRoute(specWith({ "/about": {} }), "/ABOUT")?.pattern).toBe(
"/about",
);
});
it("returns null for an unmatched path", () => {
expect(matchRoute(specWith({ "/": {} }), "/missing")).toBeNull();
});
});
describe("static paths", () => {
it("converts splat content to pathnames", () => {
expect(splatToPath(undefined)).toBe("/");
expect(splatToPath("docs/intro")).toBe("/docs/intro");
});
it("includes static routes and expands dynamic route params", () => {
const spec = specWith({
"/": {},
"/about": {},
"/blog/$slug": {
staticParams: [{ slug: "hello" }, { slug: "world" }],
},
"/docs/$": { staticParams: [{ _splat: "guides/intro" }] },
"/search/$query": { staticParams: [{ query: "hello world" }] },
"/users/$id": {},
});
expect(collectStaticPaths(spec)).toEqual([
"/",
"/about",
"/blog/hello",
"/blog/world",
"/docs/guides/intro",
"/search/hello%20world",
]);
});
it("emits matchable paths for route patterns with trailing slashes", () => {
const spec = specWith({
"/about/": {},
"/blog/$slug/": { staticParams: [{ slug: "hello" }] },
});
const paths = collectStaticPaths(spec);
expect(paths).toEqual(["/about/", "/blog/hello/"]);
expect(
paths.map((pathname) => matchRoute(spec, pathname)?.pattern),
).toEqual(["/about/", "/blog/$slug/"]);
});
it("round trips percent signs in static params", () => {
const spec = specWith({
"/coupon/$code": {
staticParams: [{ code: "100%" }, { code: "%25" }],
},
"/docs/$": { staticParams: [{ _splat: "rates/100%" }] },
});
const paths = collectStaticPaths(spec);
expect(paths).toEqual([
"/coupon/100%25",
"/coupon/%2525",
"/docs/rates/100%25",
]);
expect(paths.map((pathname) => matchRoute(spec, pathname)?.params)).toEqual(
[{ code: "100%" }, { code: "%25" }, { _splat: "rates/100%" }],
);
});
});
+156
View File
@@ -0,0 +1,156 @@
import type { MatchedRoute, StartAppSpec } from "./types";
interface CompiledRoute {
pattern: string;
regex: RegExp;
paramNames: string[];
segmentRanks: number[];
}
const SPLAT_PARAM = "_splat";
const STATIC_SEGMENT_RANK = 3;
const DYNAMIC_SEGMENT_RANK = 2;
const SPLAT_SEGMENT_RANK = 1;
/** Compile a TanStack Router pattern into a pathname matcher. */
function compileRoute(pattern: string): CompiledRoute {
const paramNames: string[] = [];
const normalizedPattern = normalizePathname(pattern);
const segments =
normalizedPattern === "/" ? [""] : normalizedPattern.split("/").slice(1);
const regexParts: string[] = [];
const segmentRanks: number[] = [];
for (const segment of segments) {
if (segment === "$") {
paramNames.push(SPLAT_PARAM);
segmentRanks.push(SPLAT_SEGMENT_RANK);
regexParts.push("(?:/(.*))?");
} else if (segment.startsWith("$") && segment.length > 1) {
paramNames.push(segment.slice(1));
segmentRanks.push(DYNAMIC_SEGMENT_RANK);
regexParts.push("/([^/]+)");
} else {
segmentRanks.push(STATIC_SEGMENT_RANK);
regexParts.push(`/${escapeRegExp(segment)}`);
}
}
return {
pattern,
regex: new RegExp(
normalizedPattern === "/" ? "^/$" : `^${regexParts.join("")}$`,
"i",
),
paramNames,
segmentRanks,
};
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Match a pathname using TanStack Router's static, dynamic, and splat forms. */
export function matchRoute(
spec: StartAppSpec,
pathname: string,
): MatchedRoute | null {
const normalizedPath = normalizePathname(pathname);
const compiled = Object.keys(spec.routes).map(compileRoute);
compiled.sort((a, b) => {
const segmentCount = Math.max(a.segmentRanks.length, b.segmentRanks.length);
for (let index = 0; index < segmentCount; index++) {
const aRank = a.segmentRanks[index] ?? 0;
const bRank = b.segmentRanks[index] ?? 0;
if (aRank !== bRank) return bRank - aRank;
}
return 0;
});
for (const candidate of compiled) {
const match = candidate.regex.exec(normalizedPath);
if (!match) continue;
const params: Record<string, string> = {};
let validParams = true;
for (let index = 0; index < candidate.paramNames.length; index++) {
const name = candidate.paramNames[index]!;
const value = match[index + 1];
try {
params[name] = decodeURIComponent(value ?? "");
} catch {
validParams = false;
break;
}
}
if (!validParams) continue;
return {
route: spec.routes[candidate.pattern]!,
pattern: candidate.pattern,
params,
};
}
return null;
}
function normalizePathname(pathname: string): string {
const withoutTrailingSlash = pathname.replace(/\/+$/, "") || "/";
try {
// TanStack preserves encoded percent signs in pathnames so route params
// can decode them exactly once. Shield them while decoding static text.
return decodeURI(withoutTrailingSlash.replace(/%25/gi, "%2525"));
} catch {
return withoutTrailingSlash;
}
}
/** Convert TanStack Router splat content to a pathname. */
export function splatToPath(splat: string | undefined): string {
if (!splat) return "/";
return `/${splat}`;
}
/** Collect concrete pathnames suitable for TanStack Start prerendering. */
export function collectStaticPaths(spec: StartAppSpec): string[] {
const results: string[] = [];
for (const [pattern, route] of Object.entries(spec.routes)) {
if (route.staticParams) {
for (const params of route.staticParams) {
const pathname = buildPathFromPattern(pattern, params);
if (pathname) results.push(pathname);
}
} else if (!pattern.includes("$")) {
results.push(pattern);
}
}
return results;
}
function buildPathFromPattern(
pattern: string,
params: Record<string, string>,
): string | null {
if (pattern === "/") return "/";
const result: string[] = [];
for (const segment of pattern.split("/").slice(1)) {
if (segment === "$") {
const value = params[SPLAT_PARAM];
if (value) result.push(...value.split("/").map(encodeURIComponent));
} else if (segment.startsWith("$") && segment.length > 1) {
const value = params[segment.slice(1)];
if (!value) return null;
result.push(encodeURIComponent(value));
} else {
result.push(segment);
}
}
return result.length === 0 ? "/" : `/${result.join("/")}`;
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { startComponentDefinitions } from "./catalog";
import { schema, type StartSpec } from "./schema";
const catalog = schema.createCatalog({ components: {}, actions: {} });
describe("@json-render/tanstack-start schema", () => {
it("accepts a minimal Start app spec", () => {
const spec = {
routes: {
"/": {
page: {
root: "root",
elements: {
root: { type: "Card", props: {}, children: [] },
},
},
},
},
};
expect(catalog.validate(spec)).toMatchObject({ success: true, data: spec });
});
it("preserves metadata and React element features", () => {
const spec = {
metadata: { title: "Home", alternates: { canonical: "/" } },
routes: {
"/": {
page: {
root: "root",
state: { active: true },
elements: {
root: {
type: "Card",
props: {},
children: [],
slots: { header: [] },
visible: { $state: "/active" },
on: { press: { action: "save" } },
repeat: { statePath: "/items" },
watch: { "/active": { action: "track" } },
},
},
},
},
},
};
expect(catalog.validate(spec)).toMatchObject({ success: true, data: spec });
});
it("validates built-in Slot and Link elements in a real catalog", () => {
const builtInCatalog = schema.createCatalog({
components: startComponentDefinitions,
actions: {},
});
const result = builtInCatalog.validate({
routes: {
"/": {
page: {
root: "link",
elements: {
link: {
type: "Link",
props: { href: "/about" },
children: [],
},
},
},
},
},
layouts: {
main: {
root: "slot",
elements: {
slot: { type: "Slot", props: {}, children: [] },
},
},
},
});
expect(result.success).toBe(true);
});
it("requires children on every element", () => {
const result = catalog.validate({
routes: {
"/": {
page: {
root: "root",
elements: { root: { type: "Card", props: {} } },
},
},
},
});
expect(result.success).toBe(false);
});
it("infers optional top-level fields", () => {
type InferredSpec = StartSpec<Parameters<typeof schema.createCatalog>[0]>;
const spec: InferredSpec = {
routes: {
"/": {
page: {
root: "root",
elements: {
root: { type: "Card", props: {}, children: [] },
},
},
},
},
};
expect(spec.routes["/"]?.page.root).toBe("root");
});
});
+269
View File
@@ -0,0 +1,269 @@
import { defineSchema, type PromptContext } from "@json-render/core";
function startAppPromptTemplate(context: PromptContext): string {
const { catalog, options, formatZodType } = context;
const {
system = "You are a TanStack Start application generator.",
customRules = [],
} = options;
const lines: string[] = [system, ""];
lines.push("OUTPUT FORMAT:");
lines.push(
"Output JSONL (one JSON object per line) with RFC 6902 JSON Patch operations to build a TanStack Start application spec.",
);
lines.push(
"The spec defines routes, layouts, metadata, and state for a full TanStack Start app.",
);
lines.push("");
lines.push("Example output (each line is a separate JSON object):");
lines.push("");
lines.push(
`{"op":"add","path":"/metadata","value":{"title":{"default":"My App","template":"%s | My App"},"description":"A TanStack Start application"}}`,
);
lines.push(`{"op":"add","path":"/layouts","value":{}}`);
lines.push(
`{"op":"add","path":"/layouts/main","value":{"root":"shell","elements":{"shell":{"type":"AppShell","props":{},"children":["nav","slot"]},"nav":{"type":"NavBar","props":{},"children":[]},"slot":{"type":"Slot","props":{},"children":[]}}}}`,
);
lines.push(`{"op":"add","path":"/routes","value":{}}`);
lines.push(
`{"op":"add","path":"/routes/~1","value":{"layout":"main","metadata":{"title":"Home"},"page":{"root":"hero","elements":{"hero":{"type":"Card","props":{"title":"Welcome"},"children":[]}}}}}`,
);
lines.push("");
lines.push("SPEC STRUCTURE:");
lines.push("- metadata: Root SEO metadata and title templates");
lines.push(
"- layouts: Reusable element trees with a Slot element for page content",
);
lines.push("- routes: Route definitions keyed by URL pattern");
lines.push("- state: Global initial state shared by routes");
lines.push("");
lines.push("ROUTES:");
lines.push("Route keys use TanStack Router URL patterns:");
lines.push("- '/' - home page");
lines.push("- '/about' - static route");
lines.push("- '/blog/$slug' - dynamic segment");
lines.push("- '/docs/$' - splat segment matching the remaining path");
lines.push("");
lines.push(
"In JSON Patch paths, escape every forward slash in a route key as ~1.",
);
lines.push("- Route '/' becomes '/routes/~1'");
lines.push("- Route '/about' becomes '/routes/~1about'");
lines.push("- Route '/blog/$slug' becomes '/routes/~1blog~1$slug'");
lines.push("");
lines.push("Each route has:");
lines.push("- page: Element tree with root, elements, and optional state");
lines.push("- metadata: Route-specific SEO metadata");
lines.push("- layout: Key in the top-level layouts map");
lines.push("- loading, error, notFound: Optional fallback element trees");
lines.push("- loader: Optional server loader name");
lines.push("- staticParams: Optional params for prerendered dynamic routes");
lines.push("");
lines.push("PAGE ELEMENTS:");
lines.push("- props may contain $state, $item, $index, and $computed values");
lines.push("- on maps component events to one or more action bindings");
lines.push("- repeat renders children for values at a state path");
lines.push("- visible conditionally includes an element");
lines.push("- watch runs actions when state paths change");
lines.push(
"- slots maps named slots to element keys; use children for default",
);
lines.push("");
const catalogData = catalog as {
components?: Record<
string,
{
description?: string;
props?: unknown;
slots?: string[];
events?: string[];
}
>;
actions?: Record<string, { description?: string }>;
};
if (catalogData.components) {
lines.push(
`AVAILABLE COMPONENTS (${Object.keys(catalogData.components).length}):`,
);
lines.push("");
for (const [name, definition] of Object.entries(catalogData.components)) {
const props = definition.props
? formatZodType(definition.props as any)
: "{}";
const children = definition.slots?.length ? " [accepts children]" : "";
const events = definition.events?.length
? ` [events: ${definition.events.join(", ")}]`
: "";
const description = definition.description
? ` - ${definition.description}`
: "";
lines.push(`${name}: ${props}${description}${children}${events}`);
}
lines.push("");
}
lines.push("BUILT-IN COMPONENTS:");
lines.push("- Slot: Layout placeholder for page content");
lines.push("- Link: { href: string } client-side navigation link");
lines.push("");
if (catalogData.actions && Object.keys(catalogData.actions).length > 0) {
lines.push("AVAILABLE ACTIONS:");
for (const [name, definition] of Object.entries(catalogData.actions)) {
lines.push(
`${name}${definition.description ? `: ${definition.description}` : ""}`,
);
}
lines.push("");
}
lines.push("BUILT-IN ACTIONS:");
lines.push("- setState: { statePath, value }");
lines.push("- pushState: { statePath, value, clearStatePath? }");
lines.push("- removeState: { statePath, index }");
lines.push("- navigate: { href }");
lines.push("");
lines.push("RULES:");
const rules = [
"Output only JSONL patches, one JSON object per line",
"Add metadata, then layouts, then routes",
"Every layout must contain a Slot element",
"Only use available components plus Slot and Link",
"Every element must include type, props, and children",
"Every child and named-slot key must reference an existing element",
"Escape route-key slashes as ~1 in JSON Patch paths",
"Use Link for navigation between routes",
"Use repeat for lists and include realistic state data",
"Create a cohesive application with consistent layouts",
...customRules,
];
rules.forEach((rule, index) => lines.push(`${index + 1}. ${rule}`));
return lines.join("\n");
}
/** The AI generation schema for full TanStack Start applications. */
export const schema = defineSchema(
(s) => ({
spec: s.object({
metadata: {
...s.object({
title: { ...s.any(), ...s.optional() },
description: { ...s.string(), ...s.optional() },
keywords: { ...s.array(s.string()), ...s.optional() },
openGraph: { ...s.any(), ...s.optional() },
twitter: { ...s.any(), ...s.optional() },
robots: { ...s.any(), ...s.optional() },
alternates: { ...s.any(), ...s.optional() },
icons: { ...s.any(), ...s.optional() },
}),
...s.optional(),
},
routes: s.record(
s.object({
page: s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
slots: {
...s.record(s.array(s.string())),
...s.optional(),
},
visible: { ...s.any(), ...s.optional() },
on: { ...s.any(), ...s.optional() },
repeat: { ...s.any(), ...s.optional() },
watch: { ...s.any(), ...s.optional() },
}),
),
state: { ...s.any(), ...s.optional() },
}),
metadata: { ...s.any(), ...s.optional() },
layout: { ...s.string(), ...s.optional() },
loading: { ...s.any(), ...s.optional() },
error: { ...s.any(), ...s.optional() },
notFound: { ...s.any(), ...s.optional() },
loader: { ...s.string(), ...s.optional() },
staticParams: { ...s.any(), ...s.optional() },
}),
),
layouts: {
...s.record(
s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
slots: {
...s.record(s.array(s.string())),
...s.optional(),
},
visible: { ...s.any(), ...s.optional() },
on: { ...s.any(), ...s.optional() },
repeat: { ...s.any(), ...s.optional() },
watch: { ...s.any(), ...s.optional() },
}),
),
state: { ...s.any(), ...s.optional() },
}),
),
...s.optional(),
},
state: { ...s.any(), ...s.optional() },
}),
catalog: s.object({
components: s.map({
props: s.zod(),
slots: s.array(s.string()),
description: s.string(),
example: s.any(),
}),
actions: s.map({
params: s.zod(),
description: s.string(),
}),
}),
}),
{
promptTemplate: startAppPromptTemplate,
builtInActions: [
{
name: "setState",
description:
"Update state at a JSON Pointer. Params: { statePath, value }",
},
{
name: "pushState",
description:
"Append to an array. Params: { statePath, value, clearStatePath? }",
},
{
name: "removeState",
description: "Remove an array item. Params: { statePath, index }",
},
{
name: "navigate",
description: "Navigate within the app. Params: { href }",
},
],
},
);
export type StartSchema = typeof schema;
export type StartSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
? S
: never;
+37
View File
@@ -0,0 +1,37 @@
/** Server-safe TanStack Start helpers with no React or router imports. */
export { createStartApp } from "./create-app";
export {
startComponentDefinitions,
type StartComponentDefinitions,
} from "./catalog";
export { schema, type StartSchema, type StartSpec } from "./schema";
export { collectStaticPaths, matchRoute, splatToPath } from "./router";
export {
metadataToHead,
resolveMetadata,
type ResolvedMetadata,
} from "./metadata";
export type {
CreateStartAppOptions,
HeadDescriptors,
LoaderFn,
MatchedRoute,
PageData,
StartAppExports,
StartAppSpec,
StartMetadata,
StartRouteSpec,
} from "./types";
export type {
ActionFn,
Actions,
BaseComponentProps,
ComponentContext,
ComponentFn,
Components,
EventHandle,
SetState,
StateModel,
} from "./catalog-types";
export type { Spec, StateStore } from "@json-render/core";
@@ -0,0 +1,52 @@
import React from "react";
import { createRootRoute, createRoute, notFound } from "@tanstack/react-router";
import { describe, expect, it } from "vitest";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
} from "./index";
import { createStartApp } from "./server";
import type { StartAppSpec } from "./types";
const spec: StartAppSpec = {
routes: {
"/$": {
page: {
root: "root",
elements: {
root: { type: "Card", props: {}, children: [] },
},
},
},
},
};
const app = createStartApp({ spec });
const rootRoute = createRootRoute();
const route = createRoute({
getParentRoute: () => rootRoute,
path: "$",
loader: async ({ location }) => {
const data = await app.getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => app.getHead({ pathname: match.pathname }),
component: RouteComponent,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
function RouteComponent() {
return <PageRenderer {...route.useLoaderData()} />;
}
describe("TanStack Router contract", () => {
it("accepts all json-render route hooks and components", () => {
expect(route.options.loader).toBeTypeOf("function");
expect(route.options.head).toBeTypeOf("function");
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { Spec } from "@json-render/core";
/**
* SEO metadata for TanStack Start pages.
*
* This framework-neutral shape resolves into TanStack Router `head`
* descriptors through `metadataToHead`.
*/
export interface StartMetadata {
/** Page title, or a title template configuration. */
title?:
| string
| {
/** Default title when no route overrides it. */
default: string;
/** Template containing `%s` for the route title. */
template?: string;
/** Absolute title that ignores the parent template. */
absolute?: string;
};
description?: string;
keywords?: string[];
openGraph?: {
title?: string;
description?: string;
images?: string | string[];
type?: string;
url?: string;
siteName?: string;
locale?: string;
};
twitter?: {
card?: "summary" | "summary_large_image" | "app" | "player";
title?: string;
description?: string;
images?: string | string[];
creator?: string;
site?: string;
};
robots?: string | { index?: boolean; follow?: boolean };
alternates?: { canonical?: string };
icons?: string | { icon?: string; apple?: string; shortcut?: string };
}
/** A route definition within a StartAppSpec. */
export interface StartRouteSpec {
/** Page content as a standard json-render element tree. */
page: Spec;
metadata?: StartMetadata;
/** Key of a reusable layout in `StartAppSpec.layouts`. */
layout?: string;
loading?: Spec;
error?: Spec;
notFound?: Spec;
/** Name of a loader supplied to `createStartApp`. */
loader?: string;
/** Parameter sets used to produce concrete prerender paths. */
staticParams?: Record<string, string>[];
}
/**
* A full json-render application for TanStack Start.
*
* Route keys use TanStack Router conventions: `/`, `/blog/$slug`, and
* `/docs/$` for a splat route.
*/
export interface StartAppSpec {
metadata?: StartMetadata;
routes: Record<string, StartRouteSpec>;
/** Layouts use a `Slot` element to mark where page content is inserted. */
layouts?: Record<string, Spec>;
state?: Record<string, unknown>;
}
/** The result of matching a pathname against the application spec. */
export interface MatchedRoute {
route: StartRouteSpec;
pattern: string;
/** Splat content is returned as a slash-delimited string under `_splat`. */
params: Record<string, string>;
}
export type LoaderFn = (
params: Record<string, string>,
) => Promise<Record<string, unknown>> | Record<string, unknown>;
export interface CreateStartAppOptions {
spec: StartAppSpec | (() => StartAppSpec | Promise<StartAppSpec>);
loaders?: Record<string, LoaderFn>;
}
/** Serializable data returned from a TanStack Start route loader. */
export interface PageData {
spec: Spec;
initialState?: Record<string, unknown>;
layoutSpec?: Spec | null;
}
/** Descriptors accepted by a TanStack Router route's `head` option. */
export interface HeadDescriptors {
meta: Record<string, string>[];
links: Record<string, string>[];
}
export interface StartAppExports {
/** Resolve serializable page data for a pathname, or null when unmatched. */
getPageData: (props: { pathname: string }) => Promise<PageData | null>;
/** Resolve metadata for a route's `head` option. */
getHead: (props: { pathname: string }) => Promise<HeadDescriptors>;
/** Get concrete pathnames for TanStack Start prerendering. */
getStaticPaths: () => Promise<string[]>;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from "tsup";
const sharedExternal = [
"react",
"react-dom",
"@tanstack/react-router",
"@json-render/core",
"@json-render/react",
"zod",
];
export default defineConfig([
{
entry: { index: "src/index.ts" },
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
splitting: false,
banner: ({ format }) => (format === "cjs" ? { js: '"use client";' } : {}),
external: sharedExternal,
},
{
entry: { server: "src/server.ts", catalog: "src/catalog.ts" },
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
splitting: false,
external: sharedExternal,
},
]);
+109
View File
@@ -2568,6 +2568,37 @@ importers:
specifier: ^5.4.5
version: 5.9.3
packages/tanstack-start:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../core
'@json-render/react':
specifier: workspace:*
version: link:../react
react:
specifier: ^19.2.3
version: 19.2.4
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@tanstack/react-router':
specifier: 1.170.32
version: 1.170.32(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/react':
specifier: 19.2.14
version: 19.2.14
tsup:
specifier: ^8.5.1
version: 8.5.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)
typescript:
specifier: ^5.4.5
version: 5.9.3
zod:
specifier: ^4.3.6
version: 4.3.6
packages/typescript-config: {}
packages/ui:
@@ -7324,6 +7355,30 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tanstack/history@1.162.1':
resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==}
engines: {node: '>=20.19'}
'@tanstack/react-router@1.170.32':
resolution: {integrity: sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==}
engines: {node: '>=20.19'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
'@tanstack/react-store@0.9.3':
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/router-core@1.171.27':
resolution: {integrity: sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==}
engines: {node: '>=20.19'}
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -8880,6 +8935,9 @@ packages:
resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
cookie-es@3.1.1:
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -10898,6 +10956,10 @@ packages:
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
isbot@5.2.2:
resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==}
engines: {node: '>=18'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -13417,10 +13479,20 @@ packages:
peerDependencies:
seroval: ^1.0
seroval-plugins@1.6.4:
resolution: {integrity: sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==}
engines: {node: '>=10'}
peerDependencies:
seroval: ^1.0
seroval@1.5.1:
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
engines: {node: '>=10'}
seroval@1.6.4:
resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==}
engines: {node: '>=10'}
serve-static@1.16.3:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
@@ -21250,6 +21322,33 @@ snapshots:
tailwindcss: 4.2.1
vite: 7.3.1(@types/node@22.19.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
'@tanstack/history@1.162.1': {}
'@tanstack/react-router@1.170.32(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/history': 1.162.1
'@tanstack/react-store': 0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tanstack/router-core': 1.171.27
isbot: 5.2.2
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/react-store@0.9.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/store': 0.9.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
'@tanstack/router-core@1.171.27':
dependencies:
'@tanstack/history': 1.162.1
cookie-es: 3.1.1
seroval: 1.6.4
seroval-plugins: 1.6.4(seroval@1.6.4)
'@tanstack/store@0.9.3': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
@@ -23067,6 +23166,8 @@ snapshots:
convert-to-spaces@2.0.1: {}
cookie-es@3.1.1: {}
cookie-signature@1.2.2: {}
cookie@0.6.0: {}
@@ -25575,6 +25676,8 @@ snapshots:
isarray@2.0.5: {}
isbot@5.2.2: {}
isexe@2.0.0: {}
isexe@3.1.5: {}
@@ -29179,8 +29282,14 @@ snapshots:
dependencies:
seroval: 1.5.1
seroval-plugins@1.6.4(seroval@1.6.4):
dependencies:
seroval: 1.6.4
seroval@1.5.1: {}
seroval@1.6.4: {}
serve-static@1.16.3:
dependencies:
encodeurl: 2.0.0
+199
View File
@@ -0,0 +1,199 @@
---
name: tanstack-start
description: Build JSON-defined TanStack Start applications with @json-render/tanstack-start. Use for Start route specs, splat routing, SSR loaders, head metadata, layouts, and client navigation. Do not use for generic TanStack Router apps that do not use json-render.
---
# @json-render/tanstack-start
Use this integration when a TanStack Start app needs complete pages or routes
described by json-render specs.
## Install
```bash
npm install @json-render/core @json-render/react @json-render/tanstack-start
```
## Application Spec
Include the server-safe built-in definitions in the generation catalog. The
renderer supplies their component implementations.
```typescript
import { defineCatalog } from "@json-render/core";
import {
schema,
startComponentDefinitions,
} from "@json-render/tanstack-start/server";
const catalog = defineCatalog(schema, {
components: {
...startComponentDefinitions,
Card: cardDefinition,
Shell: shellDefinition,
Navigation: navigationDefinition,
Home: homeDefinition,
Post: postDefinition,
},
actions: {},
});
```
Then use `StartAppSpec` and TanStack Router route patterns:
```typescript
import type { StartAppSpec } from "@json-render/tanstack-start";
export const spec: StartAppSpec = {
metadata: {
title: { default: "Site", template: "%s | Site" },
},
layouts: {
main: {
root: "shell",
elements: {
shell: { type: "Shell", props: {}, children: ["nav", "slot"] },
nav: { type: "Navigation", props: {}, children: [] },
slot: { type: "Slot", props: {}, children: [] },
},
},
},
routes: {
"/": {
layout: "main",
metadata: { title: "Home" },
page: {
root: "home",
elements: {
home: { type: "Home", props: {}, children: [] },
},
},
},
"/posts/$slug": {
layout: "main",
loader: "post",
staticParams: [{ slug: "hello" }],
page: {
root: "post",
elements: {
post: {
type: "Post",
props: { value: { $state: "/post" } },
children: [],
},
},
},
},
},
};
```
Routes use `/posts/$slug` for named parameters and `/docs/$` for a splat.
Splat loader parameters are slash-delimited strings under `_splat`. Escape
route-key slashes as `~1` when generating RFC 6902 patches.
Every layout needs a `Slot` element. Declare `Slot` and `Link` through
`startComponentDefinitions`; do not require consumers to register React
implementations for them.
## Server Helpers
```typescript
import { createStartApp } from "@json-render/tanstack-start/server";
export const { getPageData, getHead, getStaticPaths } = createStartApp({
spec,
loaders: {
post: async ({ slug }) => ({ post: await getPost(slug as string) }),
},
});
```
State merge precedence is application state, layout state, page state, then
loader data. `getHead` merges app and route metadata into TanStack `meta` and
`links` descriptors. `getStaticPaths` includes static routes plus dynamic
routes with `staticParams`. Convert its strings to `{ path }` objects for
TanStack Start's top-level `pages` plugin option. Loader params are URL-decoded,
while values from `staticParams` are URL-encoded in generated paths.
Route matching treats trailing slashes as optional and accepts encoded or
decoded pathname representations so loader data and metadata resolve the same
static route.
## Route Wiring
```tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
} from "@json-render/tanstack-start";
import { getHead, getPageData } from "@/lib/json-app";
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: () => <PageRenderer {...Route.useLoaderData()} />,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
```
TanStack Router loaders run on both the server and client. If a spec factory or
named loader uses credentials, database clients, or server-only imports, wrap
`getPageData` and `getHead` in a TanStack Start `createServerFn`; do not import
that server code directly into an isomorphic route loader.
## Root Provider
Wrap the root route's `Outlet` with `StartAppProvider`. Render `HeadContent` so
metadata from `getHead` reaches the document.
```tsx
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router";
import { StartAppProvider } from "@json-render/tanstack-start";
import { spec } from "@/lib/spec";
export const Route = createRootRoute({
component: () => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<StartAppProvider
registry={registry}
handlers={handlers}
spec={spec}
>
<Outlet />
</StartAppProvider>
<Scripts />
</body>
</html>
),
});
```
Use `StartLoading`, `StartErrorBoundary`, and `StartNotFound` for TanStack
Router's `pendingComponent`, `errorComponent`, and `notFoundComponent` options.
When `StartAppProvider` receives `spec`, each component selects the matched
route's corresponding fallback. Explicit fallback props override that lookup.
Pass named `$computed` implementations through `StartAppProvider.functions`.
The default error boundary invalidates the router and reruns a failed loader
when the user selects **Try again**.
Import React components from `@json-render/tanstack-start`. Import `schema`,
`createStartApp`, `matchRoute`, `resolveMetadata`, and static path helpers from
`@json-render/tanstack-start/server`.