SEO & Metadata
Dynamic OG images, sitemap, and robots: indexable and shareable by default.
A fresh ZeroStarter is indexable by search engines and unfurls a rich preview on social the moment you deploy it, no configuration. Three metadata routes, all driven by packages/config/src/site.ts and your content, do the work:
web/next/src/app/robots.ts→/robots.txtweb/next/src/app/sitemap.ts→/sitemap.xmlweb/next/src/app/og/*→ dynamic Open Graph preview images
Indexable by default
The starter ships with no noindex directive and no X-Robots-Tag header, so crawlers index it out of the box. robots.ts (a Next.js MetadataRoute.Robots) allows all public pages, disallows the routes that shouldn't be crawled, and points to the sitemap:
- Allowed:
/(all public content, including the OG images at/og). - Disallowed:
/api/,/console/,/dashboard/. - Sitemap:
${baseUrl}/sitemap.xml, wherebaseUrlisNEXT_PUBLIC_APP_URL.
Sitemap
sitemap.ts (a MetadataRoute.Sitemap) discovers pages from the Fumadocs sources instead of a hand-kept list: the home route, every page in docsSource, and the published posts from getPublishedBlogPosts(). It is force-static with revalidate = 60, so it regenerates at most once a minute, and its URLs are sorted alphabetically.
Priorities and change frequencies are set per type: the home page at 1.0, docs and blog pages at 0.9; home and docs refresh weekly, blog posts monthly. Home and docs entries set lastModified to the build time; blog entries use updatedAt when present, else publishedAt. The /blog index is absent because getPublishedBlogPosts() returns only individual published posts. The sitemap does no filtering of its own, so unpublished posts never leak in. The docs and blog blocks follow their feature flags: turn one off and its entries drop from the sitemap.
OG images
Each content type has its own Open Graph image route, rendered dynamically with takumi:
| Route | Preview |
|---|---|
/og/home | Landing page |
/og/docs/[[...slug]] | Docs pages |
/og/blog/[[...slug]] | Blog posts |
/og?section=&title=&description= | Generic, from query params |
Why /og, not /api/og
These routes live at /og on purpose. robots.txt disallows /api/, so keeping them off it is what lets social unfurlers reach the images.
The docs route is prerendered at build (force-static + generateStaticParams); the blog route prebuilds published posts and revalidates every 60s so scheduled posts appear without a rebuild; /og/home is static; and the generic /og route is force-dynamic. Page metadata wires each image in automatically through generatePageMetadata() in web/next/src/lib/fumadocs.tsx, so every docs and blog page gets a unique preview.
All routes render through renderOgImage in web/next/src/lib/og-image.tsx, resolving the page through the contentSource seam so the feature gate and publish policy apply for free. To add a section, register its kind in web/next/src/lib/content.ts, then create web/next/src/app/og/<section>/[[...slug]]/route.tsx:
import { site } from "@packages/config/site"
import { contentSource } from "@/lib/content"
import { renderOgImage } from "@/lib/og-image"
export const dynamic = "force-static"
const section = contentSource("docs") // your content kind
export async function GET(_req: Request, { params }: { params: Promise<{ slug?: string[] }> }) {
const { slug } = await params
const page = section.getPageOr404(slug)
return renderOgImage({
sectionName: "Your Section",
title: page.data.title || `${site.name} - Your Section`,
description: page.data.description || "Your default description",
})
}
export function generateStaticParams() {
return section.params()
}Publish gating is built in
getPageOr404 and params() apply each kind's publish policy, so you never hand-filter: the blog kind excludes unpublished or scheduled posts (via isPublicBlogPage), and a force-static OG route therefore never prerenders a preview for a post that isn't live. Keep revalidate = 60 for a time-gated kind so a scheduled post gets its preview without a rebuild, as web/next/src/app/og/blog/[[...slug]]/route.tsx does.
How the images render
Each image is a 1200x630 PNG. renderOgImage builds a plain flex layout (a section label, a title whose size steps down as it grows, and a description) and hands it to takumi, which rasterizes the JSX to PNG. No custom font ships: the layout uses system-ui and takumi resolves it. The response carries a one-year immutable Cache-Control, and a render failure during a production build throws to fail the build rather than ship a broken image, while a runtime failure logs and returns a 500.
To preview one, open its route while the dev server runs: /og/home, /og/docs/manage/seo, or the generic /og?title=Hello&description=World§ion=Demo to tweak the text live.
Because the images are cached hard, the page metadata appends a per-deploy ?t= timestamp to each OG URL (web/next/src/app/layout.tsx for the home image, web/next/src/lib/fumadocs.tsx for docs and blog), so social and CDN scrapers refetch the regenerated image after each deploy instead of serving a stale one. To ship a hand-made landing image instead of the rendered one, drop a public/og/home.png and the home metadata uses it.
Keep the takumi binary traced for /og on Vercel
Vercel traces /og into its own serverless function, and the takumi core is a native binary loaded dynamically (it sits in serverExternalPackages, so tracing can't follow the import). next.config.ts force-includes the matching-libc @takumi-rs/core binary for the /og function through outputFileTracingIncludes; remove it and /og returns a 500 at runtime. It is redundant only in Docker standalone, where the whole node_modules ships in one image.
Next
- Content: the docs and blog posts these routes describe.
- Environment Variables: where
NEXT_PUBLIC_APP_URLis set.