CreativeCape
Web & Next.js Featured

SEO Metadata in the Next.js App Router (Done Properly)

generateMetadata, dynamic titles, canonicals, OG images and per-entity SEO with admin overrides and dynamic fallbacks — patterns from a production Next.js site.

September 9, 2026·5 min read

The App Router's Metadata API is a genuine improvement over stuffing tags into <Head>. It is also easy to get subtly wrong in ways that do not surface until you check what actually rendered.

Static metadata vs generateMetadata

For a page whose metadata never changes, export a static object:


export const metadata: Metadata = {

  title: "Pricing — Simple Plans | Acme",

  description: "Straightforward pricing with no per-seat surprises.",

};

When metadata depends on data, export generateMetadata:


export async function generateMetadata(props: PageProps): Promise<Metadata> {

  const { slug } = await props.params;

  const product = await getProduct(slug);

  if (!product) return { title: "Not found | Acme" };

  return { title: `${product.title} | Acme`, description: product.excerpt };

}

Note params is a Promise and must be awaited. Requests made in generateMetadata are deduplicated against the same request in the page body, so fetching the product twice does not mean two database queries — provided you use the same function and it is cached.

Per-entity SEO with database overrides

For anything content-managed, editors need to override the title and description — and you need a sensible value when they have not.

The pattern we use is a single helper:


export function buildDynamicMetadata(input: DynamicSeoInput): Metadata {

  const title = clean(input.metaTitle) ?? `${input.name} | ${input.suffix}`;

  const description =

    toMetaDescription(input.metaDescription) ??

    toMetaDescription(input.descriptionFallback) ??

    `${input.name} — ${input.suffix}.`;

  // …

}

The fallback pattern: admin value wins, else generate

The detail that matters: treat empty strings as "not set".


// Wrong — an empty string is not null, so this returns ""

const title = row.metaTitle ?? generated;

// Right

const title = (row.metaTitle ?? "").trim() || generated;

?? only falls through on null and undefined. A CMS field that was filled in and then cleared stores "", and with ?? you ship an empty <title>. This single character is responsible for a surprising number of blank titles in production.

The same applies to derived descriptions: strip HTML and markdown, collapse whitespace, and truncate on a word boundary rather than mid-word.


export function toMetaDescription(v?: string | null, max = 160) {

  const raw = (v ?? "").trim();

  if (!raw) return undefined;

  const text = raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();

  return text.length <= max

    ? text

    : text.slice(0, max - 1).replace(/\s+\S*$/, "").trim() + "…";

}

Canonicals and locales

Set a canonical on every page:


alternates: { canonical: `/products/${slug}` }

Relative canonicals resolve against metadataBase, which you set once in the root layout:


export const metadata: Metadata = {

  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),

};

Without metadataBase, relative OG image URLs will not resolve and you get a warning in build output — one worth not ignoring, because social previews break silently.

For multi-language sites, add alternates.languages so each locale points at its siblings. If you use localePrefix: "as-needed", remember the default locale has no prefix — the canonical for English is /products/x, not /en/products/x.

The title template trap

The root layout can define a template:


title: { default: "Acme", template: "%s | Acme" }

Every child title then gets | Acme appended. Which is useful — until a page sets a title that already ends in the brand, and you ship:


Pricing — Simple Plans | Acme | Acme

We had exactly this across an entire site. The fix is absolute, which bypasses the template:


return { title: { absolute: seo.title } };

Decide once: either titles carry the brand and you use absolute, or they never do and the template adds it. Mixing the two is what produces duplicates.

OpenGraph and Twitter images


openGraph: {

  title, description,

  type: "article",

  images: cover ? [{ url: cover }] : undefined,

},

twitter: { card: "summary_large_image", title, description, images: cover ? [cover] : undefined },

Two notes. OpenGraph has its own title field which is not subject to the title template — so set it explicitly. And when adding article timestamps to a spread OG object, re-assert type: "article", or TypeScript cannot narrow the union and will reject publishedTime.

Sitemap and robots

Both are code, not files:


// app/sitemap.ts

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {

  const posts = await getPublishedPosts();

  return [

    { url: abs("/"), priority: 1, changeFrequency: "weekly" },

    ...posts.map((p) => ({ url: abs(`/blog/${p.slug}`), lastModified: p.updatedAt })),

  ];

}

Generate dynamic entries from the database rather than maintaining a list by hand — a hand-maintained sitemap is out of date within a month.

In robots.ts, disallow private areas: admin, API routes, cart and checkout, auth pages and account pages.

Common mistakes

  • Forgetting metadataBase — relative OG images silently fail

  • ?? instead of || on CMS fields — empty titles

  • Double brand suffixes from the title template

  • Missing canonicals on filtered or paginated listings, creating duplicate content

  • Titles over ~60 characters — truncated in results

  • Indexing thin pages — category pages with no content dilute the site

  • Never viewing source — the only way to know what actually rendered

That last one is the real lesson. Metadata code that compiles is not metadata that is correct. Load the page, view source, read the tags.


→ Web Application Development

Tagged with
#next.js#seo#app router#generatemetadata#open graph#canonical

Found this useful? Share it.

Keep Reading

Related articles

Booking Q2 2026 Projects

Ready to Build Something Great?

From idea to launch — let our senior engineers build, ship and scale your next product. No commitment, just a conversation.

Senior Engineers
On-Time Delivery
Enterprise-Grade
Free Consultation

Free 30-min discovery call

Talk to a senior engineer — not a salesperson.

We'll review your goals, suggest the leanest path forward, and send a clear proposal within 24 hours.

24h

Response Time

100+

Projects Delivered

No commitment · No automated bots · Fully transparent