@valbuild/tanstack
v0.150.23
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/$"src/router.tsx — import @valbuild/tanstack/autotag first
// src/router.tsx
import "@valbuild/tanstack/autotag";
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";The first import, before the router and the route tree. It makes Val's edit
tags into data-val-path attributes, which is what makes content
click-to-edit -- and keeps them out of a visitor's text when content analytics
is on. @valbuild/tanstack does it too, but under vite dev a module that
ran before it keeps React's own functions, so in development some components
would not be editable and the leak warning would name their text. A
production build is not affected.
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>
),
});Add the draft prop from Preview and draft mode so a
draft page is rendered as the draft on the server too.
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 — or, when ValProvider is given the request's
draft (see Preview and draft mode), the draft — 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.
Render the draft on the server, too. Without it the server renders the PUBLISHED page and the browser swaps the draft in once the overlay has loaded, so every draft page load shows the old text first and the new text a moment later. Read the request's draft in the site layout's loader and hand it to the provider:
// src/val/server.ts: add `fetchValDraft` to what initValContent returns
export const { fetchValDraft /* , fetchVal, ... */ } = initValContent(
config,
valModules,
{ draftMode },
);
// src/routes/_site.tsx
const getValDraft = createServerFn().handler(() => fetchValDraft());
export const Route = createFileRoute("/_site")({
// On the server only: the overlay keeps the page up to date after that.
loader: () => (typeof document === "undefined" ? getValDraft() : null),
component: SiteLayout,
});
function SiteLayout() {
const draft = Route.useLoaderData();
return (
<ValProvider config={config} suspend draft={draft}>
{/* ... */}
</ValProvider>
);
}fetchValDraft returns null — after one cookie lookup, with no read — unless
the request is in draft mode, so visitors pay nothing. In draft mode it returns
the modules the draft changes, which the server renders with and the browser
hydrates from: both produce the same page, showing the draft from the first
paint. It becomes click-to-editable as each component finishes hydrating, and
the overlay takes over from there.
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). With
draft given, the server already has that page's content, so its first render
does not 404 either.
Content analytics
Val can count which content your visitors see. On a site Val hosts it is
off by default, and a setting of the hosted project rather than of the
code: the owner turns it on in the project's settings, and Val's hosting hands
it to the site as the VAL_ANALYTICS variable, along with the id of the
content version each page is rendered from.
A site needs no code for it. With no analytics prop, <ValProvider>
reads the setting itself: the server render reads what the platform handed it
and writes it into the page as a few bytes of JSON, and the browser reads it
back. So switching it on or off in the project's settings is all it takes --
no rebuild, and nothing in the layout. While it is off, nothing is written.
It is never read in the browser from the environment, and needs no VITE_
prefix: a variable the browser could read is baked into the bundle when it is
built, which would make it a second switch, in the code, that keeps counting
after the owner has turned it off.
To decide in the code instead, pass it: the val.server.ts the platform
writes for a hosted project exports fetchValAnalytics(), which reads the
same two values into a ValAnalytics, and what is passed wins.
// src/routes/_site.tsx
const getValPage = createServerFn().handler(async () => {
const [draft, analytics] = await Promise.all([
fetchValDraft(),
fetchValAnalytics(),
]);
return { draft, analytics };
});
function SiteLayout() {
const page = Route.useLoaderData();
return (
<ValProvider
config={config}
suspend
draft={page?.draft}
analytics={page?.analytics}
>
{/* ... */}
</ValProvider>
);
}With it on, content read with the hooks is tagged for every visitor after
hydration, so each element showing content carries a data-val-path to count
(look for it in the browser's element inspector: it is not in the HTML the
server sends). A small script, loaded after hydration, then counts what each
page rendered and what was on screen, and sends it to /api/val/analytics with
the content version, so a page can be compared before and after a publish. No
cookies, no storage and no visitor IDs, and nothing is sent in draft mode, in
the Studio or from an automated browser.
To see the mode working in local development, set VAL_ANALYTICS=true in
.env.development: the development server reads it, and nothing else does.
On your own hosting
A site Val does not host is counted too, on the Custom hosting plan. Its own
Val server answers /api/val/analytics and forwards each beacon to Val, with
the VAL_API_KEY and VAL_PROJECT it already has for publishing. It needs
two things:
- Content analytics switched on in the project's settings on val.build, under Analytics. Switched off there, Val answers the forwarded beacons with a 410 and the script stops sending them.
- The layout passing
analytics, since no hosting hands it the setting. Read it on the server, so it is never baked into the browser bundle:
// src/routes/_site.tsx
const getValPage = createServerFn().handler(async () => ({
draft: await fetchValDraft(),
analytics: { enabled: process.env.VAL_ANALYTICS === "true" },
}));and set VAL_ANALYTICS=true where the site is deployed. The visitor's country
is read from the host's own header when there is one (x-vercel-ip-country,
cf-ipcountry or cloudfront-viewer-country); the IP address never is.
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 |
| client hooks render published SSR | ValProvider draft renders the draft during SSR |
| no content analytics yet | ValProvider analytics |
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.
