@jxdltd/tanstack
v0.0.1
Published
Helpers for [TanStack Start](https://tanstack.com/start) projects.
Readme
@jxdltd/tanstack
Helpers for TanStack Start projects.
The package is split into subpath entrypoints. The first one shipped is /og — a typed, runtime, per-route OG image system that drops into any TanStack Start app:
- One config file keyed by your typed route paths from
routeTree.gen.ts. - One template file for design + dimensions + fonts.
- One server route at
/og/$that renders PNGs via Satori + Resvg. - One head spread in each route that should expose an
og:image.
pnpm add @jxdltd/tanstackQuick start
1. og/config.ts — your data, keyed by route paths
// src/og/config.ts
import { defineOgConfig, ignore } from "@jxdltd/tanstack/og";
import { allPosts } from "content-collections";
export default defineOgConfig({
"/": () => ({
title: "My site",
description: "An opinionated dev blog.",
type: "website",
}),
"/blog/": () => ({
title: "Blog",
description: "All posts.",
type: "website",
}),
"/blog/$slug": ({ params }) => {
const post = allPosts.find((p) => p.slug === params.slug);
if (!post) return ignore;
return {
title: post.title,
description: post.excerpt,
type: "article",
author: post.author,
date: post.date,
tag: post.tag,
};
},
"/og/$": () => ignore,
});Keys are constrained to your project's FileRoutesByPath (which TanStack Router augments via routeTree.gen.ts). Every known route is required in the config — omitting one is a TypeScript error. Use ignore (returned from the entry function) when a route shouldn't expose an OG card.
params is typed per-key from the path: /blog/$slug gets { slug: string }, /files/$ gets { _splat: string }, / gets Record<string, never>.
2. og/template.tsx — design, dimensions, fonts
// src/og/template.tsx
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { defineOgTemplate, type OgTemplateFont } from "@jxdltd/tanstack/og";
const sans = (file: string) =>
readFileSync(join(process.cwd(), "node_modules/geist/dist/fonts/geist-sans", file));
const loadFonts = (): OgTemplateFont[] => [
{ name: "Geist", data: sans("Geist-Regular.ttf"), weight: 400, style: "normal" },
{ name: "Geist", data: sans("Geist-Medium.ttf"), weight: 500, style: "normal" },
];
export default defineOgTemplate({
width: 1200,
height: 630,
fonts: loadFonts, // function = lazy: only invoked on first render
render: ({ data }) => (
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
padding: 64,
fontFamily: "Geist",
background: "#fff",
}}
>
<h1 style={{ fontSize: 72, fontWeight: 700, marginTop: "auto" }}>{data.title}</h1>
{data.description ? <p style={{ fontSize: 28, color: "#555" }}>{data.description}</p> : null}
</div>
),
});fonts accepts either an array (eager) or a function returning an array (lazy). Use the function form if your fonts come from node_modules or otherwise touch the filesystem at module evaluation time — the resolver runs once on the first request and the result is reused.
3. routes/og/$.ts — the server route
// src/routes/og/$.ts
import { createFileRoute } from "@tanstack/react-router";
import { createOgHandler } from "@jxdltd/tanstack/og/server";
import config from "../../og/config";
import template from "../../og/template";
const handler = createOgHandler({ config, template });
export const Route = createFileRoute("/og/$")({
server: {
handlers: {
GET: ({ request }) => handler({ request }),
},
},
});The handler:
- Strips the
/og/prefix and optional.pngsuffix from the URL. - Matches the remaining path against your config keys. Static keys, named params (
$slug), and splat ($) all work. - Calls the matched entry; a return of
ignoreproduces a404. - Renders via Satori → Resvg.
- Responds
Content-Type: image/png,Cache-Control: public, max-age=31536000, immutable, and anETagderived from the rendered data. - Caches PNGs in-process and dedupes concurrent identical requests.
4. __root.tsx (or per-route) — the head spread
// inside any route's head()
import { ogMeta } from "@jxdltd/tanstack/og/router";
export const Route = createFileRoute("/blog/$slug")({
head: (ctx) => ({
meta: [
// ... your existing title, description, etc.
...ogMeta(ctx, { siteName: "My site", siteUrl: "https://example.com" }),
],
}),
});ogMeta(ctx, options?) reads the route's match.fullPath and params (or the deepest entry of matches[]), substitutes splat params, and emits:
og:image,og:image:width,og:image:heighttwitter:card(summary_large_image),twitter:imageog:image:alt(whensiteNameis provided)twitter:site(whentwitterHandleis provided)
The image URL is <siteUrl>/og/<resolved-path>.png (or relative when siteUrl is omitted, with a build-time warning if you wire one up).
API reference
@jxdltd/tanstack/og
defineOgConfig(config)
Identity helper. Constrains keys to FileRoutesByPath and types each entry's params from its path key. Returns the input.
defineOgTemplate(template)
Identity helper for the template module. Returns the input.
ignore
A unique symbol you return from a config entry to mean "this route shouldn't have an OG card." The handler returns 404 when an entry returns it; ogMeta doesn't inspect this (it just builds a URL — it's the handler that knows whether the URL has content).
fromHead()
Returns a placeholder OgConfigEntry that resolves to { title, description } strings. Useful as a quick scaffold while you fill in real per-route data.
OgData interface
The shape returned by every config entry. Augment via module declaration to add fields:
declare module "@jxdltd/tanstack/og" {
interface OgData {
customField?: string;
}
}The default fields:
| Field | Type |
| ------------- | ------------------------- |
| title | string (required) |
| description | string? |
| type | "website" \| "article"? |
| image | string? |
| author | string? |
| date | string? |
| tag | string? |
RouteParams<P>
Type-level helper that derives the params object for a given route path string. Used internally; exported for advanced cases.
type Slug = RouteParams<"/blog/$slug">; // { slug: string }
type Splat = RouteParams<"/files/$">; // { _splat: string }
type None = RouteParams<"/">; // Record<string, never>
type Multi = RouteParams<"/users/$id/posts/$postId">;
// ^? { id: string; postId: string }@jxdltd/tanstack/og/server
createOgHandler({ config, template, fallback? })
Returns (ctx: { request: Request }) => Promise<Response>.
config— fromdefineOgConfig.template— fromdefineOgTemplate.fallback?— optional(ctx) => Response | Promise<Response>for unmatched paths (default:404).
The matcher is permissive about extension and trailing slashes:
| URL | Matches config key |
| --------------------- | --------------------- |
| /og/index.png | / |
| /og/ | / |
| /og/blog.png | /blog/ (or /blog) |
| /og/blog/foo.png | /blog/$slug |
| /og/files/a/b/c.png | /files/$ |
Param values are URL-decoded before being passed to the entry. The cache key is a hash of JSON.stringify(data) — so when your data changes, the ETag rotates and downstream caches refresh.
@jxdltd/tanstack/og/router
ogMeta(ctx, options?): OgMetaEntry[]
ctx is the head function's argument. Anything with match, matches, or params works (including a plain object for testing).
Options:
| Option | Default | Notes |
| --------------- | ------- | ---------------------------------- |
| siteName | — | Adds og:image:alt. |
| siteUrl | — | When set, image URLs are absolute. |
| twitterHandle | — | Adds name="twitter:site". |
| imageWidth | 1200 | Emitted as og:image:width. |
| imageHeight | 630 | Emitted as og:image:height. |
ogMeta is synchronous — it does not invoke your config entries. It builds the canonical URL based on the matched route path and params, and the handler reads the data when the URL is fetched. This keeps head() cheap and avoids double-fetching.
Patterns
Augmenting OgData with custom fields
// src/og/types.ts
declare module "@jxdltd/tanstack/og" {
interface OgData {
plan?: "free" | "pro";
}
}
export {};Now defineOgConfig and defineOgTemplate see the extra field, and the type of data inside your template's render is the augmented shape.
Fallback for unmatched paths
const handler = createOgHandler({
config,
template,
fallback: () => Response.redirect("/og/index.png"),
});Per-route override on the meta side
ogMeta always emits og:image for the current route. If a particular route should advertise a different image (e.g. a YouTube thumbnail for a video page), drop the ogMeta spread on that route and emit your own meta entries — they'll dedupe by property/name against any root-level entries.
Dev mode and .png URLs
In dev, Vite's static asset middleware intercepts requests with file extensions before they reach TanStack Start's request handler. If your og:image URLs end in .png, add this Vite plugin to your config so the request reaches the route in dev (the build path is unaffected):
// vite.config.ts
const ogDevStripPng = {
name: "og-dev-strip-png",
apply: "serve" as const,
configureServer(server: { middlewares: { use: (fn: unknown) => void } }) {
server.middlewares.use((req: { url?: string }, _res: unknown, next: () => void) => {
if (req.url) {
const m = req.url.match(/^(\/og\/[^?]*)\.png(\?.*)?$/);
if (m) req.url = m[1] + (m[2] ?? "");
}
next();
});
},
};
export default defineConfig({
plugins: [ogDevStripPng /* ... */],
});The handler matches both /og/<path> and /og/<path>.png, so the rewrite is purely about getting the request past Vite's static layer in dev.
Externalising native bindings
@resvg/resvg-js ships native bindings. When using Vite + Nitro, externalise it so Rolldown doesn't try to bundle the .node file:
export default defineConfig({
optimizeDeps: { exclude: ["@resvg/resvg-js"] },
ssr: {
external: ["@resvg/resvg-js"],
optimizeDeps: { exclude: ["@resvg/resvg-js"] },
},
plugins: [
nitro({ rollupConfig: { external: [/^@resvg\//] } }),
// ...
],
});Caching model
- HTTP:
Cache-Control: public, max-age=31536000, immutableplus anETagderived from the rendered data. CDNs absorb everything past the first miss per region. - Origin: in-process
Map<hash, Uint8Array>plus an in-flight promise map so concurrent identical requests share a single render. The cache is unbounded — for long-lived workloads behind a hot CDN this is fine; if you have many distinct cards per process consider clearing periodically.
Limitations
- Runtime only. The handler runs on demand. For fully static deployments where no server is available, prerender the OG paths alongside your HTML pages or invoke the handler at build time.
- Synchronous
ogMeta. Becausehead()is typically synchronous and we don't want to double-fetch your data,ogMetaonly builds URLs — it doesn't run config entries. If you need per-content cache busting in URLs, append a query string yourself (?v=<hash>). - Native bindings.
@resvg/resvg-jsis Node-only. For Cloudflare Workers / Deno Deploy, you'll want a wasm fallback (not yet shipped).
