npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

npm install @valbuild/tanstack
npm install --save-dev @valbuild/cli @valbuild/eslint-plugin

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

  1. It has to go through createServerFn. A route loader runs in the browser too — that is what makes a client navigation work — so importing src/val/server.ts straight into a loader pulls @valbuild/server, and Node's fs with it, into the client bundle. createServerFn is compiled away on the client and everything only its handler uses goes with it.
  2. 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:

  1. 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.
  2. 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.