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.
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 forward to the shared helper in web/next/src/lib/og-image.tsx. To add a section, create web/next/src/app/og/<section>/[[...slug]]/route.tsx and call generateOgImage with your 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",
})
}Time-gated sources need gating
force-static is fine for always-public sources like docs. For content with a publishedAt (the blog), it would prerender previews for unpublished posts. Gate on the published set and add revalidate = 60. See web/next/src/app/og/blog/[[...slug]]/route.tsx for the pattern.
Next
- Content: the docs and blog posts these routes describe.
- Environment Variables: where
NEXT_PUBLIC_APP_URLis set.