agentblog
blockblog-routessource: ok · hit
AgentBlog routes
Fully prerendered /blog index, post, category, tag, and author routes. generateStaticParams returns every slug, there is no client component in the article render path, and every segment spreads shared metadata defaults so og:site_name and max-snippet survive Next.js's shallow merge.
npx shadcn add "https://agentblog.dev/r/blog-routes.json"free
Where it goes
statsblog-list
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
@agentblog/blog-core@agentblog/blog-schema@agentblog/blog-ui@agentblog/mdx-components@agentblog/source-mdx@agentblog/seo-routes@agentblog/eeat-pages
Files10 · source cached 10/14/2026, 11:56:27 AM
registry/blog/app/blog/layout.tsx
/**
* Layout for every route under `/blog`.
*
* ---------------------------------------------------------------------------
* FOR THE HUMAN MAINTAINER AND FOR THE CODING AGENT EDITING THIS FILE
* ---------------------------------------------------------------------------
* This layout does three jobs and deliberately nothing else:
*
* 1. It calls `preflight()` at module scope. That is the only place in the
* shipped block where a misconfigured `next.config.ts` becomes visible.
* Module scope means it runs once per process on `next dev` and once per
* compilation on `next build`, which is exactly when a config warning is
* still cheap to act on. Do not move the call inside the component: a
* prerendered route renders once and then never again, so the warning
* would disappear the moment the page was cached.
* 2. It supplies the vertical padding, and nothing at all about width. See
* the layout contract below.
* 3. It renders the footer link to `/editorial-policy`. That page is the
* Trust surface in E-E-A-T, and a link from every blog page is what makes
* it discoverable to a crawler that only ever fetches one article.
*
* ---------------------------------------------------------------------------
* THE LAYOUT CONTRACT: EVERY PAGE OWNS ITS OWN CONTAINER
* ---------------------------------------------------------------------------
* This layout used to centre every route in one `max-w-3xl` column, and that is
* the wrong shape for half of what it wraps. A reading column and a card grid
* want different widths, and forcing both into 768px gave the index a
* three-column grid of 224px cards, which is narrower than the text inside
* them needs.
*
* So the rule is: this file sets the vertical padding, and each page writes its
* own `mx-auto w-full max-w-(--agentblog-…) px-6` against one of the rails
* declared in `styles/agentblog.css`. Index, category, tag, and author pages
* take `--agentblog-rail`. Article pages take `--agentblog-measure`. Nothing
* takes an arbitrary width.
*
* The gutter is on the same element as the cap rather than on this one, which
* is the ordinary Tailwind container and is what makes the block line up with a
* host shell that wrote theirs the same way. `styles/agentblog.css` has the
* long version of why the other nesting is off by exactly one gutter.
*
* There is no `min-h-screen` here, and adding one back is a regression. This
* block is a set of routes inside somebody else's application, not an
* application shell. When the host already renders a footer under a flexed
* `min-h-dvh` body, a `min-h-screen` on this element reserves a full viewport
* for content that does not fill it and leaves several hundred pixels of dead
* space between the blog footer and the site footer.
*
* ---------------------------------------------------------------------------
* THE TRAP: DO NOT ADD `title.template` HERE
* ---------------------------------------------------------------------------
* `title.template` applies to CHILD segments only. A template declared in
* `app/blog/layout.tsx` does NOT apply to `app/blog/page.tsx`, because that page
* belongs to the same segment as this layout rather than a child of it. Adding
* it here therefore produces a brand suffix on every post and no brand suffix on
* the blog index, which looks like a random bug and is very hard to spot in
* review. The template belongs in the ROOT layout (`app/layout.tsx`), which
* `agentblog init` patches for you. `title.default` is required alongside it.
*
* Related trap: this file must not set `openGraph` or `robots` either. Metadata
* merging is shallow, so defining `openGraph` at any level replaces the parent's
* entire object and silently drops `og:site_name`. The blog pages build their
* metadata through `@/lib/metadata`, which spreads the shared defaults. If you
* ever need layout-level metadata here, spread `openGraphDefaults` and
* `robotsDefaults` the same way the pages do.
*
* ---------------------------------------------------------------------------
* THE LANDMARK CONTRACT. READ THIS BEFORE ADDING A `<main>` ANYWHERE.
* ---------------------------------------------------------------------------
* **The block never renders `<main>`, and never renders a skip link. Your root
* layout owns both.** That is the whole premise of installing this as a block:
* the host application owns the page shell, and every route here renders into
* whatever `app/layout.tsx` already wraps around it.
*
* It is a contract rather than a preference because the alternative does not
* work. A `<main>` in a block route lands inside the host's `<main>`, which is
* invalid HTML and gives the page two `main` landmarks, and a screen reader
* user then has to guess which one holds the article. There is no way for a
* route in this block to know whether the host already provided one, so the
* only answer that is right in every install is "never".
*
* What you owe the block in return, in your root layout:
*
* 1. Exactly one `<main id="main">` wrapping the route slot.
* 2. A skip link, `<a href="#main">Skip to content</a>`, as the first
* focusable element on the page. Every post route puts a breadcrumb `nav`
* and a table-of-contents `nav` ahead of the article body, so without a
* bypass mechanism a keyboard user tabs through the whole contents list to
* reach the prose. That is WCAG 2.4.1, and the block cannot satisfy it
* from inside the shell.
*
* Nothing checks either one for you, which is why it is written here rather
* than left implied. The footer below is the one landmark this block does
* contribute, and it carries an `aria-label` for the same reason the rule
* exists: your site footer is almost certainly also a `contentinfo`, and two
* unnamed ones are indistinguishable in a landmark list.
*
* No `'use client'`. Nothing under `/blog` is a client component except the two
* components that are documented as such, and both render meaningful content on
* the server first.
*
* @see https://docs.agentblog.dev/reference/files
*/
import Link from 'next/link'
import { RssIcon } from '@/components/blog/icons'
import { JsonLd } from '@/components/blog/json-ld'
import { blogPath, sitePath } from '@/lib/config'
import { preflight } from '@/lib/preflight'
import { buildOrgGraph } from '@/lib/schema'
// Module scope on purpose. See the header block above.
preflight()
const FOOTER_LINK =
'text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none'
export default function BlogLayout({ children }: LayoutProps<'/blog'>) {
return (
<div className="bg-background text-foreground">
{/*
* The Organization and WebSite nodes are emitted here, from the blog
* layout, and this placement is deliberate.
*
* Every article graph built by `lib/schema.ts` references these two nodes
* by `@id`: `BlogPosting.publisher` points at the Organization and
* `WebPage.isPartOf` points at the WebSite. If nothing on the page emits
* them, those references dangle. The graph still validates, because a
* validator cannot know what a missing node was supposed to contain, and
* it still means nothing, because the publisher resolves to no entity.
*
* Putting them in the blog layout rather than the root layout is what
* makes the registry-only install correct on its own. A registry can write
* this file; it cannot patch your root layout. If you also want
* Organization markup on your marketing pages, add `buildOrgGraph()` there
* too, but do not add it to a layout that wraps `/blog`, or every blog page
* will carry the same two nodes twice.
*/}
<JsonLd nodes={buildOrgGraph()} />
{/*
* Gutter and vertical padding only. The width belongs to the page. See
* the layout contract at the top of this file.
*/}
<div className="py-12 lg:py-20">{children}</div>
{/*
* The editorial policy link ships from every blog page on purpose. An AI
* crawler routinely fetches a single article and nothing else, so a policy
* page linked only from the site's own navigation is invisible to it.
*
* `/editorial-policy` comes from the optional `@agentblog/eeat-pages`
* item. If you chose not to install it, either install it or replace this
* href with your own policy page. Leaving a link to a 404 here is worse
* than having no link at all.
*/}
{/*
* `aria-label` is load bearing, not decoration. This `<footer>` sits
* inside `<body>` rather than inside a `<section>` or an `<article>`, so
* it maps to a `contentinfo` landmark, and the host application almost
* certainly renders its own site footer as a second one. Two unnamed
* `contentinfo` landmarks are two identical entries in a screen reader's
* landmark list. Naming this one is what makes them tellable apart. See
* the landmark contract at the top of this file.
*/}
<footer aria-label="Blog" className="border-border border-t">
{/*
* Every internal href here comes from a `@/lib/config` path helper where
* one exists, because those helpers are what apply `config.trailingSlash`.
* A hardcoded `/blog` on a site configured with `trailingSlash: true`
* links to a URL that immediately 308s to `/blog/`, on every blog page,
* which is a self-inflicted redirect chain that AI crawlers frequently do
* not follow at all.
*
* `/feed.xml` is a literal on purpose: the helpers never add a trailing
* slash to a last segment that looks like a file, so this path is already
* in its final form. `/editorial-policy` has no helper of its own, so it
* is the one href on this page that a `trailingSlash: true` site has to
* fix by hand.
*/}
{/*
* The footer aligns to `--agentblog-rail` on every route, including the
* article routes whose content column is narrower. That is deliberate:
* a footer is chrome, and chrome that changes width per page reads as a
* layout bug rather than as a response to the content above it.
*
* The padding is on the OUTER element and the rail is on the inner one,
* which is the same nesting the content above uses. Written the other
* way round, as one element carrying both, `max-width` would include the
* padding and the footer links would land 32px inboard of the page
* content they sit under. That is small enough to look like nothing and
* large enough to see.
*/}
<div className="mx-auto flex w-full max-w-(--agentblog-rail) flex-wrap items-center gap-x-8 gap-y-3 px-6 py-8 text-sm">
<Link className={FOOTER_LINK} href={blogPath()}>
All posts
</Link>
<Link className={FOOTER_LINK} href={sitePath('/editorial-policy')}>
Editorial policy
</Link>
<Link className={FOOTER_LINK} href="/feed.xml">
<RssIcon aria-hidden="true" className="size-3.5" />
RSS
</Link>
</div>
</footer>
</div>
)
}
registry/blog/app/blog/page.tsx
/**
* The blog index. Paginated, indexable, and crawlable without JavaScript.
*
* ---------------------------------------------------------------------------
* FOR THE HUMAN MAINTAINER AND FOR THE CODING AGENT EDITING THIS FILE
* ---------------------------------------------------------------------------
* This page is the discovery path to every post. If pagination here stops
* working for a client that does not run JavaScript, deep posts stop being
* reachable by crawl even though they are all in the sitemap. So the pagination
* is real `<a href>` links, produced by `<Pagination>`, and never a click
* handler, a router push, or an infinite scroll.
*
* ---------------------------------------------------------------------------
* PAGINATION DECISION: `?page=N`, NOT `/blog/page/N`
* ---------------------------------------------------------------------------
* This block paginates with a `?page=N` search parameter on this single route.
* The alternative, a `/blog/page/[page]` path segment, is better in one respect
* and worse in another, so here is the whole trade written down rather than
* left implicit:
*
* `?page=N` (chosen) One route file. Nothing to keep in sync. But reading
* `searchParams` is a request-time API, so this route
* renders on demand instead of being prerendered. The
* response is still complete HTML in the first byte,
* because nothing here is wrapped in Suspense, so a
* crawler loses nothing. What it costs is TTFB and CDN
* cacheability on the index page only. Post pages, which
* are the pages that actually get cited, are unaffected:
* they are fully prerendered.
*
* `/blog/page/N` Fully prerenderable, better TTFB. Costs a second route
* file whose `generateStaticParams` must stay in step
* with `postsPerPage`, and a redirect rule so that
* `/blog/page/1` does not become a duplicate of `/blog`.
*
* If you want the path-segment form, add `app/blog/page/[page]/page.tsx` and
* change the `basePath` passed to `<Pagination>` below at the same time. Those
* two edits go together; doing one without the other produces 404s on page 2.
*
* CANONICAL STRATEGY FOR `?page=`. Each paginated page self-canonicalizes.
* Page 1 canonicalizes to `/blog` with no query string, and pages 2 and up
* canonicalize to their own `?page=N` URL. Canonicalizing every page to `/blog`
* would be the intuitive move and it is wrong: it tells Google the deep pages
* are duplicates, which removes the only crawl path to older posts. Google
* stopped using `rel="prev"`/`rel="next"` for indexing years ago, so those are
* emitted below for other engines and for assistive technology, not relied on.
*
* A page number past the end returns a real 404 rather than an empty list, so
* `?page=9999` cannot mint an unlimited supply of thin indexable URLs.
*
* @see https://docs.agentblog.dev/reference/files
* @see https://docs.agentblog.dev/concepts/geo-playbook
*/
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { Breadcrumbs } from '@/components/blog/breadcrumbs'
import { CategoryPills } from '@/components/blog/category-pills'
import { JsonLd } from '@/components/blog/json-ld'
import { Pagination } from '@/components/blog/pagination'
import { PostList } from '@/components/blog/post-list'
import {
CLUSTER_GAP,
DISPLAY,
LEDE,
META,
PAGE_HEADER,
SECTION_GAP,
TITLE_CLUSTER,
} from '@/components/blog/type-scale'
import { absoluteUrl, blogPath, config } from '@/lib/config'
import { buildListMetadata } from '@/lib/metadata'
import { getAllCategories, getPage } from '@/lib/posts'
import { buildBlogGraph, buildBreadcrumb } from '@/lib/schema'
import { cn } from '@/lib/utils'
/*
* ISR window in seconds. THIS MUST STAY A NUMERIC LITERAL.
*
* `export const revalidate = config.revalidate` does not compile cleanly.
* Next.js reads route segment config both by evaluating the module and by
* statically reading the AST (`next/dist/build/analysis/extract-const-value.js`),
* and the AST reader accepts only literals. A member expression is reported as
* `Unsupported node type "MemberExpression"`, which throws in `next dev` (error
* E1013) and logs a build error in production.
*
* Keep this equal to `revalidate` in `agentblog.config.ts`. `agentblog doctor`
* compares the two, because nothing in the type system can.
*/
export const revalidate = 3600
/*
* The bare word, not `${config.brand.name} blog`.
*
* The root layout's `title.template` is `%s | ${config.brand.name}`, so the
* document title of this route is already "Blog | Your Brand". Putting the
* brand in the page title as well produces "Your Brand blog | Your Brand" in the
* tab, in a search result, and in an AI citation, and the H1 below reads "Your
* Brand blog" on a page that is already on that brand's domain, under that
* brand's header. The brand belongs to the site, so it is stated once, by the
* template. If your root layout has no template, add one there rather than
* putting the brand back here.
*/
const INDEX_TITLE = 'Blog'
const INDEX_DESCRIPTION = `Articles, guides, and reference material from ${config.brand.name}.`
/**
* Path for a given page number. Page 1 has no query string, so it is the only
* URL for the first page and cannot compete with `/blog` for the same content.
*/
function pagePath(page: number): string {
return page <= 1 ? '/blog' : `/blog?page=${page}`
}
/**
* `searchParams` values are `string | string[] | undefined`. Anything that is
* not a positive integer falls back to page 1 rather than erroring, because a
* malformed query string is a crawler artefact, not a user mistake.
*/
function parsePageParam(raw: string | string[] | undefined): number {
const value = Array.isArray(raw) ? raw[0] : raw
if (!value) return 1
const parsed = Number.parseInt(value, 10)
return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : 1
}
export async function generateMetadata(props: PageProps<'/blog'>): Promise<Metadata> {
const requested = parsePageParam((await props.searchParams)['page'])
// `getPage` clamps the requested page, so `page` here is the real page number
// and the canonical below can never point at a page that does not exist.
const { page } = await getPage(requested)
/*
* `buildListMetadata` is where `openGraphDefaults` and `robotsDefaults` get
* spread. Metadata merging is shallow: writing an `openGraph` object inline
* here would replace the root layout's entire object and drop `og:site_name`,
* and writing a `robots` object inline would drop `max-snippet: -1`, which is
* the directive that permits full snippets in AI answers. Neither failure is
* visible to TypeScript or to any structured data validator, so construction
* lives in one file and pages call into it.
*/
const base = buildListMetadata({
title: page > 1 ? `${INDEX_TITLE}, page ${page}` : INDEX_TITLE,
description: INDEX_DESCRIPTION,
path: pagePath(page),
index: true,
})
return {
...base,
// Self-canonical per page. See the canonical strategy note in the header.
// Built through `absoluteUrl` so the origin is decided in exactly one place,
// never by concatenating `siteUrl` with a path at the call site.
alternates: { ...base.alternates, canonical: absoluteUrl(pagePath(page)) },
}
}
export default async function BlogIndexPage(props: PageProps<'/blog'>) {
// `searchParams` is a Promise in Next.js 16 and must be awaited. Reading it is
// what opts this route into request-time rendering. See the pagination note in
// the header for why that trade is acceptable on this page and only this page.
const requested = parsePageParam((await props.searchParams)['page'])
const [{ posts, totalPages, page }, categories] = await Promise.all([
getPage(requested),
getAllCategories(),
])
// A page number past the last real page is a 404, not an empty list. An empty
// list returned with a 200 is a soft 404 and lets a crawler manufacture
// unlimited thin URLs from the query string.
if (requested > 1 && requested > totalPages) notFound()
/*
* Page one has no breadcrumb, and that is the correct output rather than a
* missing feature.
*
* With no Home crumb (see `breadcrumbs.tsx`) this route is the top of its own
* trail, so page one's trail is the single word "Blog" sitting above an H1
* that already says it. A one-item `BreadcrumbList` tells a search engine
* nothing it cannot read off the URL, and a one-item nav is a nav announcing
* itself to a screen reader in order to say where the reader already is.
* `<Breadcrumbs>` renders nothing for an empty trail, and the JSON-LD below
* omits the node entirely rather than emitting an empty `itemListElement`.
*
* Page two and beyond do have somewhere to go back to, so they get one.
*
* Paths rather than absolute URLs. `buildBreadcrumb` absolutizes for the
* JSON-LD; the rendered `href` has to stay relative so `next/link` keeps
* routing inside the origin serving the page. See `BreadcrumbTrailItem.path`.
*
* Typed explicitly rather than inferred, so that `crumb.path` below is a legal
* property access on every element and so the shape matches `buildBreadcrumb`
* exactly. Note the `?:` rather than `| undefined`: this repo compiles with
* `exactOptionalPropertyTypes`, so an absent crumb path is an absent key.
*/
const trail: { name: string; path?: string }[] =
page > 1 ? [{ name: 'Blog', path: blogPath() }, { name: `Page ${page}` }] : []
return (
<>
{/*
* `rel="prev"` and `rel="next"` as real `<link>` elements. React hoists
* `<link>` rendered anywhere in the tree into `<head>`, which is the only
* way to emit these: the Next.js `Metadata` type has no field for them.
* Google ignores them for indexing. Other engines and some readers do not,
* and they cost nothing. The `<a rel>` attributes on the pagination
* controls below carry the same relationship for assistive technology.
*
* These sit OUTSIDE the flex column below, and so does the `<JsonLd>` at
* the foot of this file. A `<link>` and a `<script>` render nothing, but
* they are still flex items, and a flex item with no height still takes a
* full `gap` on each side. Inside the column they would open 128px of
* blank page that no element accounts for and no inspector attributes to
* anything. Metadata goes outside the layout, always.
*/}
{page > 1 ? <link href={absoluteUrl(pagePath(page - 1))} rel="prev" /> : null}
{page < totalPages ? <link href={absoluteUrl(pagePath(page + 1))} rel="next" /> : null}
{/*
* The wide rail, because this page is a card grid rather than a reading
* column. `SECTION_GAP` is the only vertical spacing here: no child below
* sets a margin of its own, which is what makes it impossible for one of
* them to sit flush against its neighbour, as the category pills and the
* post grid did at a measured zero pixels before this rule existed.
* See `components/blog/type-scale.ts`.
*/}
<div className={cn('mx-auto w-full max-w-(--agentblog-rail) px-6', SECTION_GAP)}>
<header className={PAGE_HEADER}>
{/* Breadcrumb paths come from the config path helpers, the same
helpers every canonical is absolutized from, so the visible trail
and the BreadcrumbList JSON-LD built from the same array below can
never disagree about a destination. */}
<Breadcrumbs trail={trail} />
<div className={TITLE_CLUSTER}>
{/* Exactly one <h1>, and it does not change on page 2. Paginated pages
are the same collection viewed further down, not a different topic. */}
<h1 className={DISPLAY}>{INDEX_TITLE}</h1>
<p className={LEDE}>{INDEX_DESCRIPTION}</p>
{page > 1 ? <p className={META}>{`Page ${page} of ${totalPages}`}</p> : null}
</div>
</header>
{/* Pills and grid are one cluster, at the tighter rhythm: the filters
belong to the results, not to the page header above them. */}
<div className={CLUSTER_GAP}>
{/* Category hubs are linked from the index so the hub and spoke graph
has a real entry point that does not depend on the host site
navigation. */}
<CategoryPills categories={categories} />
<PostList posts={posts} />
</div>
{/*
* Real `<a href>` pagination. `basePath` is the route that `<Pagination>`
* appends `?page=N` to.
*
* It comes from `blogPath()` rather than the literal `/blog`, because
* that helper is the only thing that applies `config.trailingSlash`.
* Pass a literal and every page-2-and-beyond link on a
* `trailingSlash: true` site points at a URL that 308s before it serves,
* on the one surface that is often the only crawl path to deep posts.
*
* If you switch this block to a `/blog/page/[page]` path segment, this
* value and the URL form inside `<Pagination>` must change together.
* They are the same decision expressed in two files.
*
* `<Pagination>` returns null on a single-page blog, and null is not a
* flex item, so the gap above it disappears with it.
*/}
<Pagination basePath={blogPath()} page={page} totalPages={totalPages} />
</div>
{/*
* `Blog` JSON-LD listing the posts on THIS page, linked by `@id` to the
* Article nodes those posts emit on their own routes. Listing every post
* in the archive here instead would claim that all of them appear on this
* URL, which is not what a reader or a crawler sees.
*
* The BreadcrumbList is built from the same `trail` array that renders
* above it, so the markup and the schema cannot drift apart.
*/}
{/* `pagePath(page)` goes to both nodes, and to the canonical above. The
`Blog` node is keyed by path because `/blog` and `/blog?page=2` list
different posts: one `@id` across both would describe one entity two
incompatible ways, and whichever page a crawler fetched last would
win. */}
<JsonLd
nodes={[
buildBlogGraph(posts, pagePath(page)),
// Nothing on page one, where the trail is empty. An `itemListElement`
// with no items is a node claiming a breadcrumb that does not exist.
...(trail.length === 0 ? [] : [buildBreadcrumb(trail, pagePath(page))]),
]}
/>
</>
)
}
registry/blog/app/blog/opengraph-image.tsx
/**
* The blog's Open Graph card, at `/blog/opengraph-image`.
*
* ---------------------------------------------------------------------------
* WHY THIS LIVES UNDER `app/blog/` AND NOT AT THE APP ROOT
* ---------------------------------------------------------------------------
* It used to be `app/opengraph-image.tsx`, which meant installing a blog took
* over the social card for the host site's entire domain, landing page included.
* A blog block has no business deciding what `/` looks like when somebody shares
* it, and the takeover was silent: the card rendered, the build passed, and the
* only symptom was the marketing page unfurling as a blog card.
*
* Everything this block writes now lives under the paths it owns. If you want a
* card for your site as a whole, add your own `app/opengraph-image.png` (or
* `.tsx`); nothing here will overwrite it.
*
* ---------------------------------------------------------------------------
* WHAT USES THIS CARD
* ---------------------------------------------------------------------------
* Every blog surface that is not a single post: `/blog` and its pagination,
* category hubs, tag pages, and author pages. They reach it by name through
* `BLOG_OG_IMAGE` in `lib/metadata.ts` rather than by the file-based merge,
* because that merge only fires for a segment that has an image file of its own
* and only `app/blog/[slug]/` does. See the comment on `BLOG_OG_IMAGE`.
*
* The card says "Blog" and nothing more, and it carries no eyebrow. It stands
* for a dozen different URLs, so a headline would have to be either wrong on
* most of them or generic enough to say nothing.
*
* The brand is deliberately not in that word. `ogCard` already renders the mark
* from `config.brand.logo` in the top left, falling back to the brand name as a
* wordmark when there is no readable logo, so `${config.brand.name} Blog` set it
* a second time in the headline and produced "Your Brand" above "Your Brand
* Blog" on one 1200x630 image. `alt` below still names the brand, because alt
* text is read without the picture and has nothing else to identify it by.
*
* @see https://docs.agentblog.dev/reference/files
*/
import { ImageResponse } from 'next/og'
import { config } from '@/lib/config'
import { OG_CONTENT_TYPE, OG_SIZE, ogCard } from '@/lib/og-card'
export const alt = `${config.brand.name} blog`
export const size = OG_SIZE
export const contentType = OG_CONTENT_TYPE
export default function Image() {
return new ImageResponse(ogCard({ title: 'Blog' }), { ...size })
}
registry/blog/app/blog/[slug]/page.tsx
/**
* The blog post route. This file is the product.
*
* ---------------------------------------------------------------------------
* FOR THE HUMAN MAINTAINER AND FOR THE CODING AGENT EDITING THIS FILE
* ---------------------------------------------------------------------------
* Everything an AI crawler ever sees of a post is the HTML this route returns in
* its first response. GPTBot, ClaudeBot, PerplexityBot and friends do not
* execute JavaScript, do not wait for streamed chunks, and usually fetch a page
* exactly once. So the whole design goal here is boring and absolute: the
* complete article, its metadata, and its JSON-LD must all be present in a
* single static response body.
*
* Four things in this file are load bearing. Changing any of them silently
* removes content from the response that a crawler actually reads. Each one is
* commented at its own site below, but they are listed here so you see them
* before you start editing:
*
* 1. `generateStaticParams` returns EVERY slug.
* 2. `revalidate` is a numeric literal, not `config.revalidate`.
* 3. `dynamicParams` stays `true`.
* 4. The FAQ renders visibly, and the FAQ JSON-LD is emitted after it.
*
* There is no `'use client'` in this file and there must never be one. The two
* client components in the block (`TableOfContents` and `ShareButtons`) render
* their content on the server first and hydrate only to add scroll spy and share
* intents on top of markup that is already in the HTML.
*
* @see https://docs.agentblog.dev/reference/files
* @see https://docs.agentblog.dev/concepts/geo-playbook
*/
import type { Metadata } from 'next'
import Image from 'next/image'
import { notFound } from 'next/navigation'
import { AnswerCapsule } from '@/components/blog/answer-capsule'
import { AuthorBio } from '@/components/blog/author-bio'
import { Breadcrumbs } from '@/components/blog/breadcrumbs'
import { Byline, PostDates } from '@/components/blog/byline'
import { JsonLd } from '@/components/blog/json-ld'
import { Faq, FAQ_HEADING } from '@/components/mdx/faq'
import { Prose } from '@/components/blog/prose'
import { RelatedPosts } from '@/components/blog/related-posts'
import { ShareButtons } from '@/components/blog/share-buttons'
import { TableOfContents } from '@/components/blog/table-of-contents'
import {
CLUSTER_GAP,
DISPLAY,
PAGE_HEADER,
SECTION_GAP,
SECTION_HEADING,
TITLE_CLUSTER,
} from '@/components/blog/type-scale'
import { blogPath, categoryPath, postUrl } from '@/lib/config'
import { buildPostMetadata } from '@/lib/metadata'
import { renderMdx } from '@/lib/render-mdx'
import { getAllPosts, getPost, getRelatedPosts } from '@/lib/posts'
import { buildArticleGraph } from '@/lib/schema'
import { cn } from '@/lib/utils'
import type { TocEntry } from '@/lib/toc'
/*
* ISR window in seconds. THIS MUST STAY A NUMERIC LITERAL.
*
* `export const revalidate = config.revalidate` does not work, and it fails in a
* way that is easy to misread. Next.js reads route segment config twice: once by
* evaluating the module, and once by statically reading the AST in
* `next/dist/build/analysis/extract-const-value.js`. That AST reader accepts
* only literals, `undefined`, array and object expressions, and template strings
* with no interpolation. A member expression such as `config.revalidate` is
* reported as `Unsupported node type "MemberExpression"`, which THROWS during
* `next dev` (error E1013) and is logged as a build error in production.
*
* So the number is duplicated here on purpose. Keep it equal to `revalidate` in
* `agentblog.config.ts`. `agentblog doctor` compares the two and fails when they
* drift, because no type checker and no linter can catch this.
*/
export const revalidate = 3600
/*
* Leave this `true`, which is also the Next.js default. It is written out rather
* than omitted so that nobody "tidies up" by setting it to `false`.
*
* `dynamicParams = false` turns any slug that was not in `generateStaticParams`
* at build time into a hard 404. Next.js does not re-run `generateStaticParams`
* during ISR, so with a database-backed content source every newly published
* post would 404 until the next deploy. A slow first render is strictly better
* than a 404, especially since the publish webhook deliberately invites a
* crawler to be that first request.
*/
export const dynamicParams = true
/**
* Every slug. Not the most recent N, not one page of them, not a sample.
*
* READ THIS BEFORE YOU EDIT THE RETURN VALUE. Adding `.slice(0, 50)` or a
* `limit` here does not make the build "faster" in any way you will be glad
* about. It removes posts from the prerendered set. Those posts still resolve,
* because `dynamicParams` is `true`, but they are rendered on demand, which
* means their metadata can be streamed into `<body>` for bots on the
* `htmlLimitedBots` list and their first byte arrives after a full render. Full
* prerendering of every slug is the single mechanism that puts the complete
* article in the first response byte and the metadata in `<head>`.
*
* If your build time is a real problem, the fix is a faster content source, not
* fewer prerendered posts.
*/
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export async function generateMetadata(props: PageProps<'/blog/[slug]'>): Promise<Metadata> {
const { slug } = await props.params
const post = await getPost(slug)
// Returning `{}` lets the root layout's metadata stand for the 404 that the
// page component is about to produce. Do not throw here.
if (!post) return {}
/*
* `buildPostMetadata` is the only correct way to build metadata for a post,
* because it is where `openGraphDefaults` and `robotsDefaults` get spread.
*
* Metadata merging in Next.js is SHALLOW. If you inline an `openGraph` object
* here instead, this segment replaces the root layout's `openGraph` wholesale
* and every post ships without `og:site_name` and `og:locale`. The same applies
* to `robots`: defining it here without the shared defaults drops
* `max-snippet: -1`, which is the directive that permits full snippets in AI
* answers. Both failures are invisible to TypeScript and to every structured
* data validator, which is precisely why the construction lives in one file.
*/
const base = buildPostMetadata(post)
return {
...base,
/*
* The canonical is composed by `postUrl`, never by string concatenation, so
* that `siteUrl`, the trailing-slash policy, and the `/blog` prefix are all
* decided in one place. `alternates` is itself a nested object, so it gets
* the same shallow-merge treatment as `openGraph`: spread what
* `buildPostMetadata` produced, then override only the canonical.
*/
alternates: { ...base.alternates, canonical: postUrl(post.slug) },
}
}
export default async function PostPage(props: PageProps<'/blog/[slug]'>) {
// `params` is a Promise in Next.js 16 and synchronous access was removed
// outright. The `PageProps<'/blog/[slug]'>` type comes from `next typegen`,
// which derives it from the real route tree. Do not replace it with a
// hand-written `{ params: Promise<{ slug: string }> }`: that keeps compiling
// forever, including after somebody renames the segment.
const { slug } = await props.params
const post = await getPost(slug)
// `getPost` returns `null` only when the post is genuinely absent. Every other
// failure throws, which correctly fails the build rather than deploying a site
// whose sitemap quietly lost half its URLs.
if (!post) notFound()
// The slug is passed as the origin so a body that fails to compile names the
// post in the build error instead of pointing at `lib/render-mdx.tsx`.
const [{ content, toc, faqHeadingId }, related] = await Promise.all([
renderMdx(post.body, post.slug),
getRelatedPosts(post, 3),
])
// A table of contents on a two-heading post is noise. The guide puts the
// threshold at roughly 1,200 words; heading count is the cheaper proxy and it
// degrades in the right direction. The count is taken from the body headings
// alone, before the synthetic FAQ entry below, so adding an FAQ never tips a
// short post into having a table of contents.
const showToc = toc.length >= 3
/*
* The FAQ entry is appended here rather than discovered.
*
* `remarkExtractToc` walks the MDX body, and `<Faq>` is rendered by this
* route from `post.faq` in frontmatter, outside that body. So the extractor
* cannot see the FAQ heading and never will. A reader scanning "On this page"
* expects the FAQ to be listed, so the route contributes the one entry it
* knows about.
*
* THE ID COMES FROM `renderMdx`, NOT FROM A CONSTANT. `faqHeadingId` was
* issued by the same `github-slugger` instance that issued every body heading
* id, after the whole body was walked, so it cannot collide with one. The
* previous version of this line used a hardcoded `'faq'` that the slugger had
* never seen, and a post with a body H2 titled "FAQ" shipped two
* `<h2 id="faq">`, two `href="#faq"` entries in this list, and a "Frequently
* asked questions" link that scrolled to the body heading.
*
* The same id goes to `<Faq headingId>` below. Those two uses must stay in
* step, which is why the value is read once into a variable rather than
* spelled out twice.
*
* If you add another route-level H2 below (the Sources section, say), reserve
* its id the same way: add its text to `trailingHeadings` in
* `lib/render-mdx.tsx` rather than inventing a literal here. Every id in this
* list has to resolve to exactly one element on the page, or the link either
* does nothing or goes somewhere else.
*/
const tocEntries: readonly TocEntry[] =
post.faq.length > 0
? [...toc, { id: faqHeadingId, text: FAQ_HEADING, depth: 2 } satisfies TocEntry]
: toc
/*
* One trail, rendered by `<Breadcrumbs>` below and emitted as the
* `BreadcrumbList` by `buildArticleGraph`. Building it twice is how the page
* came to display Home, Blog, Category, Post while the markup claimed Home,
* Blog, Post, which is marked-up navigation no reader can see.
*
* It ends at the category hub, not at this post. Google does not require the
* trail to reach the current page, and a crumb naming the post repeats the H1
* directly beneath it. The hub is a real destination, so it stays a link;
* `<Breadcrumbs>` reserves `aria-current="page"` for a crumb with no `path`,
* and there is none here.
*
* There is no Home crumb for the same reason there is none anywhere else in
* the block. Google: "It is not required to include a breadcrumb ListItem for
* the top level path (your site's domain or host name)."
*
* Paths rather than absolute URLs, which is what lets one array serve both
* consumers: `buildBreadcrumb` absolutizes for the JSON-LD, and the rendered
* `href` stays inside whatever origin is serving the page. See
* `BreadcrumbTrailItem.path`.
*
* Typed explicitly rather than inferred so `crumb.path` is a legal property
* access on every element and the shape matches `buildBreadcrumb`. Note the
* `?:` rather than `| undefined`: this block compiles with
* `exactOptionalPropertyTypes`, so an absent crumb path is an absent key.
*/
const trail: { name: string; path?: string }[] = [
{ name: 'Blog', path: blogPath() },
{ name: post.category.name, path: categoryPath(post.category.slug) },
]
return (
/*
* ---------------------------------------------------------------------
* THE ARTICLE LAYOUT
* ---------------------------------------------------------------------
* Below `xl` this is one column at `--agentblog-measure`, with the contents
* list in the flow under the byline. That is the whole layout on a phone, a
* tablet, and a laptop.
*
* At `xl` the page widens to `--agentblog-article` and becomes a two column
* grid, and the contents list moves into the right rail and sticks. The
* header stays clamped to the measure so it keeps the same left edge and
* the same right edge as the prose underneath it: the outer container grew,
* but the reading column did not, and that is the point.
*
* The grid only appears when there IS a contents list. A post with two
* headings would otherwise sit in a 912px container as a 624px column
* pushed to the left of it, off centre for no visible reason.
*
* The aside comes BEFORE the body in source order and is placed into column
* two explicitly. Written the other way round it reads correctly at `xl`
* and puts the contents list below the entire article at every width under
* it, which is where a contents list is useless.
*/
<div
className={cn(
'mx-auto w-full max-w-(--agentblog-measure) px-6',
showToc && 'xl:max-w-(--agentblog-article)',
)}
>
<article className={SECTION_GAP}>
{/*
* A real `<header>` for the article's title block.
*
* It is not decoration: `<header>` scoped inside `<article>` is a banner
* landmark for the article itself, which is what groups the headline, the
* answer capsule, the byline, and the breadcrumb trail into one unit for
* a screen reader and for anything reconstructing the document outline.
* `components/blog/byline.tsx` and `components/blog/prose.tsx` both
* document this element by name, so do not unwrap it.
*/}
<header className={cn(PAGE_HEADER, 'w-full', showToc && 'xl:max-w-(--agentblog-measure)')}>
{/* Breadcrumb paths are composed by the config path helpers, the same
helpers every canonical is absolutized from, so the visible trail
and the BreadcrumbList JSON-LD built by `buildArticleGraph` can
never disagree about a destination: both read the `trail` array
above, unmodified. */}
<Breadcrumbs trail={trail} />
{/*
* Headline, byline, capsule: one cluster at the tighter rhythm, in the
* order a reader asks the questions. What is this, who says so, and
* what does it say.
*
* The byline goes between the H1 and the capsule rather than after it
* because it is one line and the capsule is a paragraph. Attribution
* sitting under a 60-word block reads as a footnote to that block
* instead of as the article's byline, and the H1 is left with nothing
* under it but prose it is not the title of.
*/}
<div className={cn(TITLE_CLUSTER, 'w-full')}>
{/* Exactly one <h1> on this page, and it is the article headline. Every
other heading in this file is an <h2>, and the MDX component map
emits <h2>/<h3> with stable ids for the body. */}
<h1 className={DISPLAY}>{post.title}</h1>
{/* Byline owns `rel="author"` on the author link, and that value must
match the JSON-LD exactly, which it does because both read the
same `post` object. The dates are not here: they render as
`<PostDates>` at the foot of the article. See `byline.tsx`. */}
<Byline post={post} />
{/* The answer capsule: 40 to 60 words, the complete answer, no links
inside it. This is the highest value block on the page for
retrieval, because it is what survives chunk truncation, and the
single line of byline above it does not push it out of the opening
chunk. Passed as `children` rather than through the `text` prop so
an MDX author and this route reach the component the same way. */}
{post.answerCapsule ? <AnswerCapsule>{post.answerCapsule}</AnswerCapsule> : null}
</div>
</header>
{/* `PostSchema` already refuses a hero image without alt text, so this
double guard is for the type checker rather than for the data. */}
{post.heroImage && post.heroAlt ? (
<figure className={cn('w-full', showToc && 'xl:max-w-(--agentblog-measure)')}>
{/*
* `preload` is deliberately NOT set here, and `priority` is deprecated
* in Next.js 16 so it must never come back.
*
* The rule is that `preload` goes on an image only when that image is
* definitively the LCP element. On this layout it is not: breadcrumbs,
* the H1, the answer capsule, and the byline all sit above the hero, so
* on a narrow viewport the H1 text block is usually LCP and preloading
* the hero would compete with it for bandwidth. The Next.js docs make
* the same point: `loading="eager"` with `fetchPriority="high"` is the
* appropriate choice when LCP depends on viewport.
*
* If you measure the hero as LCP on your own layout, change
* `loading="eager"` to `preload` and change nothing else. At most one
* image per route may carry it.
*/}
<Image
alt={post.heroAlt}
className="border-border w-full rounded-xl border"
fetchPriority="high"
height={630}
loading="eager"
sizes="(min-width: 768px) 768px, 100vw"
src={post.heroImage}
width={1200}
/>
<figcaption className="text-muted-foreground mt-3 text-sm leading-relaxed">
{post.heroAlt}
</figcaption>
</figure>
) : null}
{/*
* The body. `flex flex-col gap-12` below `xl` puts the contents list a
* full section above the prose; at `xl` the same element becomes the two
* column grid and that gap turns into the gutter between them.
*/}
<div
className={cn(
'flex w-full flex-col gap-12',
showToc &&
'xl:grid xl:grid-cols-[minmax(0,1fr)_var(--agentblog-aside)] xl:gap-x-12 xl:gap-y-0',
)}
>
{/* TableOfContents is a client component for scroll spy only. It renders
the full list of links on the server first, so the anchors exist in the
HTML whether or not JavaScript ever runs.
`top-24` clears a sticky site header. If yours is taller, this and
`scroll-padding-top` in your global stylesheet are the two numbers
that have to move together. */}
{showToc ? (
<aside className="xl:col-start-2 xl:row-start-1">
<div className="xl:sticky xl:top-24 xl:max-h-[calc(100dvh-8rem)] xl:overflow-y-auto">
<TableOfContents entries={tocEntries} />
</div>
</aside>
) : null}
<div className={cn(SECTION_GAP, 'min-w-0 xl:col-start-1 xl:row-start-1')}>
{/* The compiled MDX. `renderMdx` is the only place MDX is compiled in
the whole block, which is what keeps the article render path free
of client components and free of the Turbopack MDX loader
restrictions. */}
<Prose>{content}</Prose>
{/*
* VISIBLE FAQ. This block must stay in the rendered output.
*
* The FAQPage JSON-LD emitted at the bottom of this component describes
* exactly these question and answer pairs. Google's structured data
* policies forbid marking up content that is not visible to readers, so
* deleting or conditionally mounting this section while leaving the schema
* in place turns a working page into a guidelines violation.
*
* That is also why this is plain markup rather than an accordion that
* mounts its children on click. CSS-hidden content is fine for crawlers;
* JavaScript-mounted content is not.
*/}
<Faq headingId={faqHeadingId} items={post.faq} />
{/*
* The FAQ is rendered from frontmatter, here, once. Do not also place
* a `<Faq />` tag in the body of a post: the schema and the visible
* section both read `post.faq`, and a second copy in the MDX would
* duplicate the questions on the page while the JSON-LD still
* described only one set.
*/}
{/* Outbound citations to named sources. The GEO paper measured citing
sources as one of the few interventions with a peer-reviewed effect
on how often a page is cited back, so these are content, not
decoration. */}
{post.citations.length > 0 ? (
<section aria-labelledby="sources" className={CLUSTER_GAP}>
<h2 className={SECTION_HEADING} id="sources">
Sources
</h2>
<ol className="text-muted-foreground marker:text-muted-foreground list-decimal space-y-3 pl-5 text-sm leading-relaxed">
{post.citations.map((citation) => (
<li key={citation.url}>
<a
className="text-foreground hover:text-muted-foreground underline decoration-1 underline-offset-4 transition-colors"
href={citation.url}
rel="noopener"
>
{citation.name}
</a>
{citation.author ? <span>{`, ${citation.author}`}</span> : null}
{citation.datePublished ? (
<span>
{', '}
<time dateTime={citation.datePublished}>
{citation.datePublished.slice(0, 10)}
</time>
</span>
) : null}
</li>
))}
</ol>
</section>
) : null}
{/*
* The colophon, and the rule that ends the article.
*
* The hairline belongs above this line rather than below it, which
* is what makes the dates read as provenance for what was just read
* instead of as the first item in the list of things to do next. It
* is the same single rule the header used to carry, moved to the one
* place on the page where a horizontal line is stating something:
* the article stops here.
*
* `<PostDates>` owns `<time dateTime>` on both dates, carrying the
* raw ISO values with their UTC offsets. They must stay
* byte-identical to `datePublished` and `dateModified` in the
* JSON-LD, which they are because both read the same `post` object.
* A site that publishes to Google News wants these in the header
* instead; see `byline.tsx`.
*/}
<PostDates className="border-border w-full border-t pt-6" post={post} />
{/*
* Everything past that is one cluster: share, attribution, and where
* to read next. They are the same kind of thing (what to do now that
* you have finished) and they used to be three sections at three
* different margins, one of which was zero.
*/}
<div className="flex flex-col gap-10">
{/*
* Share controls, at the foot of the article body and above the
* author box. The URL is composed on the server by `postUrl`,
* never read from `window.location`: a client-side read produces a
* different string on `localhost` and in a preview deployment, and
* it would leave the anchors empty in the server HTML. Every
* target here is a real `<a href>`, so this stays true with
* JavaScript disabled.
*/}
<ShareButtons title={post.title} url={postUrl(post.slug)} />
{/* `postCount` is intentionally omitted here. Filling it would mean
one `getPostsByAuthor` call per post at build time, which scales
with post count for no reader benefit. The author page shows the
count instead, where the query is already being made. */}
<AuthorBio author={post.author} />
<RelatedPosts posts={related} />
</div>
</div>
</div>
</article>
{/*
* ONE serialization point for this page's structured data, and it is
* placed AFTER the visible FAQ above on purpose. Script position does not
* matter to any parser, but keeping the emit below the markup it describes
* makes the dependency impossible to miss in review: if you delete the FAQ
* section, you must also stop emitting FAQ markup.
*
* `buildArticleGraph` returns the complete connected node set for a post
* (Article, WebPage, BreadcrumbList, the author Person, and FAQPage when
* the post has visible FAQ entries). Do not add a second `<JsonLd>` to
* this route: two nodes sharing an `@id` make the graph ambiguous and is
* the most common way a valid-looking blog produces unusable structured
* data.
*/}
<JsonLd nodes={buildArticleGraph(post, trail)} />
</div>
)
}
registry/blog/app/blog/[slug]/opengraph-image.tsx
/**
* The per-post Open Graph card, at `/blog/<slug>/opengraph-image`.
*
* The layout lives in `lib/og-card.tsx` and is shared with the blog-level card,
* so the two cannot drift. This file decides what the card says; that file
* decides what it looks like.
*
* ---------------------------------------------------------------------------
* TYPING `params`, AND WHY IT IS HAND-WRITTEN HERE
* ---------------------------------------------------------------------------
* `params` is a Promise in Next.js 16 and must be awaited. Everywhere else in
* this block the typegen helpers (`PageProps<'/blog/[slug]'>`) are mandatory,
* because a hand-written type drifts from the route tree. There is no typegen
* helper for the `opengraph-image` convention, so this one signature is written
* out by hand. That is expected, not an oversight.
*
* @see https://docs.agentblog.dev/reference/files
*/
import { ImageResponse } from 'next/og'
import { config } from '@/lib/config'
import { OG_CONTENT_TYPE, OG_SIZE, ogCard } from '@/lib/og-card'
import { getAllPosts, getPost } from '@/lib/posts'
/**
* `alt` is a static module export, so it cannot vary per post. It describes the
* card's function rather than its contents, which is the honest thing a static
* string can say here.
*/
export const alt = `${config.brand.name} blog post`
export const size = OG_SIZE
export const contentType = OG_CONTENT_TYPE
/** The line above every post title. Reads as `Acme Blog`. */
const EYEBROW = `${config.brand.name} Blog`
/**
* Every slug, so every card is generated at build time.
*
* Without this the route is dynamic and each card is rendered through Satori on
* the first request for it. That request is almost always a social or AI crawler
* unfurling a link, which is precisely the moment a slow response costs
* something: the crawler is on a timeout, the render is the most expensive thing
* this route does, and no reader is waiting who benefits from the work being
* deferred. Prerendering matches what `app/blog/[slug]/page.tsx` does, and the
* two lists come from the same `getAllPosts()` call, so they cannot drift.
*
* Same rule as the post route: EVERY slug. Slicing this list moves cards back
* onto the request path one post at a time.
*/
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
/*
* A missing post falls back to the blog-level card rather than throwing.
* Throwing here fails the build for the whole route, and at request time it
* serves a broken image to the social preview of a URL that is already about
* to 404. Neither is an improvement on a generic but valid card.
*/
if (!post) {
return new ImageResponse(ogCard({ title: EYEBROW }), { ...size })
}
return new ImageResponse(ogCard({ eyebrow: EYEBROW, title: post.title }), { ...size })
}
registry/blog/app/blog/category/[slug]/page.tsx
/**
* A category hub. Indexable on purpose, unlike the tag pages next door.
*
* ---------------------------------------------------------------------------
* FOR THE HUMAN MAINTAINER AND FOR THE CODING AGENT EDITING THIS FILE
* ---------------------------------------------------------------------------
* Categories are a small fixed set (5 to 10 is the working range) with one
* primary category per post. That makes each of these pages a legitimate topical
* hub that can rank and can be cited on its own, which is why they are indexed
* while tag pages usually are not.
*
* The category description is rendered prominently, directly under the H1 and
* above the post list. This is not decoration. A hub page whose entire body is a
* list of post titles has no content of its own, competes with the posts it
* links to, and reads to a crawler as a thin doorway page. That is why
* `CategorySchema` makes `description` required rather than optional: the schema
* refuses to construct a category that would produce a thin hub. If you remove
* the paragraph below, you have reintroduced the problem the schema exists to
* prevent.
*
* @see https://docs.agentblog.dev/reference/files
* @see https://docs.agentblog.dev/concepts/geo-playbook
*/
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { Breadcrumbs } from '@/components/blog/breadcrumbs'
import { CategoryPills } from '@/components/blog/category-pills'
import { JsonLd } from '@/components/blog/json-ld'
import { PostList } from '@/components/blog/post-list'
import {
CLUSTER_GAP,
DISPLAY,
LEDE,
META,
PAGE_HEADER,
SECTION_GAP,
TITLE_CLUSTER,
} from '@/components/blog/type-scale'
import { blogPath, categoryUrl } from '@/lib/config'
import { buildListMetadata } from '@/lib/metadata'
import { getAllCategories, getCategory, getPostsByCategory } from '@/lib/posts'
import { buildBreadcrumb } from '@/lib/schema'
import { cn } from '@/lib/utils'
/*
* ISR window in seconds. THIS MUST STAY A NUMERIC LITERAL.
*
* `export const revalidate = config.revalidate` does not work. Next.js reads
* route segment config both by evaluating the module and by statically reading
* the AST (`next/dist/build/analysis/extract-const-value.js`), and the AST
* reader accepts only literals. A member expression is reported as
* `Unsupported node type "MemberExpression"`, which throws in `next dev` (error
* E1013) and logs a build error in production.
*
* Keep this equal to `revalidate` in `agentblog.config.ts`. `agentblog doctor`
* compares the two, because nothing in the type system can.
*/
export const revalidate = 3600
/*
* Default, written out so nobody sets it to `false`. With `false`, a category
* added after the last build becomes a hard 404 instead of a slow first render,
* and Next.js does not re-run `generateStaticParams` during ISR to fix it.
*/
export const dynamicParams = true
/**
* Every category. Not a subset.
*
* The set is small, so slicing here saves nothing measurable and costs the
* prerender for whichever hubs fall off the end. Those hubs still resolve
* because `dynamicParams` is `true`, but they render on demand, which means
* their metadata can be streamed rather than sitting in `<head>`.
*/
export async function generateStaticParams() {
const categories = await getAllCategories()
return categories.map((category) => ({ slug: category.slug }))
}
export async function generateMetadata(
props: PageProps<'/blog/category/[slug]'>,
): Promise<Metadata> {
const { slug } = await props.params
const category = await getCategory(slug)
if (!category) return {}
/*
* `buildListMetadata` is the only place `openGraphDefaults` and
* `robotsDefaults` get spread. Metadata merging in Next.js is shallow, so
* inlining an `openGraph` object here would replace the root layout's entire
* object and drop `og:site_name`, and inlining a `robots` object would drop
* `max-snippet: -1`, the directive that permits full snippets in AI answers.
* Both failures pass type checking and every structured data validator, which
* is exactly why the construction is centralised.
*/
const base = buildListMetadata({
title: category.name,
description: category.description,
path: `/blog/category/${category.slug}`,
// Category hubs are indexable. This is the deliberate difference from
// `/blog/tag/[slug]`, which noindexes below a post-count threshold.
index: true,
})
return {
...base,
// Canonical composed by `categoryUrl`, never by concatenating `siteUrl`.
// `alternates` is a nested object and gets the same shallow-merge treatment
// as `openGraph`: spread what the builder produced, override one key.
alternates: { ...base.alternates, canonical: categoryUrl(category.slug) },
}
}
export default async function CategoryPage(props: PageProps<'/blog/category/[slug]'>) {
// `params` is a Promise in Next.js 16 and synchronous access was removed.
// `PageProps<'/blog/category/[slug]'>` comes from `next typegen`, which
// derives it from the real route tree, so renaming the segment breaks the
// build instead of silently breaking the page.
const { slug } = await props.params
const [category, categories] = await Promise.all([getCategory(slug), getAllCategories()])
// An absent category is a real 404, never an empty hub returned with a 200.
// A soft 404 here would put an indexable, contentless URL in the crawl graph
// for every mistyped category anyone ever links to.
if (!category) notFound()
const posts = await getPostsByCategory(category.slug)
// No Home crumb, here or anywhere else in the block. Google does not require
// one ("It is not required to include a breadcrumb ListItem for the top level
// path"), and the SERP prefixes the domain regardless. See `breadcrumbs.tsx`.
const trail: { name: string; path?: string }[] = [
{ name: 'Blog', path: blogPath() },
{ name: category.name },
]
return (
<>
<div className={cn('mx-auto w-full max-w-(--agentblog-rail) px-6', SECTION_GAP)}>
<header className={PAGE_HEADER}>
{/* Breadcrumb paths come from the config path helpers, so the visible
trail and the BreadcrumbList built from the same array below cannot
drift. */}
<Breadcrumbs trail={trail} />
<div className={TITLE_CLUSTER}>
{/* Exactly one <h1> on this page. */}
<h1 className={DISPLAY}>{category.name}</h1>
{/* THE HUB'S OWN CONTENT. Keep it. See the header block for why a category
page that is only a list of links is a crawl liability. */}
<p className={LEDE}>{category.description}</p>
<p className={META}>{posts.length === 1 ? '1 post' : `${posts.length} posts`}</p>
</div>
</header>
<div className={CLUSTER_GAP}>
{/* Sibling hubs, so a crawler that lands here has a path to the rest of
the taxonomy without going back through the site navigation. */}
<CategoryPills activeSlug={category.slug} categories={categories} />
<PostList posts={posts} />
</div>
</div>
{/* One serialization point. The breadcrumb is the only graph this page
owns: the posts it links to emit their own Article nodes, and claiming
a `Blog` node at this URL would duplicate the `@id` that `/blog` uses. */}
<JsonLd nodes={[buildBreadcrumb(trail, `/blog/category/${category.slug}`)]} />
</>
)
}
registry/blog/app/blog/tag/[slug]/page.tsx
/**
* A tag archive. Noindexed while it is thin, indexable once it is not.
*
* ---------------------------------------------------------------------------
* FOR THE HUMAN MAINTAINER AND FOR THE CODING AGENT EDITING THIS FILE
* ---------------------------------------------------------------------------
* Tags are freeform, so a blog with 60 posts routinely produces 200 tag pages,
* most of them listing one or two posts and containing no writing of their own.
* Indexing all of them is the classic way a blog spends its crawl budget on
* near-empty URLs that compete with the posts they link to.
*
* So this route flips its own robots directive on a count:
*
* fewer than `config.noindexTagsBelow` posts `index: false, follow: true`
* at or above the threshold indexable
*
* `follow: true` in both cases, always. `noindex` says "do not list this URL in
* results". `nofollow` would say "do not use the links on it", which would cut
* the only crawl path from this page to the posts it lists. Noindex plus follow
* is the combination that removes the thin page from the index while keeping the
* link equity flowing through it.
*
* The threshold is a config value rather than a constant because the right
* number depends on how a given blog uses tags. Change it in
* `agentblog.config.ts`, not here.
*
* @see https://docs.agentblog.dev/reference/files
* @see https://docs.agentblog.dev/concepts/geo-playbook*/
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { Breadcrumbs } from '@/components/blog/breadcrumbs'
import { JsonLd } from '@/components/blog/json-ld'
import { PostList } from '@/components/blog/post-list'
import {
DISPLAY,
META,
PAGE_HEADER,
SECTION_GAP,
TITLE_CLUSTER,
} from '@/components/blog/type-scale'
import { blogPath, config, tagUrl } from '@/lib/config'
import { buildListMetadata, googleBotDefaults } from '@/lib/metadata'
import { getAllTags, getPostsByTag } from '@/lib/posts'
import { buildBreadcrumb } from '@/lib/schema'
import { cn } from '@/lib/utils'
/*
* ISR window in seconds. THIS MUST STAY A NUMERIC LITERAL.
*
* `export const revalidate = config.revalidate` does not work. Next.js reads
* route segment config both by evaluating the module and by statically reading
* the AST (`next/dist/build/analysis/extract-const-value.js`), and the AST
* reader accepts only literals. A member expression is reported as
* `Unsupported node type "MemberExpression"`, which throws in `next dev` (error
* E1013) and logs a build error in production.
*
* Keep this equal to `revalidate` in `agentblog.config.ts`. `agentblog doctor`
* compares the two, because nothing in the type system can.
*/
export const revalidate = 3600
/*
* Default, written out so nobody sets it to `false`. A tag introduced by a post
* published after the last build would otherwise 404, and Next.js does not
* re-run `generateStaticParams` during ISR to add it.
*/
export const dynamicParams = true
/**
* Every tag, including the thin ones.
*
* Prerendering a tag page that is going to be noindexed is not wasted work. The
* page still has to serve a real 200 with real links on it, because `follow` is
* on and those links are a crawl path to the posts. Filtering the list here to
* "only the indexable tags" would leave the rest rendering on demand for no
* benefit.
*
* Next.js encodes these values into the URL and decodes them back into `params`,
* so a tag containing a space or a slash round trips without any manual
* `encodeURIComponent` at this layer.
*/
export async function generateStaticParams() {
const tags = await getAllTags()
// `slug`, never `tag`. The display text may contain spaces and capitals; the
// route segment must not.
return tags.map(({ slug }) => ({ slug }))
}
/**
* The authored text for a tag slug, for everything a reader or a search result
* actually sees.
*
* The route segment is the slug (`ai-crawlers`), which is the right shape for a
* URL and the wrong shape for a heading, a `<title>`, a meta description, or a
* breadcrumb. `getAllTags()` returns `{ tag, slug, count }` precisely so the
* authored spelling ("AI crawlers") is recoverable from the slug, and it keeps
* the first spelling it saw so the display text is stable across builds.
*
* Falls back to the slug for a tag the source no longer knows about, which is
* the honest answer: it is better to show `ai-crawlers` than to fail rendering a
* page that still lists posts.
*/
async function displayTag(slug: string): Promise<string> {
const tags = await getAllTags()
return tags.find((entry) => entry.slug === slug)?.tag ?? slug
}
export async function generateMetadata(props: PageProps<'/blog/tag/[slug]'>): Promise<Metadata> {
const { slug } = await props.params
const posts = await getPostsByTag(slug)
if (posts.length === 0) return {}
const label = await displayTag(slug)
const indexable = posts.length >= config.noindexTagsBelow
/*
* `buildListMetadata` is where `openGraphDefaults` and `robotsDefaults` get
* spread. Metadata merging in Next.js is shallow: an inline `openGraph` object
* here would replace the root layout's entire object and drop `og:site_name`,
* and an inline `robots` object would drop `max-snippet: -1`, the directive
* that permits full snippets in AI answers.
*/
const base = buildListMetadata({
title: `Posts tagged ${label}`,
description: `Every ${config.brand.name} post tagged ${label}.`,
path: `/blog/tag/${slug}`,
index: indexable,
})
const metadata: Metadata = {
...base,
// Canonical composed by `tagUrl`, never by concatenating `siteUrl`.
alternates: { ...base.alternates, canonical: tagUrl(slug) },
}
if (indexable) return metadata
/*
* The thin-page branch, written out rather than left to the builder so the
* reasoning is visible at the point of decision.
*
* `robots` is a nested object, so setting it here replaces whatever the root
* layout declared, wholesale. That is why `googleBotDefaults` is spread into
* `googleBot` before the two overrides: without the spread, this page would
* ship `noindex` and would also silently lose `max-image-preview: large` and
* `max-snippet: -1`. Those two directives govern how much of a page an AI
* answer may quote, and losing them on a followed page is a real regression
* even though the page itself is not indexed.
*/
return {
...metadata,
robots: {
index: false,
follow: true,
googleBot: { ...googleBotDefaults, index: false, follow: true },
},
}
}
export default async function TagPage(props: PageProps<'/blog/tag/[slug]'>) {
// `params` is a Promise in Next.js 16 and synchronous access was removed.
// The type comes from `next typegen` rather than being hand written, so it
// tracks the real route tree.
const { slug } = await props.params
const posts = await getPostsByTag(slug)
// A tag nobody uses is a real 404. Rendering an empty archive with a 200 is a
// soft 404 and would let any mistyped tag in any inbound link mint a new
// contentless URL.
if (posts.length === 0) notFound()
// The authored spelling, not the URL slug. See `displayTag` above.
const label = await displayTag(slug)
// No Home crumb. See `breadcrumbs.tsx` for why the trail starts at the first
// level that tells a reader something they cannot get from the address bar.
const trail: { name: string; path?: string }[] = [
{ name: 'Blog', path: blogPath() },
{ name: label },
]
return (
<>
<div className={cn('mx-auto w-full max-w-(--agentblog-rail) px-6', SECTION_GAP)}>
<header className={PAGE_HEADER}>
<Breadcrumbs trail={trail} />
<div className={TITLE_CLUSTER}>
{/* Exactly one <h1> on this page. */}
<h1 className={DISPLAY}>{`Posts tagged ${label}`}</h1>
<p className={META}>{posts.length === 1 ? '1 post' : `${posts.length} posts`}</p>
</div>
</header>
<PostList posts={posts} />
</div>
{/* One serialization point. Only the breadcrumb: the listed posts emit
their own Article nodes on their own routes, and a tag archive has no
content of its own to describe. */}
<JsonLd nodes={[buildBreadcrumb(trail, `/blog/tag/${slug}`)]} />
</>
)
}
registry/blog/app/authors/layout.tsx
/**
* Layout for every route under `/authors`.
*
* ---------------------------------------------------------------------------
* WHY THIS FILE EXISTS AT ALL
* ---------------------------------------------------------------------------
* It does exactly one job: emit the `Organization` and `WebSite` nodes.
*
* Both the article graph and the person graph reference those two nodes by
* `@id`. `BlogPosting.publisher` and `Person.worksFor` both point at
* `{siteUrl}/#organization`, and `WebPage.isPartOf` points at
* `{siteUrl}/#website`. An `@id` reference is a pointer, not a definition, so
* something rendered on the page has to define what it points at. If nothing
* does, the reference dangles: the graph still validates, because a validator
* cannot know what a missing node was supposed to contain, and it still means
* nothing, because the publisher and the employer resolve to no entity.
*
* `app/blog/layout.tsx` does this for everything under `/blog`. `/authors`
* lives outside `/blog` and therefore inherits none of it, which left the one
* page whose entire purpose is entity resolution emitting a `Person` whose
* `worksFor` resolved to nothing.
*
* ---------------------------------------------------------------------------
* WHAT BREAKS IF YOU CHANGE IT
* ---------------------------------------------------------------------------
* - Deleting this file returns `/authors/[slug]` to a dangling `worksFor`.
* - Moving `buildOrgGraph()` into your root layout instead means every blog page
* emits the two nodes twice, once from here or from `app/blog/layout.tsx` and
* once from the root. Two nodes sharing an `@id` make the graph ambiguous.
* Pick one place per route subtree.
* - Do not add `openGraph` or `robots` here. Metadata merging is shallow, so
* defining either at this level replaces the root layout's entire object and
* silently drops `og:site_name`. The author page builds its metadata through
* `@/lib/metadata`, which spreads the shared defaults.
*
* No `'use client'`, and no wrapper markup: the author page supplies its own
* content column so this layout stays a pure structured-data carrier.
*
* @see https://docs.agentblog.dev/reference/files
* @see app/blog/layout.tsx, which makes the same decision for `/blog`
*/
import { JsonLd } from '@/components/blog/json-ld'
import { buildOrgGraph } from '@/lib/schema'
export default function AuthorsLayout({ children }: LayoutProps<'/authors'>) {
return (
<>
<JsonLd nodes={buildOrgGraph()} />
{children}
</>
)
}
registry/blog/app/authors/[slug]/page.tsx
/**
* An author page. This is an E-E-A-T lookup surface, not an archive with a
* headshot on it.
*
* ---------------------------------------------------------------------------
* FOR THE HUMAN MAINTAINER AND FOR THE CODING AGENT EDITING THIS FILE
* ---------------------------------------------------------------------------
* Google's Search Quality Rater Guidelines direct raters to look up the author
* of a page and find out who they are. An AI answer engine does something
* structurally similar and cheaper: it follows `author` from the Article node to
* this URL and reads the `Person` graph it finds. So the three properties that
* matter most here are the ones a human would go looking for:
*
* `sameAs` Absolute profile URLs. The single highest value property in
* the whole block, because it is what turns the string "Jane Doe"
* into a resolvable entity. A bare handle such as `@janedoe`
* disambiguates nothing, which is why `AbsoluteUrl` rejects it
* at the schema layer rather than here.
* `knowsAbout` The topics this person is credible on.
* `worksFor` The organization, as a URL rather than a name string.
*
* All three come from `content/authors.json` through `buildPersonGraph`. If an
* author record has none of them, this page still renders, and it is worth
* roughly nothing. Filling them in is the cheapest credibility work available.
*
* This route lives at `/authors/[slug]`, outside `/blog`, so it does not inherit
* `app/blog/layout.tsx`. That is deliberate: the author page is a site-level
* entity page, not a blog archive page.
*
* @see https://docs.agentblog.dev/reference/files
* @see https://docs.agentblog.dev/concepts/geo-playbook
*/
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { AuthorBio } from '@/components/blog/author-bio'
import { Breadcrumbs } from '@/components/blog/breadcrumbs'
import { JsonLd } from '@/components/blog/json-ld'
import { PostList } from '@/components/blog/post-list'
import {
CLUSTER_GAP,
DISPLAY,
PAGE_HEADER,
SECTION_GAP,
SECTION_HEADING,
} from '@/components/blog/type-scale'
import { authorUrl, blogPath } from '@/lib/config'
import { buildListMetadata, googleBotDefaults } from '@/lib/metadata'
import { getAllAuthors, getAuthor, getPostsByAuthor } from '@/lib/posts'
import { buildBreadcrumb, buildPersonGraph } from '@/lib/schema'
import { cn } from '@/lib/utils'
/*
* ISR window in seconds. THIS MUST STAY A NUMERIC LITERAL.
*
* `export const revalidate = config.revalidate` does not work. Next.js reads
* route segment config both by evaluating the module and by statically reading
* the AST (`next/dist/build/analysis/extract-const-value.js`), and the AST
* reader accepts only literals. A member expression is reported as
* `Unsupported node type "MemberExpression"`, which throws in `next dev` (error
* E1013) and logs a build error in production.
*
* Keep this equal to `revalidate` in `agentblog.config.ts`. `agentblog doctor`
* compares the two, because nothing in the type system can.
*/
export const revalidate = 3600
/*
* Default, written out so nobody sets it to `false`. An author added after the
* last build would otherwise 404, and a 404 on the page that an Article node's
* `author` property points at breaks the entity link the whole graph depends on.
*/
export const dynamicParams = true
/**
* Every author. Not a subset.
*
* Each of these URLs is referenced by `@id` from every Article that person
* wrote, so a missing prerender is a dangling entity reference in the graph
* rather than merely a slow page.
*/
export async function generateStaticParams() {
const authors = await getAllAuthors()
return authors.map((author) => ({ slug: author.slug }))
}
export async function generateMetadata(props: PageProps<'/authors/[slug]'>): Promise<Metadata> {
const { slug } = await props.params
const author = await getAuthor(slug)
if (!author) return {}
/*
* An author with no published posts is a real page with nothing on it: a name,
* a bio, and an empty list. It stays a 200, because a writer whose first post
* is still in draft should have a page the moment their record exists, and
* because this URL is the target of every `author` link in every Article
* graph. But it must not be indexed while it is empty, for the same reason the
* tag route noindexes a thin archive.
*
* `follow` stays true in both branches. `noindex` says "do not list this URL";
* `nofollow` would say "do not use the links on it", which would cut the crawl
* path from this page to the posts it will list as soon as there are any.
* `app/sitemap.ts` applies the same rule and skips zero-post authors, because a
* noindexed URL in a sitemap is the site contradicting itself in two
* machine-readable surfaces.
*/
const posts = await getPostsByAuthor(author.slug)
const indexable = posts.length > 0
/*
* `buildListMetadata` is where `openGraphDefaults` and `robotsDefaults` get
* spread. Metadata merging in Next.js is shallow, so writing an `openGraph`
* object inline here would replace the root layout's entire object and drop
* `og:site_name`, and writing a `robots` object inline would drop
* `max-snippet: -1`, the directive that permits full snippets in AI answers.
* Neither failure is visible to TypeScript or to any schema validator.
*/
const base = buildListMetadata({
title: author.name,
// The bio doubles as the meta description because it is the one piece of
// author copy written for a stranger. `AuthorSchema` requires it for this
// reason.
description: author.bio,
path: `/authors/${author.slug}`,
index: indexable,
...(author.avatar ? { images: [author.avatar] } : {}),
})
const metadata: Metadata = {
...base,
// Canonical composed by `authorUrl`, never by concatenating `siteUrl`.
alternates: { ...base.alternates, canonical: authorUrl(author.slug) },
}
if (indexable) return metadata
/*
* The empty-author branch, written out rather than left to the builder so the
* reasoning is visible at the point of decision.
*
* `robots` is a nested object, so setting it here replaces whatever the root
* layout declared, wholesale. That is why `googleBotDefaults` is spread into
* `googleBot` before the two overrides: without the spread this page would
* ship `noindex` and would also silently lose `max-image-preview: large` and
* `max-snippet: -1`, the directives that govern how much of a page an AI
* answer may quote.
*/
return {
...metadata,
robots: {
index: false,
follow: true,
googleBot: { ...googleBotDefaults, index: false, follow: true },
},
}
}
export default async function AuthorPage(props: PageProps<'/authors/[slug]'>) {
// `params` is a Promise in Next.js 16 and synchronous access was removed.
// The type comes from `next typegen`, so it tracks the real route tree.
const { slug } = await props.params
const author = await getAuthor(slug)
// An absent author is a real 404. A soft 404 here is worse than usual: this
// URL is the target of every `author` link in every Article graph, so an
// empty 200 would present a credible-looking entity page with nothing behind
// it.
if (!author) notFound()
const posts = await getPostsByAuthor(author.slug)
// No Home crumb. See `breadcrumbs.tsx` for why the trail starts at the first
// level that tells a reader something they cannot get from the address bar.
const trail: { name: string; path?: string }[] = [
{ name: 'Blog', path: blogPath() },
{ name: author.name },
]
return (
/*
* This route lives under `/authors`, whose layout is a JSON-LD wrapper and
* nothing else, so unlike the blog routes this page supplies its own gutter
* and vertical padding. The values match `app/blog/layout.tsx` exactly: an
* author page that is 32px narrower than the blog it belongs to is the kind
* of thing nobody reports and everybody notices.
*/
<div className="bg-background text-foreground py-12 lg:py-20">
<div className={cn('mx-auto w-full max-w-(--agentblog-rail) px-6', SECTION_GAP)}>
<header className={PAGE_HEADER}>
{/* Breadcrumb paths come from the config path helpers, so the visible
trail and the BreadcrumbList built from the same array below cannot
drift. */}
<Breadcrumbs trail={trail} />
{/* Exactly one <h1>, and it is the person's name and nothing else. Job
titles, honorifics, and the publisher name belong in `jobTitle` and
`worksFor`, which is why `AuthorSchema` splits them out. */}
<h1 className={DISPLAY}>{author.name}</h1>
</header>
{/* `postCount` is filled here and omitted on the post page. The query is
already being made on this route, so it is free; on the post page it
would mean one extra source call per post at build time.
`linkName={false}` because this IS the author page. Left on, the box
renders `<h2><a rel="author" href="/authors/...">Name</a></h2>`
directly below the `<h1>Name</h1>` above: a link to the page you are
already on, and the person's name as two consecutive headings. */}
<AuthorBio
author={author}
className="max-w-(--agentblog-measure)"
linkName={false}
postCount={posts.length}
showDetails={false}
/>
{author.knowsAbout.length > 0 ? (
<section aria-labelledby="expertise" className={CLUSTER_GAP}>
<h2 className={SECTION_HEADING} id="expertise">
Areas of expertise
</h2>
{/* Rendered visibly as well as in the graph. `knowsAbout` in JSON-LD
that corresponds to nothing on the page is markup for invisible
content, which is the same policy problem as an invisible FAQ. */}
<ul className="text-muted-foreground flex flex-wrap gap-2 text-sm">
{author.knowsAbout.map((topic) => (
<li className="border-border rounded-full border px-3 py-1" key={topic}>
{topic}
</li>
))}
</ul>
</section>
) : null}
{author.sameAs.length > 0 ? (
<section aria-labelledby="elsewhere" className={CLUSTER_GAP}>
<h2 className={SECTION_HEADING} id="elsewhere">
Elsewhere
</h2>
{/* Same rule as above: these are the `sameAs` entries, rendered as
real links so the entity claim is verifiable by a reader too.
The card above is passed `showDetails={false}` so this section is
the only place they appear. */}
<ul className="text-muted-foreground space-y-2 text-sm">
{author.sameAs.map((profile) => (
<li key={profile}>
<a
className="hover:text-foreground underline underline-offset-4 transition-colors"
href={profile}
rel="me noopener"
>
{profile}
</a>
</li>
))}
</ul>
</section>
) : null}
<section aria-labelledby="posts" className={CLUSTER_GAP}>
<h2 className={SECTION_HEADING} id="posts">
{`Posts by ${author.name}`}
</h2>
{/* `headingLevel={3}` because this list sits under the `<h2 id="posts">`
above. Card titles at H2 would make each post a sibling of the
section that contains them and flatten the outline. */}
<PostList headingLevel={3} posts={posts} />
</section>
</div>
{/* One serialization point. The `Person` node is the whole reason this
route exists; the breadcrumb comes from the same array the visible
trail above is rendered from. */}
<JsonLd
nodes={[buildPersonGraph(author), buildBreadcrumb(trail, `/authors/${author.slug}`)]}
/>
</div>
)
}
registry/blog/lib/og-card.tsx
/**
* The Open Graph card, in one file.
*
* `app/blog/opengraph-image.tsx` and `app/blog/[slug]/opengraph-image.tsx` both
* render this. They used to hold two copies of the same layout with a note
* telling you to keep seven constants in sync by hand, which is a rule that
* holds until the first time somebody changes one card and ships.
*
* ---------------------------------------------------------------------------
* THE COLOUR LITERALS IN THIS FILE ARE INTENTIONAL AND SANCTIONED
* ---------------------------------------------------------------------------
* Everywhere else in this block a hardcoded colour is a lint failure: pages use
* semantic tokens (`bg-background`, `text-muted-foreground`) so the theme flips
* correctly and the block inherits your design system.
*
* This file is the documented exception, and it is an exception of necessity.
* Satori has no DOM, no stylesheet, and therefore no CSS custom properties. It
* cannot read `--background`. A literal is the only way to colour a PNG.
*
* Making the card yours is a four line edit. Change the four colours below to
* the resolved values of your own `--background`, `--card`, `--foreground`, and
* `--muted-foreground`. Resolved, not `var(--card)`: paste the hex or `oklch()`
* your stylesheet ends up at. Nothing else in the card needs touching, and no
* other file repeats these values.
*
* ---------------------------------------------------------------------------
* TYPOGRAPHY, AND WHY THERE IS EXACTLY ONE WEIGHT
* ---------------------------------------------------------------------------
* `next/og` bundles Geist Regular and uses it when no `fonts` option is passed.
* That is one weight, 400, and asking for `fontWeight: 600` does not synthesise
* a bolder cut: Satori picks the nearest weight it has, which is the one it has.
* So the hierarchy here is carried by size, colour, and tracking rather than by
* weight, which is the correct way to build it anyway.
*
* To use your own typeface, read a `.ttf` or `.otf` at module scope and pass it
* through the `fonts` option of `ImageResponse` in both routes:
*
* const brand = await readFile(join(process.cwd(), 'public/fonts/Brand.ttf'))
* new ImageResponse(ogCard({ ... }), {
* ...OG_SIZE,
* fonts: [{ name: 'Brand', data: brand, weight: 400, style: 'normal' }],
* })
*
* and add `fontFamily: 'Brand'` to the root style below. Only `ttf`, `otf`, and
* `woff` parse, and the whole `ImageResponse` bundle (JSX, CSS, fonts, images)
* has a 500KB ceiling, so one weight of one family is the realistic budget.
*
* ---------------------------------------------------------------------------
* HARD LIMITS
* ---------------------------------------------------------------------------
* - `opengraph-image` output must be at most 8MB or the build fails. The
* `twitter-image` limit is 5MB. This layout produces a few tens of kilobytes,
* so the only realistic way to breach it is embedding a large asset.
* - Satori supports flexbox and a subset of CSS. `display: grid` does not work,
* and every element with children below sets `display: flex` explicitly
* because Satori requires it rather than inferring it.
* - Any file read happens once, at module scope. A `readFile` inside the render
* path runs on every card, which for two hundred posts is two hundred reads
* for byte-identical output.
*
* @see https://docs.agentblog.dev/reference/files
* @see https://vercel.com/docs/og-image-generation
*/
import { readFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { config } from '@/lib/config'
/* -------------------------------------------------------------------------- */
/* Palette. Literals are permitted in this file only. See the header. */
/* -------------------------------------------------------------------------- */
/** The bleed behind the panel. Your `--background`. */
const BLEED = '#0b0b0b'
/** The panel itself. Your `--card`. */
const PANEL = '#1e1e1e'
/** Title and wordmark. Your `--foreground`. */
const INK = '#ededed'
/** The eyebrow. Your `--muted-foreground`. */
const MUTED = '#8f8f8f'
/* -------------------------------------------------------------------------- */
/* Geometry */
/* -------------------------------------------------------------------------- */
/** 1200x630 is what every platform crops against. Do not change it. */
export const OG_SIZE = { width: 1200, height: 630 } as const
export const OG_CONTENT_TYPE = 'image/png'
/** The panel floats inside the canvas, so the card has an edge on any feed. */
const PANEL_INSET = 24
const PANEL_RADIUS = 48
/** Distance from the canvas edge to the content. The panel absorbs the inset. */
const MARGIN = 80
/** The brand mark, square. A non-square logo is letterboxed by `objectFit`. */
const MARK_SIZE = 160
/* -------------------------------------------------------------------------- */
/* The brand mark */
/* -------------------------------------------------------------------------- */
/**
* Refuse anything that would eat the 500KB `ImageResponse` budget on its own.
*
* A logo over this size is nearly always a full resolution export that was never
* meant for a 160px slot. Resize it rather than raising this number.
*/
const MAX_LOGO_BYTES = 150_000
const LOGO_MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
}
/**
* Read `brand.logo.url` out of `public/` once, at module scope, and inline it as
* a data URI.
*
* ---------------------------------------------------------------------------
* IF YOUR CARDS SHOW A WORDMARK WHERE YOUR LOGO SHOULD BE, IT IS ONE OF THESE
* ---------------------------------------------------------------------------
* Every failure path returns `null` and the card falls back to your brand name
* set in the mark's position. That is deliberate: a missing logo must not fail
* the build of every route that has an OG image. It does mean the failure is
* quiet, so the four causes are listed here and `agentblog doctor` reports them.
*
* 1. `brand.logo.url` is a remote URL. Refused on purpose. A remote logo is a
* network fetch per generation, which is exactly the per-request work this
* module-scope read exists to avoid. Copy the file into `public/`.
* 2. The extension is not `.png`, `.jpg`, `.jpeg`, or `.svg`. A `.webp` or an
* `.ico` will not rasterise here.
* 3. There is no such file under `public/`. `brand.logo.url` is a URL path, so
* `/logo.png` means `public/logo.png`.
* 4. The file is over 150KB. See `MAX_LOGO_BYTES`.
*
* If you have no logo at all, export a square PNG at 512x512 with a transparent
* background, save it as `public/logo.png`, and set
* `brand: { logo: { url: '/logo.png', width: 512, height: 512 } }` in
* `agentblog.config.ts`. Those dimensions are also what `Organization.logo` in
* the JSON-LD graph needs, so one file serves the card and the structured data.
* Google does not accept SVG for `Organization.logo`, which is why the config
* field wants a raster even though the card here will take either.
*/
async function readBrandMark(): Promise<string | null> {
const url = config.brand.logo.url
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return null
const mime = LOGO_MIME[extname(url).toLowerCase()]
if (mime === undefined) return null
try {
const bytes = await readFile(join(process.cwd(), 'public', url.replace(/^\/+/, '')))
if (bytes.byteLength > MAX_LOGO_BYTES) {
console.warn(
`[agentblog] ${url} is ${bytes.byteLength} bytes, over the ${MAX_LOGO_BYTES} byte budget ` +
'for an inlined Open Graph asset. Falling back to a text wordmark.',
)
return null
}
return `data:${mime};base64,${bytes.toString('base64')}`
} catch {
return null
}
}
const brandMark = await readBrandMark()
/* -------------------------------------------------------------------------- */
/* Type scale */
/* -------------------------------------------------------------------------- */
/**
* The title size, stepped by length so a short title fills the canvas and a long
* one still fits.
*
* A single fixed size is the usual approach and it is what makes most generated
* cards look wrong: at a size chosen for the longest title, a five word headline
* sits marooned in the middle of the panel.
*
* The steps are derived rather than guessed. Geist averages close to 0.52em per
* character, the line box is `1200 - 2 * MARGIN` wide, and `PostSchema` caps a
* title at 70 characters. Every step below therefore lands on three lines or
* fewer, and three lines at `TITLE_LINE_HEIGHT` clears the mark above it. Raise
* the schema cap and this function is what has to move with it.
*/
export function titleFontSize(title: string): number {
if (title.length <= 30) return 76
if (title.length <= 50) return 68
return 60
}
const TITLE_LINE_HEIGHT = 1.1
const EYEBROW_SIZE = 32
/* -------------------------------------------------------------------------- */
/* The card */
/* -------------------------------------------------------------------------- */
interface OgCardProps {
/**
* The line above the title. Omit it on a card that stands for a whole surface
* rather than one post, where there is no honest thing for it to say.
*/
readonly eyebrow?: string
readonly title: string
}
/**
* The card, as a Satori element tree.
*
* The mark sits in its own row at the top and the text block takes the rest of
* the height with `justifyContent: 'center'`, so the block is centred in the
* space the mark leaves rather than in the whole canvas. Centring against the
* canvas is the obvious reading of "vertically centred" and it collides with the
* mark on a three line title, which only shows up on the longest post you have.
*/
export function ogCard({ eyebrow, title }: OgCardProps) {
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: BLEED,
padding: PANEL_INSET,
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
backgroundColor: PANEL,
borderRadius: PANEL_RADIUS,
// The panel already carries `PANEL_INSET`, so this is what brings the
// content to `MARGIN` from the canvas edge.
padding: MARGIN - PANEL_INSET,
color: INK,
}}
>
{brandMark ? (
/*
* The disable has to be here rather than at the top of the file.
*
* `ImageResponse` renders through Satori, which implements a small
* subset of HTML and has no concept of `next/image`. A plain `img` is
* the only element available, and the output is a PNG rather than a
* page, so the LCP advice the rule gives does not apply.
*
* A file-level eslint-disable banner would be the tidier place for
* that, and it does not survive the trip: `shadcn add` strips the
* leading comment block from every file it installs. The banner would
* be present in this repo and absent in yours, so the warning would
* appear in your project and never in ours, which is the worst place
* for a lint rule to disagree. A directive attached to the line it
* governs sits inside the JSX and is copied along with it.
*/
// eslint-disable-next-line @next/next/no-img-element -- see above
<img
src={brandMark}
width={MARK_SIZE}
height={MARK_SIZE}
alt=""
style={{ objectFit: 'contain' }}
/>
) : (
/*
* The fallback when there is no readable logo. See `readBrandMark` for
* the four reasons you are looking at this instead of your mark.
*/
<div
style={{
display: 'flex',
alignItems: 'center',
height: MARK_SIZE,
fontSize: 40,
letterSpacing: -1,
}}
>
{config.brand.name}
</div>
)}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{eyebrow !== undefined ? (
<div
style={{
display: 'flex',
fontSize: EYEBROW_SIZE,
color: MUTED,
// Geist tightens as it grows, so the eyebrow is the one line
// that reads better at its natural tracking.
letterSpacing: 0,
marginBottom: 20,
}}
>
{eyebrow}
</div>
) : null}
<div
style={{
display: 'flex',
fontSize: titleFontSize(title),
lineHeight: TITLE_LINE_HEIGHT,
// Negative tracking at display sizes is what makes a large Geist
// heading read as one shape rather than as spaced out letters.
letterSpacing: -2,
}}
>
{title}
</div>
</div>
</div>
</div>
)
}