ZeroStarter

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.txt
  • web/next/src/app/sitemap.ts/sitemap.xml
  • web/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, where baseUrl is NEXT_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:

RoutePreview
/og/homeLanding 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