SEO That Generates Itself
No hand-written meta tags, ever. How titles, Open Graph, sitemap and RSS all fall out of the same schema the posts already use — generated, not maintained.

Hand-written meta tags are a lie waiting to happen. You copy a title into an Open Graph field, the post title changes, and now the share card disagrees with the page. On this blog I never touch meta tags directly — titles, descriptions, social cards, sitemap, and RSS all fall out of the same schema the posts already use. It's generated, not maintained. Part of the Building with AI series.
The post already knows its metadata
The insight is that a post's SEO fields aren't new data — they're the fields I already wrote. The title is the meta title, the excerpt is the meta description (that's why it's constrained to 50–300 characters), and the hero is the share image. So metadata isn't authored twice; it's derived once, in the App Router's generateMetadata.
1import type { Metadata } from "next";
2import { getPostBySlug } from "@repo/service";
3
4export async function generateMetadata(
5 { params }: { params: Promise<{ slug: string }> }
6): Promise<Metadata> {
7 const post = await getPostBySlug((await params).slug);
8 return {
9 title: post.title,
10 description: post.excerpt,
11 openGraph: {
12 title: post.title,
13 description: post.excerpt,
14 images: [{ url: post.heroUrl, width: 2400, height: 1260 }],
15 },
16 };
17}The highlighted line matters: metadata comes through the same typed service layer as the page content, from the same query. There's no separate SEO source that can drift, because there's no separate source at all.
The sitemap is a query, not a file
A hand-maintained sitemap is stale the moment you publish. Next.js lets the sitemap be code, so it's just another read through the service layer — publish a post and it's listed on the next build:
1import type { MetadataRoute } from "next";
2import { getAllSlugs } from "@repo/service";
3
4export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
5 const slugs = await getAllSlugs();
6 return slugs.map((s) => ({
7 url: `https://blog.example.com/blog/${s.current}`,
8 lastModified: s.updatedAt,
9 }));
10}RSS works the same way — one route handler maps the same typed posts into feed items. Three surfaces, one source.
An override hatch, used rarely
Deriving everything is right almost always, but not never. The schema keeps an optional seo object for the rare post that needs a custom meta title or a bespoke share image. The rule is that it's empty by default and the derived values win unless I deliberately override — so the exception can't quietly become the norm.
Generate metadata from the content, and the content and its SEO can never disagree.
Why this suits an agent-built site
Derived metadata isn't just tidy — it's safe to let an agent extend. When every SEO surface reads from the schema, an agent adding a field can't forget to "also update the meta tags," because there are no meta tags to update. The correctness lives in one place, which is the same reason the content model and the service layer were worth centralizing.
Don't maintain metadata — derive it. The title you wrote is the only title, everywhere it appears. Next in the series: how those generated pages stay static and still update the instant I hit publish — Statically Rendered, Instantly Fresh.
