Next.js Metadata API: Complete SEO Setup Without a Library
How to use the native Metadata API for titles, canonicals, OpenGraph, Twitter cards and JSON-LD — and why next-seo is unnecessary in the App Router.
Table of contents
- Start with metadataBase
- Per-route metadata
- Build it once, not per page
- Dynamic OpenGraph images
- JSON-LD is not part of the Metadata API
- sitemap.ts and robots.ts
- Frequently asked questions
- Do I need next-seo?
- Why is my title not showing the template?
- Should every page have a canonical?
- How do I stop a page being indexed?
- Related reading
- References
The App Router has a built-in metadata system that covers everything next-seo was written for, renders on the server, and ships no client JavaScript. If you are starting a Next.js project today, you do not need an SEO library.
Start with metadataBase#
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
default: 'Example — Free Developer Tools',
template: '%s | Example',
},
description: 'Default description used when a page does not set one.',
};metadataBase is the piece people skip and then wonder why their canonicals are relative. With it set, every child route can write alternates: { canonical: '/tools' } and Next resolves it to an absolute URL. Without it, relative URLs are emitted verbatim and crawlers ignore them.
The title.template appends your brand once, so child pages set only their own title.
Per-route metadata#
Static, when the route is fixed:
export const metadata: Metadata = {
title: 'All Tools',
description: '45 free developer tools that run in your browser.',
alternates: { canonical: '/tools' },
};Dynamic, when it depends on params:
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
const tool = getTool(slug);
if (!tool) return { title: 'Not found' };
return {
title: tool.seoTitle,
description: tool.seoDescription,
alternates: { canonical: `/tools/${slug}` },
openGraph: {/* ... */},
};
}generateMetadata runs on the server and its fetch calls are deduplicated with the page's, so looking up the same record twice costs one request.
Build it once, not per page#
The verbose part of the native API is that a complete metadata object is ~40 lines. Repeating that across 100 routes is how canonicals and OG tags start to drift. Wrap it:
export function buildMetadata({
title,
description,
path,
image,
type = 'website',
}): Metadata {
const canonical = absoluteUrl(path);
return {
title,
description,
alternates: { canonical },
openGraph: {
type,
url: canonical,
siteName: 'Example',
title,
description,
images: [
{
url: image ?? absoluteUrl(`/api/og?title=${encodeURIComponent(title)}`),
width: 1200,
height: 630,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [image ?? '...'],
},
robots: {
index: true,
follow: true,
googleBot: { 'max-image-preview': 'large', 'max-snippet': -1 },
},
};
}Now every page is three lines and cannot forget a field.
Dynamic OpenGraph images#
next/og renders an image from JSX at the edge:
// app/api/og/route.tsx
export const runtime = 'edge';
export async function GET(request: NextRequest) {
const title = request.nextUrl.searchParams.get('title') ?? 'Example';
return new ImageResponse(<div style={{/* ... */}}>{title}</div>, {
width: 1200,
height: 630,
});
}Two practical constraints: avoid fetching remote fonts or images, because the renderer must await them and a CDN hiccup breaks every share preview; and cap the title length, because a very long string pushes render time past the crawler's timeout.
JSON-LD is not part of the Metadata API#
Structured data is rendered as a script tag, and the recommended shape is a single @graph per page so nodes can reference each other by @id:
export function JsonLd({ graph }: { graph: string }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: graph.replace(/</g, '\\u003c') }}
/>
);
}dangerouslySetInnerHTML is required — React would escape the quotes in a text child and produce unparseable JSON. The < replacement prevents the string from closing the script element early.
Declare Organization and WebSite once in a shared layout, then have page-level Article, FAQPage and BreadcrumbList nodes reference them by @id. That makes the whole site read as one entity rather than a hundred unrelated documents.
sitemap.ts and robots.ts#
Both are file conventions returning typed objects:
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: 'https://example.com', lastModified: new Date(), priority: 1 },
...tools.map((t) => ({
url: `https://example.com/tools/${t.slug}`,
priority: 0.8,
})),
];
}Frequently asked questions#
Do I need next-seo?#
No. It was built for the Pages Router, which had no metadata API. In the App Router it duplicates built-in functionality and adds client JavaScript.
Why is my title not showing the template?#
title.template only applies to child routes. A route that sets title: { absolute: 'X' } opts out deliberately.
Should every page have a canonical?#
Yes. It is the cheapest protection against duplicate content from query strings, trailing slashes and alternate paths.
How do I stop a page being indexed?#
robots: { index: false, follow: false } in that route's metadata. Use it for search-result pages and paginated archives beyond page one.