next-seo Is Obsolete: 28 Native Metadata Declarations, Zero Package
next-seo is a Pages Router package the native Metadata API replaced in Next 13. Zero dependency here, 28 metadata sources across 27 pages — the full migration mapping.
next-seo is an NPM package for injecting SEO metadata into a Next.js app — invaluable in the Pages Router era, and functionally obsolete since Next 13 shipped the native Metadata API. This storefront runs Next 16 with zero next-seo dependency (grep -c next-seo package.json → 0) and 28 native metadata declarations across 27 page files — 26 static export const metadata objects and 2 async generateMetadata functions for the dynamic routes. Here is what the native API actually replaces, and why the migration is a delete-and-move, not a rewrite.
What next-seo used to do
In Next 12 and earlier, the Pages Router had no first-class metadata API — you rendered <Head> yourself, one page at a time, and repeated the boilerplate. next-seo wrapped that with a <NextSeo> component and a <DefaultSeo> at the root, plus typed helpers for OpenGraph and Twitter cards.
That job is now the framework's, in TypeScript, with static analysis.
The mapping, one API to another
next-seo (Pages Router) | Native Metadata API (App Router) |
|---|---|
<DefaultSeo> in _app.tsx | export const metadata in app/layout.tsx |
<NextSeo> per page | export const metadata in app/<route>/page.tsx |
<NextSeo openGraph={...}> | metadata.openGraph = {...} |
<NextSeo twitter={...}> | metadata.twitter = {...} |
<NextSeo canonical="..."> | metadata.alternates = { canonical: "..." } |
<NextSeo noindex nofollow> | metadata.robots = { index: false, follow: false } |
<ArticleJsonLd> and friends | <script type="application/ld+json"> inside the component |
<NextSeo additionalMetaTags> | metadata.other |
Every column-2 entry is a plain object literal typed against Metadata from "next". There's no component to render, nothing shipped to the client, and the whole config is statically extractable — Next reads it during build and generates the <head> on the server.
The root layout, in this codebase
The default-plus-title-template pattern that used to live in <DefaultSeo>:
// src/app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://asoc-landing-page.vercel.app"),
title: {
default: "ASoc — Premium Tailwind CSS Templates",
template: "%s · ASoc",
},
description:
"Production-grade Next.js and Tailwind templates for admin dashboards, landing pages, and ecommerce.",
openGraph: {
type: "website",
siteName: "ASoc",
images: [{ url: "/og.png", width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
},
alternates: { canonical: "/" },
};
Every inner page inherits from that and overrides what it needs to. The title template resolves %s against the inner page's own title, so /pricing's title: "Pricing" becomes Pricing · ASoc in the tab and search snippet. That is the whole "site defaults + per-page overrides" pattern next-seo used to sell.
A static page's metadata, verbatim
Most routes here are static enough that a plain object is the whole story:
// src/app/pricing/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pricing",
description:
"One-time payment. Four tiers: Free, Single, All-Access, Full Stack.",
alternates: { canonical: "/pricing" },
openGraph: {
title: "Pricing · ASoc",
description: "One-time payment. Four tiers.",
url: "/pricing",
},
};
Twenty-six of the 27 page files in this app carry a block that shape. The two that don't are /templates/[slug] and /blog/[slug] — dynamic routes whose metadata varies per slug, and that's where generateMetadata earns its keep.
The dynamic case: generateMetadata
// src/app/templates/[slug]/page.tsx
import type { Metadata } from "next";
import { getProduct } from "@/data/catalog";
import { notFound } from "next/navigation";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const product = getProduct(slug);
if (!product) return {};
return {
title: product.seoLabel,
description: product.tagline,
alternates: { canonical: `/templates/${slug}` },
openGraph: {
title: `${product.name} · ASoc`,
description: product.tagline,
url: `/templates/${slug}`,
images: [{ url: product.screenshots[0].jpg, width: 1600, height: 900 }],
},
};
}
That's the whole shape. Same Metadata return type as the static case, resolved per slug from the catalog. There's no equivalent <NextSeo> component in the tree — the object is picked up by the framework and rendered into <head> on the server before any React child runs.
JSON-LD is not part of the API
The one thing the native Metadata API deliberately doesn't cover is structured data. next-seo's <ProductJsonLd> and <ArticleJsonLd> don't have a native equivalent because the API's job is to name a small set of well-defined tags — everything else is a plain <script> you drop in the component:
// Inside a product page component
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
image: product.screenshots.map((s) => s.jpg),
description: product.tagline,
offers: {
"@type": "Offer",
priceCurrency: "USD",
price: product.price,
},
}),
}}
/>
The reason to write it this way rather than reach for the old helpers is grep-ability — the actual schema type sits in the file next to the data it describes, and the schema shape you shipped is diff-reviewable. next-seo's wrappers were nice ergonomics; they were never a correctness argument. See nextjs-product-schema-json-ld for the full schema audit on this catalog.
What you actually gain
Three things next-seo never gave you:
- Zero client bundle.
<NextSeo>was tiny but non-zero.export const metadataships literally nothing to the browser — the<head>is built server-side and streamed with the page. - Type safety at the tag level.
Metadatais a discriminated union:robots: { index: false, followw: true }fails TypeScript, not silently at runtime.next-seo's TS types covered the wrapper's props; they couldn't check that you didn't invent an OpenGraph field. - File-collocation for static assets.
app/opengraph-image.tsxandapp/twitter-image.tsxare conventions the framework picks up automatically. Same forrobots.tsandsitemap.ts. That's a per-route asset systemnext-seohad no equivalent for. Seenextjs-open-graph-image-generationfor how this codebase uses it.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Metadata renders correctly in dev but is missing in prod | Two metadata exports in the same file; the second wins silently | Keep one export const metadata OR one generateMetadata per file, never both |
openGraph.images URL is served as a relative path in the crawled HTML | metadataBase isn't set in the root layout, so relative URLs stay relative | Set metadataBase: new URL("https://...") on the root metadata export |
The title template (%s · ASoc) shows literally in some pages | That page's title was set to a full string rather than the token — the template only fires when title is a bare string | Use a plain string (title: "Pricing"), not title: { default: "..." }, in inner pages |
| Product page metadata is empty for some slugs | generateMetadata returned {} for the not-found case, but Next still tried to render the page | Call notFound() in generateMetadata when the slug is unknown; the framework short-circuits to your not-found.tsx |
Adding next-seo "just in case" produces duplicate tags | Both APIs run — you'll get two titles, two descriptions | Don't install next-seo on the App Router; use the native API |
Structured-data helpers you liked from next-seo are missing | Native API deliberately doesn't cover JSON-LD | Emit the <script type="application/ld+json"> yourself in the component |
FAQ
Should I still install next-seo on a new App Router project?
No. Every capability it provided is covered by the native Metadata API, plus a plain <script> for JSON-LD. Installing it now adds a dependency for zero gain and risks the duplicate-tags problem above.
What about older Pages Router projects?
next-seo still works and is still the right answer on the Pages Router. There's no rush to migrate SEO alone; migrate when you migrate the router.
Does the Metadata API support Open Graph video and audio?
Yes — openGraph: { videos: [{ url: "...", width: 1280, height: 720 }] } and openGraph: { audio: [...] } are typed and render the corresponding tags. Twitter Player Cards go under twitter: { players: [...] }.
How do I test what actually gets rendered?
curl -s <url> | grep -o '<meta[^>]*>' prints every meta tag in the delivered HTML — the ground truth before Google or Facebook parses it. Rich Results Test verifies structured data specifically.
Templates in this post
ASoc Fiscal is a financial-platform landing page, ASoc Flow a workflow-automation template, and ASoc Folio a developer-portfolio website — three of the 66 landing templates whose per-page metadata is generated by the exact pattern audited above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the broader tag-by-tag audit, see meta-tags-seo; for the JSON-LD half the Metadata API deliberately leaves out, see nextjs-product-schema-json-ld.
