Fernando

Fernando
Hi, my name is Fernando and I'm a senior software engineer. Welcome to my personal website!

SEO in the Next.js App Router Is a Pipeline, Not a Meta Tag

Published
23 hours ago
• 7 min read
A meta tag is the last centimeter of a much longer pipeline, and every interesting SEO failure happens upstream of it — silently, with a green build, while the page works fine for users and is nearly invisible to a crawler. SEO in the Next.js App Router Is a Pipeline, Not a Meta Tag

SEO in the Next.js App Router Is a Pipeline, Not a Meta Tag

Introduction

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.

The shape of it

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)"]

One source of truth

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.

Metadata that can't drift

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 },
  };
}

The two upstream traps

The 'use client' boundary is a SEO decision

generateMetadata 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 once

Without 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),
  // ...
};

The sitemap that can't lie

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.)

The part nobody shows: the YAML

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:

  1. 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.
  2. The same check is the 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.

Why this ages well

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.

Try it yourself

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
Get the Working Code

Want to see the code from this tutorial in action? PULL the complete working example from my GitHub repository!

download code

© 2024 PullStackDeveloper. All rights reserved.