If your Next.js site runs on the App Router, use the built-in Metadata API (metadata and generateMetadata). If it still runs on the Pages Router, keep next-seo and pin it to v6. On the App Router, the Metadata API covers titles, canonicals and Open Graph tags, and JSON-LD goes in a plain <script type="application/ld+json"> tag. The only reason I'd keep next-seo in an App Router project is its typed JSON-LD components, and you can live without those.
That's the short answer. Below I put the two side by side with code and explain the trap in next-seo v7. After that, I go through how to move a live site off next-seo without breaking a single canonical.
What is the difference between next-seo and the Next.js Metadata API?
next-seo is a third-party npm package built for the Pages Router. The Metadata API is built into Next.js for the App Router. Both end up writing the same head tags. What differs is which router they serve and how you configure them.
next-seo is a community package by Gary Meehan. Back when every Next.js site used the Pages Router and next/head, it was the standard way to manage meta tags. You set site-wide defaults once, then override the title, description, canonical and Open Graph tags on each page.
The Metadata API ships with Next.js for the App Router. You export a static metadata object or an async generateMetadata function from a layout.tsx or page.tsx, and Next.js writes the head tags for you. The generateMetadata reference says both exports only work in Server Components. It also notes that a fetch call inside generateMetadata is memoised, so it shares data with the page instead of fetching twice.
next-seo has changed direction too. In v7 it stopped managing standard meta tags in the App Router and now concentrates on JSON-LD. The next-seo README tells you to use Next.js's built-in generateMetadata for standard meta tags.
| Question | next-seo | Next.js Metadata API |
|---|---|---|
| Router | Pages Router (also JSON-LD in App Router) | App Router only |
| Install | npm package | Built in |
| Site-wide defaults | DefaultSeo (v6) or generateDefaultSeo (v7) | metadata in the root layout.tsx |
| Title template | titleTemplate | title.template |
| Canonical | canonical prop, absolute URL | alternates.canonical, relative with metadataBase |
| Open Graph | openGraph prop | openGraph field |
| JSON-LD | Ready-made typed components | Plain <script> tag you write |
| Works in Server Components | v7 JSON-LD components only (v6 needs next/head) | Yes, and only there |
Which should you use on the Pages Router?
On the Next.js Pages Router, use next-seo v6. The alternative is writing <Head> tags by hand, and next-seo is still tidier than that. You set defaults in one config, the props are typed, and you're less likely to end up with duplicate tags.
What you need to watch is the version. next-seo v7 was a complete rewrite that removed the NextSeo and DefaultSeo components. Pages Router sites on v7 call generateNextSeo() and generateDefaultSeo() from next-seo/pages and wrap the output in next/head, as the Pages Router README shows. Unless you need the new JSON-LD components or you're starting fresh, I'd stay on v6.
Don't let Renovate or Dependabot auto-merge next-seo 7
Going from next-seo v6 to v7 changes every import on a Pages Router site. Pin next-seo to ^6 until you have planned the rewrite or the move to the App Router.
Here's a typical next-seo v6 page:
// pages/blog/[slug].tsx (next-seo v6)
import { NextSeo } from 'next-seo';
export default function Post({ post }) {
return (
<>
<NextSeo
title={post.title}
description={post.excerpt}
canonical={`https://example.com/blog/${post.slug}`}
openGraph={{
type: 'article',
url: `https://example.com/blog/${post.slug}`,
title: post.title,
description: post.excerpt,
images: [{ url: post.cover, width: 1200, height: 630, alt: post.title }],
}}
/>
<article>{/* ... */}</article>
</>
);
}
And the same page on next-seo v7:
// pages/blog/[slug].tsx (next-seo v7)
import Head from 'next/head';
import { generateNextSeo } from 'next-seo/pages';
export default function Post({ post }) {
return (
<>
<Head>
{generateNextSeo({
title: post.title,
description: post.excerpt,
canonical: `https://example.com/blog/${post.slug}`,
openGraph: { type: 'article', url: `https://example.com/blog/${post.slug}`, title: post.title },
})}
</Head>
<article>{/* ... */}</article>
</>
);
}
How do titles, canonicals and Open Graph look with generateMetadata?
On the App Router, site-wide defaults go in the metadata export of the root app/layout.tsx. Each page then returns its own title, canonical and Open Graph tags from generateMetadata. Set metadataBase once and you can write relative URLs for canonicals and images, which makes it much harder to ship a staging domain in a canonical tag, as long as metadataBase itself points at production. I like that one a lot.
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: { default: 'Acme', template: '%s | Acme' },
description: 'What Acme does, in one sentence.',
openGraph: { siteName: 'Acme', type: 'website', locale: 'en_GB' },
};
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { getPost } from '@/lib/posts';
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title, // becomes "Post title | Acme"
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
type: 'article',
url: `/blog/${slug}`,
title: post.title,
description: post.excerpt,
publishedTime: post.publishedAt,
images: [{ url: post.cover, width: 1200, height: 630, alt: post.title }],
},
};
}
Most of the mapping from next-seo is one to one. titleTemplate becomes title.template, canonical becomes alternates.canonical, and noindex becomes robots: { index: false }.
Open Graph is replaced, not merged
Next.js merges metadata from parent layouts shallowly, so a page that sets openGraph drops every openGraph field from the layout, including siteName and the default image. Put shared Open Graph values in a constant and spread them into each page.
This is documented under merging in the generateMetadata reference, but it's easy to miss. Pages lose their share image and nobody notices until someone posts a link on LinkedIn and it shows up blank.
There's one more thing next-seo never had to deal with: streaming. From Next.js 15.2, Next.js can by default stream the result of generateMetadata after the first UI is sent. For bots that run JavaScript, like Googlebot, the tags get appended to the body. HTML-limited bots, like Facebook's crawler, still get them in the head. If you want every tag in the initial head for every user agent, set htmlLimitedBots: /.*/ in next.config. Streamed metadata and the htmlLimitedBots option both need Next.js 15.2 or later, so this section doesn't apply if you're on an older version.
How do you add JSON-LD without next-seo?
In the App Router, you add JSON-LD by rendering a <script type="application/ld+json"> tag in the page or layout. The Metadata API has no JSON-LD field. The Next.js JSON-LD guide recommends exactly this approach and warns that JSON.stringify on its own doesn't sanitise strings, so replace < with its unicode escape.
// app/blog/[slug]/page.tsx (continued)
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
image: post.cover,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: post.author.name, url: post.author.url },
};
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }}
/>
{/* ... */}
</article>
);
}
If you'd rather keep next-seo for this part, its v7 JSON-LD components, such as ArticleJsonLd, work in App Router pages. Check the next-seo README for the exact import path and the props each component takes before you wire it up.
The output is the same either way. The next-seo component saves you looking up which properties Google wants. The plain script adds no dependency. One catch if your site sends a strict Content-Security-Policy: inline scripts without a matching nonce get blocked. Whichever option you pick, make sure the nonce reaches the script tag. With a plain script you add it yourself; with next-seo, check the next-seo README for how its components handle it.
If you don't want to guess at the schema object, my Schema Markup Generator turns a plain description into JSON-LD you can paste straight into jsonLd.
Which one should you pick for your project?
Go by router. The Metadata API for new and App Router sites, next-seo v6 for Pages Router sites that work, and both side by side on a site that's halfway through a migration.
- New Next.js site: start on the App Router with the Metadata API. There's nothing to install, and it's what the Next.js docs now assume you're using.
- Pages Router site that works: keep next-seo v6. Rewriting the metadata layer on its own won't move your rankings. Change it when you move routes to the App Router.
- Mixed site: the two routers can run side by side during an incremental migration. Keep next-seo in
pages/and usegenerateMetadatainapp/. Don't try to renderNextSeoinside an App Router page. It was built aroundnext/head, which the App Router doesn't use for meta tags.
How do you migrate off next-seo without losing rankings?
Crawl the live site's head tags first, port them to generateMetadata, then diff a staging crawl against that baseline before you deploy. Swapping the library isn't the risky part. The risk is a page quietly losing its canonical, its description or its noindex along the way, so I'd treat this like any other release that can hurt SEO.
- 1
Snapshot the current head
Crawl the live site and export title, meta description, canonical, robots and og: tags for every URL. This is your baseline.
- 2
Port the defaults
Move next-seo.config into metadata in app/layout.tsx with metadataBase, title.template and shared Open Graph values in a constant.
- 3
Map each page
Replace NextSeo or generateNextSeo with generateMetadata, translating canonical to alternates.canonical and noindex to robots.
- 4
Move JSON-LD
Swap next-seo v6 components for a plain script tag or v7 components, and run each template through Google's Rich Results Test.
- 5
Diff and ship
Crawl staging, diff it against the baseline, fix every mismatch, then deploy and inspect key URLs in Search Console.
People skip the diff. Don't. It's the step that catches the Open Graph merge bug and trailing slash mismatches in canonicals. And if the move to the App Router changes any URLs, add the redirects in the same release. My Redirect Map Builder turns a list of old and new URLs into a redirects() array for next.config.js, and my guide to moving from WordPress to Next.js without losing SEO covers the rest of the redirect work.
For the other launch checks (sitemaps, robots, hreflang, Core Web Vitals), work through my technical SEO checklist for Next.js.
What should you ask a developer before they touch your metadata?
Ask which router the site is on, how they'll prove every page keeps its head tags, and what their plan is for Open Graph defaults, JSON-LD and the next-seo version.
- Which router is the site on, and will this change move any routes between
pages/andapp/? - How will you check that every page keeps its title, canonical and robots tags? The answer you want is a crawl diff.
- Where will shared Open Graph values live, so pages don't lose the default share image?
- How will JSON-LD be rendered and escaped, and will it work with our Content-Security-Policy?
- Is next-seo pinned, and to which major version?
Also, writing good titles and descriptions is a different job from wiring them up. Budget time for both. My free Meta Tag Generator can help you draft them.
Next step
If your Next.js site is stuck between next-seo and the App Router, or you're planning the move and don't want a traffic dip, I can help. This sits under my Next.js and React websites and technical SEO and Core Web Vitals work. Send me the URL and tell me which router you're on through the contact page, and I'll tell you whether it's a quick swap or a proper migration.



