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

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/$"

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 doeshead/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.

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.