GenerateSaaS

SEO kit

One shared layer giving every content and marketing page article-grade metadata, canonical and hreflang, breadcrumbs, JSON-LD, generated OG images, markdown renditions, and a required-fields guard.

Every page's <title>, description, canonical and Open Graph come from one of two builders: content pages get the article-shaped one, every other route the marketing one.

Metadata

BuilderUsed byEmits
buildPageMetadata (lib/seo/page-metadata.ts)landing, contact, legal, download, dashboardCanonical, Open Graph, and the site-wide /og.png
buildContentMetadata (lib/seo/content-metadata.ts)every route under [section]: documents, the section hub and its ?page=N rungs, category, tag, author, searchCanonical via canonicalFor, og:type article or website, dates, an OG image, twitter labels, and noindex when the guard failed or config.indexable is off
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "contact.meta" });
  return buildPageMetadata({
    locale,
    pathname: "/contact",
    title: t("title"),
    description: t("description", { product: config.siteName })
  });
}

Canonical and hreflang

SignalSource
canonicalSet per page by the builder, or by canonicalFor (lib/seo.ts) for pages that assemble bespoke metadata
hreflang (incl. x-default)Emitted for every locale by next-intl's middleware as an HTTP Link response header, configured in i18n/routing.ts
Localized URLsOne sitemap entry per route and locale, skipping documents a locale reaches only through the default-locale fallback

hreflang lives in the HTTP Link header, not the HTML <head>, so "view source" will not show it. Confirm with curl -I https://yoursite.com/contact; Google treats a header hreflang as equivalent to a link tag.

Article archives

An article section has exactly one crawlable pagination surface - the ?page=N ladder on its own hub - so its documents never occupy two indexable addresses.

URLCanonicalRobotsSitemap
/{section}itselfindex, followyes
/{section}?page=Nitself, plus rel=prev / rel=nextindex, followyes
/{section}/category/{slug}, /{section}/author/{slug}itselfindex, followyes
/{section}/tag/{slug}, /{section}/searchitselfnoindex, followno
any sorted viewno URL existsn/ano
  • /{section}?page=1 permanently redirects to /{section}; a non-numeric or out-of-range page 404s from the body and the metadata.
  • Rungs 2+ carry a de-optimised title and no description, so they never compete with the hub for the section's own query.
  • Both ladder controls are plain navigation: Load more articles is the next numbered link with a friendlier label, so every rung is a real server-rendered document.
  • Every other listing pages in place, and sorting never changes a URL anywhere, so only the ladder mints a second address - see Content sections.

buildBreadcrumbList(items, locale, path) and <Breadcrumbs items> render one trail from buildBreadcrumbItems twice: as visible navigation and as a BreadcrumbList node. Content pages, contact, the legal pages and /download use both, so no page emits breadcrumb schema without showing the trail.

Structured data on content pages

<ContentSchema doc locale section> emits one connected graph per page, picked by its template.

TemplateNodes
articleWebPage + Article + BlogPosting + hero ImageObject
pageWebPage (+ BreadcrumbList, and FAQPage when the document has an faq); Article only when the frontmatter sets article: true
alternativesWebPage + Article + ItemList (items in order, absolute URLs)
comparePair page: WebPage + Article + an unordered ItemList of the two products. The URL names one first as a canonical rule, never a verdict, so nothing is published as a ranking
listings, archives and hubsWebPage with a CollectionPage sub-type; SearchResultsPage on /search. The compare hub is the exception and emits no graph, in either of its two modes

Every graph also carries BreadcrumbList, the author Person, and FAQPage when the document has an faq. Review, rating and offer nodes are never built from content data.

The landing page is the one exception: its SoftwareApplication node can carry AggregateRating and Review behind config.seo.productRating - see Marketing & SEO before turning that on.

OG images

Two route handlers generate 1200x630 cards on demand: /og/{locale}/{section} for a listing, /og/{locale}/{section}/{slug} for a document, each drawing the title, section label, site name and a bundled font.

  • A document with image advertises that hero and never names the generated URL.
  • Every content route declares its own openGraph.images, archives included; nothing is inherited from a parent segment.

Markdown renditions

Every content page also serves Markdown at <path>.md, and at its canonical URL under Accept: text/markdown - see AI Agents & Crawlers. /llms.txt lists the static routes, then one line per section listing with content, titled from content.sections.{slug}, minus noindex sections.

The alternatives rendition puts the frontmatter first and the body after (the verdict as a list, the grouped matrix and the pricing as tables, one block per rival, FAQ as question and answer): for that template the body alone is close to empty. An alternatives section serves its page at /{section}.md, the way a page section serves its root, rather than a listing of documents it does not have. Every side's pricing is printed - ours from pricingConfig.plans, theirs from the compare fact sheet each item names - above a starting-price line naming each tool.

Preferred sources (Google)

A reader who marks a site as a preferred source sees it more often in Google Top Stories, with a "preferred" badge. Ships ON.

KeyDefaultWhat it does
config.seo.preferredSource.enabledtrueRenders the opt-in on the surfaces below. Turn it off until your domain appears in Google's tool (see the warning)
config.seo.preferredSource.variant"link""link" is your own button, upgraded on click to a popup window so the reader never leaves the page. The anchor stays real, so a blocked popup, a modifier click and a crawler all still work. "button" swaps in Google's widget (publisher.js, marketing pages only), which also opens in place but paints its own styling
config.seo.preferredSource.domainhostname of config.baseUrlSet only when your canonical host differs (a news. subdomain, say)

It renders as a compact button in the footer brand column, between the support email and the social icons (site-wide), and once in an article's sticky right rail, directly under the share controls (desktop widths, where that rail exists). Nowhere else: a section hub asks a reader who has read nothing yet, and commercial and app surfaces are not Top Stories candidates.

Only a domain or subdomain is eligible (example.com, news.example.com), never a subdirectory (example.com/blog), and your site must ALREADY appear in Google's tool. Open https://www.google.com/preferences/source?q=your-domain first - sending readers to a page that cannot list you is worse than not asking. A domain that names a path renders nothing at all.

Google's translated badge images are a download from Google, not a file in this repo: add one to your own pages if you want it.

The required-fields guard

A document missing a required field gets indexable: false: noindex, follow, out of the sitemap and llms.txt, hidden from hubs and listings in production, rendered degraded, and named in the build summary. A whole section goes out with noindex: true, which changes robots, sitemap and llms.txt only, never listing membership. See the editorial checklist.

A CDN or WAF rule that returns 403 to an AI crawler wins over everything on this page. robots.txt cannot override an edge block: test your production domain with the crawler's user agent.

On this page