@bgub/fig-tanstack-start
v0.1.2
Published
TanStack Start adapter for Fig
Readme
@bgub/fig-tanstack-start
The TanStack Start adapter for Fig. TanStack owns builds, requests, route loading, redirects, and server-function transport; Fig owns rendering, data resources, asset resources, and the single data store used by loaders and components.
pnpm add @bgub/fig-tanstack-start @bgub/fig-tanstack-routerAdd the adapter to Vite:
import { tanstackStart } from "@bgub/fig-tanstack-start/plugin/vite";
const plugins = [tanstackStart()];The plugin supplies the default client and server entries, including streamed
Fig SSR, full-document hydration, and state-preserving Fig Fast Refresh. It
currently uses TanStack's Solid target
as a private compiler compatibility layer because plugin core has no custom
framework target. The generator currently normalizes file-route constructor
imports to its Solid package ID; the plugin maps that ID directly to Fig, and no
Solid Router or Start adapter runtime is installed or bundled. TypeScript needs the corresponding
compiler-only paths entry:
{
"compilerOptions": {
"paths": {
"@tanstack/solid-router": ["./node_modules/@bgub/fig-tanstack-router"],
"@tanstack/solid-start": ["./node_modules/@bgub/fig-tanstack-start"]
}
}
}Response preload headers
The default server entry leaves response Link headers disabled because a CDN
may cache and replay request-specific asset URLs. A custom server entry can opt
in globally or provide a resource filter:
import { createFigStartHandler } from "@bgub/fig-tanstack-start/server";
const fetch = createFigStartHandler({
preloadHeader: {
filter: (resource) => resource.href.startsWith("/assets/"),
},
});
export default { fetch };The header contains assets discovered before Fig's document shell becomes ready. Assets discovered later through Suspense or Payload remain in the HTML stream and cannot be added after the response is created.
The Start mapping lets the generated registration footer carry middleware
context types from a conventional src/start.ts into createServerFn.
Create the router with a root-neutral Fig store:
import { createStartDataContext } from "@bgub/fig-tanstack-start";
import {
createRouter,
createRootRouteWithContext,
} from "@bgub/fig-tanstack-router";
const startData = createStartDataContext();
const rootRoute = createRootRouteWithContext<typeof startData.context>()({
component: Document,
});
export const router = createRouter({
...startData,
routeTree: rootRoute,
});The root document renders route-managed assets followed by Start's combined Fig data and TanStack script transport:
import { StartScripts } from "@bgub/fig-tanstack-start";
import { HeadContent, Outlet } from "@bgub/fig-tanstack-router";
function Document() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<Outlet />
<StartScripts />
</body>
</html>
);
}StartScripts establishes where the adapter writes the Fig store with Fig's value codec, inserts initial Payload responses, and renders TanStack's bootstrap scripts. The separate data block is intentional: TanStack freezes its hydration graph before Fig renders, while the adapter waits for the document render before snapshotting settled data first discovered in route components. Client router creation decodes the data before TanStack hydration can start route loaders; hydrateStart repeats that step idempotently as a fallback before hydrateRoot adopts the same client store. The first readData therefore hits the hydrated entry without re-running its loader, and invalidateData operates directly on the live root store.
CSS
Use TanStack Start's standard side-effect CSS imports and enable build-time inlining when desired:
import "./styles.css";tanstackStart({
server: { build: { inlineCss: true } },
});HeadContent emits the server's inline style and adopts its contents when
TanStack dehydrates an empty client placeholder, so full-document hydration
keeps the page styled.
Request and function middleware
The package root also exports TanStack's createStart, createMiddleware, and
createCsrfMiddleware. A conventional src/start.ts configures global request
and server-function middleware:
import {
createCsrfMiddleware,
createMiddleware,
createStart,
} from "@bgub/fig-tanstack-start";
const requestContext = createMiddleware({ type: "request" }).server(
({ request, next }) =>
next({ context: { requestId: request.headers.get("x-request-id") } }),
);
export const startInstance = createStart(() => ({
requestMiddleware: [
requestContext,
createCsrfMiddleware({
filter: (context) => context.handlerType === "serverFn",
}),
],
}));Start's global async context remains request-local across interleaved SSR and
server-function work. Redirects thrown by generated route loaders or
beforeLoad use Router Core's normal server and client redirect handling.
Server functions
The package root exports TanStack's createServerFn. The Vite plugin compiles
the handler into the server build and the browser call into an RPC request:
import { createServerFn } from "@bgub/fig-tanstack-start";
export const renameUser = createServerFn({ method: "POST" })
.validator((input: { id: string; name: string }) => input)
.handler(async ({ data }) => {
await database.users.rename(data.id, data.name);
});An async event must capture the Fig store before its first await when it
needs to refresh data afterward:
import { readDataStore } from "@bgub/fig";
const data = readDataStore();
await renameUser({ data: { id, name }, signal });
data.invalidateData(userResource, id);Payload routes
Payload routes keep Payload-rendered component trees out of the client bundle while using the same Fig data store as ordinary route data. The smallest route is one colocated declaration:
// profile.payload.tsx
import { createPayloadComponent } from "@bgub/fig-dom";
import { serverPayload } from "@bgub/fig-tanstack-start/payload";
import { Profile } from "./Profile.server.tsx";
export const ProfilePage = createPayloadComponent<{ id: string }>({
key: ["profile-payload"],
load: serverPayload(Profile),
});serverPayload accepts a component or render callback; it is the semantic
boundary that the compiler extracts into a private server function. It may be
async. The browser bundle keeps the component handle, cache, and RPC stub; it
omits the server render and imports used only by it. Keep the Payload component
declaration in a client-importable module such as .payload.tsx because the
route imports that handle. The rendered component itself may live in a
TanStack-protected .server.tsx module.
When the tree grows, the callback can import ordinary components. Those imports
also stay out of the browser bundle when only the callback uses them. A filename
does not cause a component to render through Payload—serverPayload does.
Applications do not call createServerFn or renderPayloadResponse for Payload
resources.
Mark the exceptional SSR-plus-hydration boundary with Isomorphic and an
ordinary static component import. For example, a separate Payload component can
contain the boundary:
import type { FigNode } from "@bgub/fig";
import { Isomorphic } from "@bgub/fig-tanstack-start/payload";
import { LikeButton } from "./LikeButton.tsx";
export function Profile({ id }: { id: string }): FigNode {
return <Isomorphic component={LikeButton} userId={id} />;
}The Vite plugin replaces only the component prop with an opaque Payload
reference and generates its server/browser module resolver. The component
remains an ordinary export and can render through Payload elsewhere without
the boundary. component must be a named or default static import.
Applications do not use a .client.tsx suffix, clientReference,
createPayloadClientReferenceResolver, reference ids, or dynamic imports.
The route can start the load early, then render the component normally:
import { ensureRouteData } from "@bgub/fig-tanstack-router";
import { createFileRoute } from "@tanstack/solid-router";
import { ProfilePage } from "../profile.payload.tsx";
export const Route = createFileRoute("/profiles/$id")({
loader: ({ context, params }) =>
ensureRouteData(context, ProfilePage, { id: params.id }),
component: ProfileRoute,
});
function ProfileRoute() {
const { id } = Route.useParams();
return <ProfilePage id={id} />;
}When the route does not need the tree before commit, preload it and return
void; Fig Suspense then owns the pending UI and stream:
import { Suspense } from "@bgub/fig";
export const Route = createFileRoute("/profiles/$id")({
loader: ({ context, params }) => {
context.data.preloadData(ProfilePage, { id: params.id });
},
component: () => (
<Suspense fallback={<p>Streaming profile…</p>}>
<ProfileRoute />
</Suspense>
),
});On SSR, Fig decodes and renders the root once, retains Payload-discovered asset resources on the rows that declared them, and embeds the response bytes for hydration. This includes Payload resources registered after the document shell starts. The document renderer emits each asset before its dependent HTML segment, including streamed Suspense holes. The browser adopts the embedded bytes without a second server-function call. Shell HTML streams while Suspense holes settle; TanStack starts full-document hydration after each complete initial Payload response is embedded in a keyed carrier. Client navigation and refresh use the same raw response path.
The Vite adapter follows ordinary component imports from each serverPayload
render and compiles their static stylesheet imports into Payload asset
dependencies. Import CSS normally; no manual
assets(stylesheet(...)) wrapper or ?url import is needed. A Payload-rendered
component's stylesheet is copied from the server build into the public client
output. An Isomorphic component's hashed client CSS is attached through the
generated manifest. Both use the existing Payload asset row and reveal gate
and are emitted only when their component renders.
The demo-tanstack-start app exercises the
adapter through Vite's production client and SSR builds: generated and split
file routes, streamed SSR, Router dehydration, Fig-owned data serialization,
full-document hydration, request-derived themes, view transitions, live
data-resource invalidation, nested routes, and post and asset Payload trees all
run through public adapter entries. The asset route embeds two independent
Payload resources and hydrates an explicit Isomorphic component whose CSS and
SVG are emitted through the production builds.
