writing
← all postsPer-route OG image generation for TanStack Start
Build-time PNG pipelines are a lot of moving parts for what is, ultimately, one image per route. @jxdltd/tanstack/og turns the whole thing into a typed config file, a JSX template, a six-line server route, and a head spread — same code path in dev and prod.
I keep rewriting the same Open Graph image pipeline. Every TanStack Start project that ships a blog ends up with a build-time script that loads fonts from node_modules, scans content/, hashes the inputs, calls satori then resvg, writes PNGs into public/og/, and wires og:image URLs into the head. It works. It's also a lot of plumbing for something that boils down to "one image per route".
@jxdltd/tanstack/og is the cleaned-up, generalised version of that pipeline. Two user files (a typed config and a JSX template), a six-line server route, and a head spread. Cards render at runtime with aggressive cache headers — same code path in dev and prod, no build step, content edits flip the ETag automatically.
Production
These two cards are rendered live, on demand, by createOgHandler running on the respective sites — what crawlers see is exactly what you see here.


The same package powers OG generation on auvia.io, policystack.dev, and this site.
Setup
Four touch points. pnpm add @jxdltd/tanstack, then:
1. The template — design, dimensions, fonts.
import { defineOgTemplate, type OgTemplateFont } from "@jxdltd/tanstack/og";
import GeistRegular from "./fonts/Geist-Regular.ttf?inline";
import GeistMedium from "./fonts/Geist-Medium.ttf?inline";
const decode = (dataUrl: string) =>
Buffer.from(dataUrl.split(",")[1], "base64");
const fonts: OgTemplateFont[] = [
{ name: "Geist", data: decode(GeistRegular), weight: 400 },
{ name: "Geist", data: decode(GeistMedium), weight: 500 },
];
export default defineOgTemplate({
width: 1200,
height: 630,
fonts,
render: ({ data }) => (
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
padding: 64,
fontFamily: "Geist",
background: "#fff",
}}
>
<h1 style={{ fontSize: 72, fontWeight: 500, marginTop: "auto" }}>
{data.title}
</h1>
{data.description ? (
<p style={{ fontSize: 28, color: "#555" }}>{data.description}</p>
) : null}
</div>
),
});2. The config — your data, keyed by typed route paths.
import { defineOgConfig, ignore } from "@jxdltd/tanstack/og";
import { allPosts } from "content-collections";
export default defineOgConfig({
"/": () => ({
title: "Jamie Davenport",
description: "Software engineer, entrepreneur, investor.",
type: "website",
}),
"/writing/$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 ?? "Jamie Davenport",
date: post.date,
};
},
"/og/$": () => ignore,
});Keys are constrained to FileRoutesByPath from your generated route tree. Forget a route, typo a path, rename a route — TypeScript catches it. params is typed per-route: /writing/$slug gets { slug: string }. Returning ignore produces a 404, so unknown slugs and the OG route itself don't render cards.
3. The server route — six lines.
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 .png suffix, matches the path against your config (static, named params, splat), runs the entry, satori → resvg → PNG, and responds with Cache-Control: public, max-age=31536000, immutable plus an ETag derived from the rendered data. Concurrent identical requests share a single in-flight render.
4. The head spread — one line per route that should expose an og:image.
import { ogMeta } from "@jxdltd/tanstack/og/router";
head: (ctx) => ({
meta: [
// ...your existing title, description, etc.
...ogMeta(ctx, {
siteName: "JXD",
siteUrl: "https://jxd.dev",
twitterHandle: "@jamiedavenport",
}),
],
}),ogMeta is synchronous. It reads the matched route from ctx, substitutes params, and emits the canonical og:image / og:image:width / og:image:height / twitter:card / twitter:image set. It does not invoke your config entries — that's the handler's job at request time, which keeps head() cheap and avoids double-fetching your data layer.
An AI prompt
Most of the wiring is mechanical and per-project. If you'd rather not write it by hand, here's a prompt that works well in Claude Code, Cursor, or anything else with codebase access. Drop it in at the root of an existing TanStack Start project:
Add per-route OG image generation to this project using
@jxdltd/tanstack/og.
1. Install with `pnpm add @jxdltd/tanstack` (also add @resvg/resvg-js
as a direct dependency so pnpm hoists it for runtime resolution).
2. Create src/og/template.tsx with `defineOgTemplate` — width 1200,
height 630, render a JSX layout that matches the look of this
site (read existing colours/typography from src/styles.css and
src/components). Vendor any TTFs into src/og/fonts/ and import
them with `?inline`.
3. Create src/og/config.ts with `defineOgConfig`. Add one entry per
route in src/routeTree.gen.ts. For static routes return constant
OgData; for content-driven routes look up the data the loader
already uses. Return `ignore` for routes that shouldn't have a
card and for the `/og/$` route itself.
4. Create src/routes/og/$.ts that calls `createOgHandler` and
exports it as the GET handler.
5. Update src/lib/seo.ts (or equivalent) so `head()` spreads
`ogMeta(ctx, { siteName, siteUrl, twitterHandle })`. Pass the
head ctx through from each route. Drop any manual og:image /
twitter:image emission.
6. In vite.config.ts, externalise @resvg/resvg-js for both ssr and
the nitro rolldown config.
7. Delete any legacy build-time OG scripts and remove their
pnpm scripts from package.json.
Verify by running `pnpm build && pnpm preview`, then curl
/og/index.png and /og/<dynamic-route>.png — both should return
200 with Content-Type: image/png and 1200×630 pixels.The model fills in the project-specific details (existing route shapes, content sources, brand colours) and the package handles the rest. The verification step at the end matters — without it the model will sometimes produce something that type-checks but fails at runtime, especially around the @resvg/resvg-js resolution edge cases.
API
The full surface, in a paragraph each:
defineOgConfig(config). Identity helper. Constrains keys to your FileRoutesByPath (which TanStack Router augments via routeTree.gen.ts). Every known route is required — omitting one is a TypeScript error. params is inferred per-key from the path string (/writing/$slug → { slug: string }, /files/$ → { _splat: string }, / → Record<string, never>).
defineOgTemplate(spec). Identity helper for the template module. fonts accepts either an array (eager) or a function returning an array (lazy, runs once on first request). The render function gets { data, route, request } — data is the augmentable OgData shape, route is the matched path + params, request is the incoming Request if you need it.
createOgHandler({ config, template, fallback? }). Returns a handler ({ request }) => Promise<Response>. URL matching is permissive about extension and trailing slash (/og/, /og/index.png, /og/blog.png, /og/blog/foo.png, /og/files/a/b/c.png all resolve naturally). The cache key is a hash of JSON.stringify(data), so when your data changes the ETag rotates and CDNs revalidate.
ogMeta(ctx, options?). Synchronous head helper. Reads the deepest matched route from ctx, builds the canonical image URL <siteUrl>/og/<path>.png, and returns the OG / Twitter meta entries. Optional siteName adds og:image:alt; twitterHandle adds twitter:site; imageWidth / imageHeight default to 1200×630.
OgData. The default fields are title (required), description, type, image, author, date, tag. Augment via module declaration to add your own:
declare module "@jxdltd/tanstack/og" {
interface OgData {
readingTime?: string;
}
}
export {};Once augmented, defineOgConfig and the template's data parameter both see the new field.
ignore. A unique symbol you return from a config entry to mean "this route shouldn't have a card." The handler responds 404 when an entry returns it.
Conclusion
If you're building on TanStack Start and want OG cards without a build pipeline, give it a try — the repo is here and a star is appreciated.