Most SEO advice for a single-page app stops at "add a meta tag". That's the part that doesn't matter. A meta tag is the last centimeter of a much longer pipeline, and every interesting failure happens upstream of it — silently, with a green build, while the page works fine for users and is nearly invisible to a crawler.
This article walks through that whole pipeline in the Next.js App Router, built around one rule: nothing SEO-related is written twice, and nothing is maintained by hand. Every artifact — metadata, structured data, sitemap, robots, Open Graph images — is derived from a single source of truth, and a CI gate refuses to ship if that derivation breaks. There's a companion repository you can clone and run; links are at the end.
Every artifact reads from the same lib/posts.ts. None of them keeps its own copy of the content, so none of them can disagree with it:
%%{init: {'theme':'dark'}}%%
flowchart LR
MD["markdown posts (source of truth)"] --> LIB["lib/posts.ts"]
LIB --> META["generateMetadata + canonical"]
LIB --> JSONLD["JSON-LD article"]
LIB --> OG["opengraph-image (next/og)"]
LIB --> SM["sitemap.ts"]
LIB --> RB["robots.ts"]
LIB --> SP["generateStaticParams (prerender)"]
A post is a markdown file with frontmatter. A single module reads it and — this is the important part — fails loud if a post is missing a field SEO depends on, instead of shipping a page with a blank <title>:
const REQUIRED_FIELDS = ["title", "description", "date"] as const;
function readPost(fileName: string): Post {
const { data, content } = matter(raw);
const missing = REQUIRED_FIELDS.filter((field) => !data[field]);
if (missing.length) {
throw new Error(`Post "${slug}" is missing required frontmatter: ${missing.join(", ")}`);
}
// ...normalize dates, keywords, reading time...
}
Everything downstream reads from this one function. None of the artifacts keep their own copy of the content, so none of them can disagree with it.
In the App Router, generateMetadata runs on the server, per route. That is the whole point: the crawler reads the HTML the server produced, so the metadata has to exist before the page renders — not in a useEffect, not on the client.
The common mistake is writing the title and description twice: once in the visible page, once in the metadata. They drift within a week. Instead, both read the same record:
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return {};
const url = absoluteUrl(`/posts/${slug}`);
return {
title: post.title,
description: post.description,
keywords: post.keywords,
alternates: { canonical: url },
openGraph: { type: "article", url, publishedTime: post.date },
};
}
'use client' boundary is a SEO decisiongenerateMetadata only works in a Server Component. The moment you put 'use client' at the top of a page so you can use a hook, that route can no longer export metadata. Next does not error — it just stops emitting your tags. The fix is to keep the page a Server Component and push the interactivity down into a small client child:
export default async function PostPage({ params }) {
// Server Component — generateMetadata still runs
return (
<article>
<ReadingProgress /> {/* the only 'use client' island */}
{/* server-rendered content */}
</article>
);
}
The rule: 'use client' goes on the smallest leaf that actually needs the browser, never on a layout or a page. Interactivity is an island, not a flood.
metadataBase is set onceWithout it, Open Graph image URLs go out relative. They look fine in local preview and fail the moment a link is pasted into LinkedIn or WhatsApp, because the social scraper has no base to resolve against. One line in the root layout fixes every absolute URL downstream:
export const metadata: Metadata = {
metadataBase: new URL(SITE.url),
// ...
};
A hand-maintained sitemap is wrong the day you forget to update it. In the App Router sitemap.ts is a function, so it reads the same posts the pages do:
export default function sitemap(): MetadataRoute.Sitemap {
return getAllPosts().map((p) => ({
url: absoluteUrl(`/posts/${p.slug}`),
lastModified: p.updated ?? p.date,
}));
}
robots.ts is the same idea, and opengraph-image.tsx with next/og renders a real PNG per post at build time — no design tool, no stale asset committed to the repo. (The cover of this very article is one of those generated cards.)
All of the above is correct only if it stays correct. That's the job of CI. The pipeline is deliberately two steps, cheap-first:
%%{init: {'theme':'dark'}}%%
flowchart TD
A["push or pull_request"] --> B["npm run seo:check"]
B -->|missing field or dup slug| X["build blocked"]
B -->|ok| C["npm run build"]
C --> D["prerender pages, sitemap, robots, OG"]
D --> E["deploy"]
The workflow itself:
name: SEO check
on:
push:
branches: [main]
pull_request:
jobs:
seo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
# Guard the source of truth first — cheap, fails fast on bad frontmatter.
- run: npm run seo:check
# Then prove the SEO artifacts actually build (metadata, sitemap,
# robots, OG images are all produced during the build).
- run: npm run build
Two things make this professional rather than decorative:
seo:check runs before build. Validating frontmatter takes milliseconds; a full build takes minutes. Ordering the cheap guard first means a bad post fails the PR almost instantly, with a readable message, instead of after a long build with a cryptic stack trace.prebuild script. It runs locally on every build too — so CI and your machine enforce the identical rule, and "works on my machine" can't drift from "passes in CI".Here's the guard it runs. Plain Node, no framework — it reads the markdown and refuses anything that would ship broken SEO:
const REQUIRED = ["title", "description", "date"];
for (const file of files) {
const { data } = matter(read(file));
const missing = REQUIRED.filter((field) => !data[field]);
if (missing.length) error(`${slug}: missing ${missing.join(", ")}`);
if (seen.has(slug)) error(`${slug}: duplicate slug`);
if (data.description?.length > 160) {
warn(`${slug}: description may be truncated in search results`);
}
}
process.exit(errors ? 1 : 0); // non-zero fails the workflow
The exit(1) is the whole point. A warning is advice; a non-zero exit is a gate. Description too long? Warning — your call. Missing a canonical-critical field, or two posts colliding on the same slug? The build does not happen.
None of this is a trick you learn once. The App Router already rewrote how SEO works in React, and it will move again. What survives a version bump isn't the config — it's the shape: one source of truth, every artifact derived, a CI gate that fails loud. When Next changes the API, you change one derivation and the pipeline tells you immediately if you got it wrong.
A green build was never the same as green SEO. The crawler doesn't run your tests — so make the build refuse to exist when the SEO is broken.
Everything above is a real, runnable project — clone it, build it, read the prerendered HTML and watch the metadata, sitemap, robots and OG images come out of one pipeline.
git clone https://github.com/PullStackDeveloper/seo-is-a-pipeline-not-a-meta-tag.git
cd seo-is-a-pipeline-not-a-meta-tag
npm install
npm run dev
Want to see the code from this tutorial in action? PULL the complete working example from my GitHub repository!
![]()
© 2024 PullStackDeveloper. All rights reserved.