SEO with the Next.js Metadata API
- nextjs
- seo
- react
- webdev
- typescript
- javascript
- softwareengineering
- frontend
In Server Actions for Mutations, we covered how to move writes into the App Router without hand-rolling API routes for every form. This piece stays on the App Router but shifts focus from mutations to something that quietly decides whether anyone finds your app at all: metadata. It's part of the React & Next.js Complete Guide series, and it's the article I wish someone had handed me the first time a client asked why their SaaS marketing pages weren't showing rich previews on Slack.
SEO gets treated as an afterthought on a lot of app-router migrations, mostly because the old next/head approach doesn't exist anymore and nobody explains what replaced it clearly. The Metadata API is the replacement, and it's genuinely better once you understand the two shapes it comes in: static objects and async functions. Getting this wrong doesn't crash your build. It just quietly ships pages with missing titles, duplicate descriptions, or metadata that never updates when the underlying data does, and you find out three months later when organic traffic looks flat.
This article covers how the Metadata API actually resolves metadata across a route tree, and how to generate dynamic metadata from a database record without duplicating a fetch. It also covers the production pitfalls that cost the most in practice: streaming and OG images, metadata merging surprises, and sitemap/robots generation that scales with your data instead of a hardcoded list.
Static versus dynamic metadata, and when each one is correct
Every route segment in the App Router can export either a metadata object or a generateMetadata function. The static object is for pages where the title and description don't depend on data: your pricing page, your login screen, your terms of service.
// app/pricing/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing | Acme",
description:
"Simple, transparent pricing for teams of any size. Start free, upgrade when you need more seats.",
alternates: {
canonical: "https://acme.com/pricing",
},
};
export default function PricingPage() {
return <div>{/* page content */}</div>;
}
This is the case you should default to. It costs nothing at build time, and it's trivially cacheable. There's no risk of it drifting out of sync with anything, because there's nothing for it to drift from.
generateMetadata exists for the other case: the title, description, or Open Graph image depends on a record you fetch. A product page, a blog post, a public user profile.
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/posts";
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return { title: "Post not found" };
}
return {
title: `${post.title} | Acme Blog`,
description: post.excerpt,
alternates: {
canonical: `https://acme.com/blog/${post.slug}`,
},
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImageUrl }],
type: "article",
publishedTime: post.publishedAt,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
// render post
}
The obvious question is why getPostBySlug gets called twice, once in generateMetadata and once in the page component. Next.js deduplicates fetch requests that go through its extended fetch within the same render pass. If you're using a data layer that doesn't go through fetch directly (a Prisma call, for example), wrap it with React's cache function so both calls resolve to a single underlying query instead of hitting your database twice per request.
// lib/posts.ts
import { cache } from "react";
import { db } from "@/lib/db";
export const getPostBySlug = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } });
});
This one change matters more than it looks like it should. Without it, every dynamic page pays for its data fetch twice on every request, and that cost compounds under real traffic in a way that's easy to miss in local development because you're only ever loading one page at a time.
How metadata merges across the route tree
Metadata isn't scoped to a single page file. It resolves across the whole segment hierarchy from the root layout down to the leaf page, and Next.js merges the objects rather than replacing them wholesale, with one important exception: title and description are overwritten by the most specific segment that defines them, while nested objects like openGraph are shallow-merged.
This is where teams get bitten. A root layout sets a broad Open Graph image for the whole site, a nested page sets its own openGraph.title, and the image from the layout silently carries through because nobody expected openGraph to merge instead of replace.
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: "%s | Acme",
default: "Acme",
},
openGraph: {
siteName: "Acme",
images: [{ url: "https://acme.com/default-og.png" }],
},
};
The title.template pattern is worth using deliberately. It lets every child page just export title: "Pricing" and get "Pricing | Acme" for free, without every page needing to remember the suffix. Skip it and you'll end up with brand name inconsistency across pages, some with the suffix, some without, which is a small thing individually but adds up to a site that looks unmaintained in search results.
The pitfall worth calling out explicitly: if a nested segment needs a different Open Graph image, it has to set the full openGraph object again, including siteName. The merge is shallow at the top level of openGraph, not recursive into every field. Assuming Next.js will intelligently deep-merge nested objects is a mistake I've seen cost people an afternoon of "why is my custom OG image not showing up" debugging.
Dynamic OG images without a design tool
Static OG images work fine for marketing pages, but anything with per-record content, a blog post, a user's public profile, a shared document, benefits from an image generated on the fly. Next.js ships ImageResponse for exactly this, built on Vercel's @vercel/og library and using Satori under the hood to convert JSX and CSS into an image.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPostBySlug } from "@/lib/posts";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
type ImageProps = {
params: Promise<{ slug: string }>;
};
export default async function Image({ params }: ImageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: "80px",
width: "100%",
height: "100%",
background: "#0f172a",
color: "#f8fafc",
}}
>
<div style={{ fontSize: 56, fontWeight: 700 }}>{post?.title}</div>
<div style={{ fontSize: 28, marginTop: 24, color: "#94a3b8" }}>
acme.com/blog
</div>
</div>
),
{ ...size }
);
}
Drop this file next to page.tsx in the same route segment and Next.js wires up the <meta property="og:image"> tag automatically; you don't need to reference it in generateMetadata at all. The tradeoff is render cost: this runs per request unless you put a cache layer in front of it, and Satori's CSS support is a meaningful subset of real CSS, not all of it. Flexbox works well; grid layouts and some advanced selectors don't. Keep the design simple and you'll avoid fighting the renderer.
For a SaaS product with thousands of dynamic pages (user profiles, shared documents), generating these on every crawl is wasteful. A CDN cache header on the image response, or falling back to a single static image for anything below a certain traffic threshold, is usually the pragmatic call rather than generating a bespoke image for every single record on day one.
Sitemaps and robots.txt as code
The other half of technical SEO that the Metadata API covers is sitemap.ts and robots.ts, both of which replace static files with functions you can drive from your database.
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPublishedPosts } from "@/lib/posts";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPublishedPosts();
const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({
url: `https://acme.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: "weekly",
priority: 0.7,
}));
return [
{
url: "https://acme.com",
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
},
{
url: "https://acme.com/pricing",
changeFrequency: "monthly",
priority: 0.8,
},
...postEntries,
];
}
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/app/", "/api/"],
},
sitemap: "https://acme.com/sitemap.xml",
};
}
The /app/ disallow entry matters for a SaaS product specifically: your public marketing routes and your authenticated application routes usually live under different path prefixes, and crawlers have no business indexing dashboard pages that require a session anyway. Blocking them in robots.txt doesn't add security, since it's an advisory convention, not enforcement, but it does keep crawl budget focused on pages you actually want ranked.
Next.js caps sitemap.ts at 50,000 URLs per file by default. If your content genuinely exceeds that, generate a sitemap index with multiple numbered sitemap files (sitemap/[id].xml) rather than trying to force everything into one function. Most SaaS products won't hit this limit for a long time, so don't build the sharding logic before you have the data volume that needs it.
Structured data and where JSON-LD actually belongs
The Metadata API covers title, description, Open Graph, and Twitter Card tags well, but it deliberately doesn't have a first-class API for JSON-LD structured data (Article, Product, FAQPage schemas). You add that yourself with a <script type="application/ld+json"> tag rendered directly in the page component.
// app/blog/[slug]/page.tsx (excerpt)
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { "@type": "Person", name: post.authorName },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* post content */}</article>
</>
);
}
dangerouslySetInnerHTML looks alarming next to a name like that, but it's the correct tool here because JSON.stringify output is not attacker-controlled HTML; it's a JSON string. The real risk is if any field in jsonLd contains unescaped user input that could break out of the script tag context, so make sure fields like post.title are plain strings from your database, not raw HTML fragments a user could have submitted through a form.
Key takeaways
- Use a static `metadata` export whenever the content doesn't depend on data; reserve `generateMetadata` for pages backed by a database record, and wrap the underlying fetch with React's `cache` so it doesn't run twice per request.
- Metadata merges across the route tree from layout to page, but the merge is shallow. Setting a nested `openGraph` object on a child page means restating fields like `siteName`, not just the ones you want to change.
- `title.template` on the root layout keeps brand naming consistent across every page without every route needing to remember the suffix manually.
- `opengraph-image.tsx` with `ImageResponse` generates per-record social previews without a design tool, but cache or gate it for high-traffic dynamic routes since it runs per request by default.
- `sitemap.ts` and `robots.ts` should be driven from your actual data, not hardcoded, and `robots.ts` should disallow authenticated app routes so crawl budget goes to public content.
- JSON-LD structured data isn't part of the Metadata API; render it as a script tag in the page component, and make sure any user-supplied fields going into it are plain strings, not raw HTML.
Frequently asked questions
Does the Next.js Metadata API replace next/head entirely?
Yes, in the App Router. `next/head` was a Pages Router pattern for imperatively injecting tags into `<head>`. The Metadata API is declarative: you export a `metadata` object or `generateMetadata` function per segment, and Next.js resolves and merges them for you. If you're still on the Pages Router, `next/head` still works there.
Why isn't my Open Graph image showing up when I share a link?
Most often it's caching on the platform doing the unfurling, not your code. Slack, Twitter, and LinkedIn cache OG data aggressively per URL. Use each platform's card validator or debugger tool to force a re-fetch before assuming your `generateMetadata` output is wrong.
Should generateMetadata and the page component both fetch the same data?
Structurally yes, they're separate functions, but the underlying request shouldn't run twice. Wrap your data-fetching function with React's `cache()` (or rely on Next.js's extended `fetch` deduplication if you're calling `fetch` directly) so both calls resolve from a single request per render.
How many pages can I put in one sitemap.ts file?
Next.js caps a single generated sitemap at 50,000 entries. Beyond that, split into a sitemap index with multiple `sitemap/[id].xml` routes. Most products don't need this until their content volume is genuinely large, so it's fine to defer.
Does blocking a route in robots.txt keep it out of Google entirely?
Not necessarily. `robots.txt` tells well-behaved crawlers not to crawl a path, but a URL can still get indexed (without its content) if enough external links point to it. If you need a page truly excluded from search results, use a `noindex` meta tag or HTTP header on that page instead of, or in addition to, robots rules.
Can I use generateMetadata with data from an external API instead of my own database?
Yes, the function just needs to return a `Metadata` object; where the data comes from is irrelevant to Next.js. The same caching advice applies: if the external call is expensive or rate-limited, cache the response at the data-fetching layer so `generateMetadata` and your page component aren't both hitting the external API on every request.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.