OG Images
Dynamic Open Graph image generation for social media previews.
Overview
ZeroStarter generates dynamic Open Graph images for social media previews using takumi-js. Each page type has its own OG image route.
Endpoints
| Route | Purpose |
|---|---|
/og/home | Landing page preview |
/og/docs/[[...slug]] | Documentation page previews |
/og/blog/[[...slug]] | Blog post previews |
/og?section=&title=&description= | Generic parameterized preview (hire, résumé) |
These routes live at /og, deliberately not under /api/. Because robots.txt disallows /api/, keeping the OG routes off it is what lets social unfurlers fetch the preview images. See robots.txt.
How It Works
The docs route is prerendered at build time (export const dynamic = "force-static" plus generateStaticParams), the blog route prebuilds currently published posts and uses 60-second revalidation so scheduled posts can become available without a rebuild, /og/home is static with no params, and the generic /og route is force-dynamic so it can render from query params on demand. Successful OG responses return PNGs with immutable cache headers (1 year).
The home OG image can also be served from a committed static file: getOgImageUrl() in web/next/src/app/layout.tsx prefers web/next/public/og/home.png when it exists (pointing the metadata at /og/home.png) and falls back to the dynamic /og/home route otherwise. So dropping a public/og/home.png shadows the generated route for the landing page.
The shared utility at web/next/src/lib/og-image.tsx creates consistent images with:
- Dark gradient background
- Page title and description (the title scales down and wraps for long headings)
- Section label (e.g., "Documentation", "Blog")
- App name branding
The OG JSX renders text in system-ui, not the site's brand fonts (DM Sans/JetBrains Mono): no custom font is loaded into the takumi renderer, so previews use the host's default UI font.
It renders with the render function from takumi-js (not ImageResponse), returning a Response with the PNG bytes. In next.config.ts, both @takumi-rs/core and takumi-js are listed in serverExternalPackages.
The outputFileTracingExcludes/outputFileTracingIncludes block is Linux- and libc-conditional. detectLibc() returns undefined off Linux, so on macOS and Windows the whole block is skipped (no include/exclude is applied). On Linux it detects glibc vs musl, then includes the matching @takumi-rs/core-linux-*-{gnu,musl} binary on the /og route so it ships with that function, while excluding the other variant (and the unused sharp/@takumi-rs/wasm builds). This trims the standalone output to just the binary the deploy target needs.
Customizing
To add OG images for a new content source, create a route at web/next/src/app/og/<section>/[[...slug]]/route.tsx that forwards to the shared helper:
import { site } from "@packages/config/site"
import { generateOgImage } from "@/lib/og-image"
import { yourSource } from "@/lib/source"
export const dynamic = "force-static"
export async function GET(_req: Request, { params }: { params: Promise<{ slug?: string[] }> }) {
const { slug } = await params
return generateOgImage(slug, {
source: yourSource,
sectionName: "Your Section",
defaultTitle: `${site.name} - Your Section`,
defaultDescription: `Your default description`,
})
}
export function generateStaticParams() {
return yourSource.generateParams().map((params) => ({
slug: params.slug ?? [],
}))
}For a one-off route (no source), build a React element and pass it to renderOgElement from @/lib/og-image. See web/next/src/app/og/home/route.tsx as a reference. For a parameterized preview driven by query params, use the generic web/next/src/app/og/route.tsx endpoint. Note the two different names: section is the query-string key the route reads from the URL, which it passes as the sectionName argument to renderOgImage (the same arg name generateOgImage and the per-section routes take). So the URL uses ?section=...&title=...&description=..., while the helper signature uses sectionName.
The force-static snippet above is fine for always-public sources like docs. For time-gated content (e.g. the blog, where posts have a publishedAt), copying it would prerender and expose OG images for unpublished posts. Such routes must gate on the published set and revalidate: add export const revalidate = 60, build params from only the published posts, and resolve through the published-only getPublicBlogPage helper. See web/next/src/app/og/blog/[[...slug]]/route.tsx as the reference for that pattern.
takumi-js v1 changed the default display from flex to inline. Every flex container in your OG JSX must set display: "flex" explicitly (the existing routes already do this).
Metadata Integration
OG images are automatically referenced in page metadata via generatePageMetadata() in web/next/src/lib/fumadocs.tsx. Each docs and blog page gets a unique OG image URL based on its slug.