Building with AI

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.

Val OvinnikovVal Ovinnikov3 min read
A single glowing content card radiating smaller cards — a social preview, a sitemap node, an RSS mark — all clearly born from the same source, warm tones.

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.

apps/web/src/app/blog/[slug]/page.tsx
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:

apps/web/src/app/sitemap.ts -->
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.

2 min

Rendering Portable Text in Next.js 16

Portable Text is just JSON until you render it. How I mapped Sanity's block format to React components — headings, links, and real code blocks — in the App Router.

building with ai
3 min

Statically Rendered, Instantly Fresh

Static pages that update the moment I publish. How a Sanity webhook triggers on-demand revalidation so a fully static Next.js site never serves stale content.

building with ai