@valbuild/tanstack
v0.134.1
Published
Val for TanStack Start: hard-coded content - super-charged
Readme
@valbuild/tanstack
Val for TanStack Start (React).
Content lives in .val.ts files in your repository — type-checked, refactorable,
reviewable in a pull request — and non-developers edit it in Val Studio, which
this package mounts at /val.
Table of contents
- Installation
- Wiring it up
- Reading content
- Routes
- Preview and draft mode
- Images
- Coding agents (MCP)
- Differences from
@valbuild/next - Schema reference
Installation
npm install @valbuild/tanstack
npm install --save-dev @valbuild/cli @valbuild/eslint-pluginRequires TanStack Start ≥ 1.130 and React 19.
Tell the route generator that *.val.ts files are not routes. A Val module
for a route lives beside the route file, and the generator scans everything
under src/routes — so without this it reads posts.$postId.val.ts as a route
at /posts/$postId/val and warns on every run:
// vite.config.ts
tanstackStart({
router: { routeFileIgnorePattern: "\\.val\\.[tj]sx?$" },
});// tsr.config.json — the same, for the standalone `tsr generate` CLI
{ "routeFileIgnorePattern": "\\.val\\.[tj]sx?$" }Wiring it up
val.config.ts
import { initVal } from "@valbuild/tanstack";
const { s, c, val, config, tanstackRouter, externalPageRouter } = initVal({
project: "yourorg/your-project", // omit while running locally
});
export type { t } from "@valbuild/tanstack";
export { s, c, val, config, tanstackRouter, externalPageRouter };val.modules.ts
import { modules } from "@valbuild/tanstack";
import { config } from "./val.config";
export default modules(config, [
{ def: () => import("./src/routes/index.val") },
{ def: () => import("./src/content/authors.val") },
]);src/val/server.ts — the API and the server-side readers
import { initValServer, initValContent } from "@valbuild/tanstack/server";
import { config } from "../../val.config";
import valModules from "../../val.modules";
const { valApiHandler, draftMode } = initValServer(valModules, { ...config });
export const {
fetchValStega: fetchVal,
fetchValRouteStega: fetchValRoute,
fetchValKeyStega: fetchValKey,
fetchValRouteUrl,
} = initValContent(config, valModules, { draftMode });
export { valApiHandler };Build both from one draftMode object. The API is what turns preview on for a
browser and the readers are what has to notice; two independently created
defaults would each work and disagree.
src/routes/api/val.$.ts — mount the API
import { createFileRoute } from "@tanstack/react-router";
import { valApiHandler } from "../../val/server";
export const Route = createFileRoute("/api/val/$")({
server: {
handlers: {
GET: ({ request }) => valApiHandler(request),
POST: ({ request }) => valApiHandler(request),
PUT: ({ request }) => valApiHandler(request),
PATCH: ({ request }) => valApiHandler(request),
DELETE: ({ request }) => valApiHandler(request),
HEAD: ({ request }) => valApiHandler(request),
},
},
});/api/val/$, not /api/val: every endpoint has a sub-path, so the splat is the
whole surface.
src/routes/val/ — Val Studio
Three files, because the Studio navigates within itself (it pushes paths like
/val/~/..., which have to resolve to the same page on a reload):
// src/routes/val/route.tsx
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { ValApp, ValModulesClient } from "@valbuild/tanstack";
import { config } from "../../../val.config";
import valModules from "../../../val.modules";
export const Route = createFileRoute("/val")({
component: () => (
<ValApp config={config}>
<ValModulesClient modules={valModules} />
<Outlet />
</ValApp>
),
});// src/routes/val/index.tsx and src/routes/val/$.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/val/")({ component: () => null }); // and "/val/$"The site's layout — ValProvider
Put the site's own chrome and ValProvider in a pathless layout route, not
in __root. __root is the shell for every route including /val, and the
Studio should not be rendered inside the site it is editing.
// src/routes/_site.tsx
import { Outlet, createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { ValModulesClient, ValProvider } from "@valbuild/tanstack";
import { config } from "../../val.config";
import valModules from "../../val.modules";
export const Route = createFileRoute("/_site")({
component: () => (
<ValProvider config={config} suspend>
<ValModulesClient modules={valModules} />
<Suspense fallback={null}>
<Outlet />
</Suspense>
</ValProvider>
),
});A pathless layout adds no URL segment: _site.index.tsx is still /. Val
modules named after those files follow the same rule, so
_site.posts.$postId.val.ts holds /posts/... keys.
The <Suspense> boundary is required when you pass suspend. With none
between a suspending component and the root, React has nowhere to put a
fallback and the tree stops updating — which looks like the Studio failing to
load. TanStack Start provides no boundary of its own.
src/val/client.ts — the hooks
import { initValClient } from "@valbuild/tanstack/client";
import { config } from "../../val.config";
export const {
useValStega: useVal,
useValRouteStega: useValRoute,
useValRouteUrl,
} = initValClient(config);Reading content
Prefer the hooks. They work in both places a component runs: during SSR they resolve the published content, and in a browser with the Studio open they resolve what the editor currently holds — so an edit appears as it is typed, with no round trip and no loader.
function Page() {
const page = useValRoute(pageVal, Route.useParams());
const authors = useVal(authorsVal);
if (page === null) throw notFound();
return <h1>{page.title}</h1>;
}Read on the server when the content has to exist before the component does —
head/meta tags, a redirect decided by content, a notFound() that must happen
during the request. Two things to know:
- It has to go through
createServerFn. A routeloaderruns in the browser too — that is what makes a client navigation work — so importingsrc/val/server.tsstraight into a loader pulls@valbuild/server, and Node'sfswith it, into the client bundle.createServerFnis compiled away on the client and everything only its handler uses goes with it. - Content that arrives through a loader on a first, server-rendered load is not click-to-editable. The edit tags are attached as JSX is created, and Val only starts attaching them once hydration has told it the Studio is open — by which time the component has already rendered its loader data. A client-side navigation to the same route tags it normally.
const getDoc = createServerFn()
.validator((params: { slug: string }) => params)
.handler(async ({ data }) => ({ doc: await fetchValRoute(pageVal, data) }));
export const Route = createFileRoute("/_site/docs/$slug")({
loader: async ({ params }) => {
const { doc } = await getDoc({ data: params });
if (!doc) throw notFound();
return { doc };
},
component: Doc,
});Routes
A Val module for a route is named after the route file it sits beside: the
.tsx becomes .val.ts. Its keys are the URLs that route serves.
| Route file | Val module | Keys look like |
| ---------------------------------- | ------------------------------------- | -------------------- |
| src/routes/index.tsx | src/routes/index.val.ts | / |
| src/routes/about.tsx | src/routes/about.val.ts | /about |
| src/routes/posts.$postId.tsx | src/routes/posts.$postId.val.ts | /posts/hello-world |
| src/routes/posts/$postId.tsx | src/routes/posts/$postId.val.ts | the same route |
| src/routes/docs.$.tsx (splat) | src/routes/docs.$.val.ts | /docs/a/b |
| src/routes/_site.posts.$id.tsx | src/routes/_site.posts.$id.val.ts | /posts/1 |
| src/routes/(marketing)/about.tsx | src/routes/(marketing)/about.val.ts | /about |
. and / both separate segments, $param is a parameter, $ on its own is a
splat, and index, route, (groups) and _pathless layouts contribute no
URL segment — exactly TanStack Router's own rules.
// src/routes/posts.$postId.val.ts
import { s, c, tanstackRouter } from "../../val.config";
export default c.define(
"/src/routes/posts.$postId.val.ts",
s.router(tanstackRouter, s.object({ title: s.string() })),
{ "/posts/hello-world": { title: "Hello world" } },
);Then, in the route's component, hand useValRoute the route's own params
unchanged — including _splat, which is what TanStack calls a splat parameter
and what Val expects:
const post = useValRoute(pageVal, Route.useParams());Val validates every key against the route's pattern, so a key that no URL of that route could produce is a content error rather than a page that silently never renders. Val Studio shows these modules as a sitemap under Pages, and an editor can add a page there — which creates the key.
For links out of your site, externalPageRouter takes whole URLs instead.
Preview and draft mode
Val's preview shows unpublished edits. TanStack Start has no draftMode() of
its own, so this package brings a cookie (val_draft_mode, valDraftMode() in
@valbuild/tanstack/server). It is a mode switch, not a credential: every draft
read also carries Val's session cookie, which is a signed JWT the server
verifies, so a forged draft cookie gets published content.
suspend on ValProvider makes useValStega / useValRouteStega wait for
draft data before rendering, so a page that exists only in an unpublished
draft renders instead of 404ing. Visitors without the Val Enable cookie pay
nothing for it. It needs React 19 and a <Suspense> boundary (see above).
Images
ValImage is a plain <img> that keeps the edit tags where the Studio can see
them, and carries the source's own width/height and its hotspot as
object-position:
import { ValImage } from "@valbuild/tanstack";
<ValImage src={page.hero.image} style={{ maxWidth: "20rem" }} />;Uploads land in public/val by default, which Vite serves at /val/.... That
shares a prefix with the Studio route, and Vite's static handling wins, so both
work — but if you would rather keep them apart, s.image({ directory }) takes
another location.
Coding agents (MCP)
initValMcp from @valbuild/tanstack/server gives an MCP host Val's content
tools — read schemas, look content up, validate it, edit it. It is the same
implementation @valbuild/next uses; mount it on a server route with the MCP
SDK of your choice. See @valbuild/mcp.
Differences from @valbuild/next
| Next | TanStack Start |
| ----------------------------------- | -------------------------------------------------- |
| nextAppRouter | tanstackRouter |
| app/blogs/[blog]/page.val.ts | src/routes/blogs.$blog.val.ts |
| initValRsc (@valbuild/next/rsc) | initValContent (@valbuild/tanstack/server) |
| draftMode() from next/headers | valDraftMode() (a cookie this package owns) |
| valNextAppRouter | valApiHandler — a Request in, a Response out |
| ValImage wraps next/image | ValImage is an <img> |
| fetchVal in a Server Component | hooks in components; createServerFn for loaders |
Schema reference
The schema types — s.string(), s.richtext(), s.image(), s.record(),
s.discriminatedUnion(), s.keyOf(), s.route(), and the rest — are the same in every Val
package and are documented in full at
val.build/docs and in
@valbuild/next's README.
