Skip to main content
ASoc
Tutorial

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.

The ASoc Team10 min read

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.tsxexport const metadata in app/layout.tsx
<NextSeo> per pageexport 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:

  1. Zero client bundle. <NextSeo> was tiny but non-zero. export const metadata ships literally nothing to the browser — the <head> is built server-side and streamed with the page.
  2. Type safety at the tag level. Metadata is 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.
  3. File-collocation for static assets. app/opengraph-image.tsx and app/twitter-image.tsx are conventions the framework picks up automatically. Same for robots.ts and sitemap.ts. That's a per-route asset system next-seo had no equivalent for. See nextjs-open-graph-image-generation for how this codebase uses it.

Troubleshooting

SymptomCauseFix
Metadata renders correctly in dev but is missing in prodTwo metadata exports in the same file; the second wins silentlyKeep one export const metadata OR one generateMetadata per file, never both
openGraph.images URL is served as a relative path in the crawled HTMLmetadataBase isn't set in the root layout, so relative URLs stay relativeSet metadataBase: new URL("https://...") on the root metadata export
The title template (%s · ASoc) shows literally in some pagesThat page's title was set to a full string rather than the token — the template only fires when title is a bare stringUse a plain string (title: "Pricing"), not title: { default: "..." }, in inner pages
Product page metadata is empty for some slugsgenerateMetadata returned {} for the not-found case, but Next still tried to render the pageCall 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 tagsBoth APIs run — you'll get two titles, two descriptionsDon't install next-seo on the App Router; use the native API
Structured-data helpers you liked from next-seo are missingNative API deliberately doesn't cover JSON-LDEmit 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.

Keep reading

Tutorial11 min read

Next.js 16 Renamed Middleware to Proxy: What Changes and What Breaks

Renaming the file is a third of the migration. The exported function has to change too, keeping both files is a build error, and the proxy runs on Node rather than the Edge.

Read more
Tutorial11 min read

A/B Testing in Next.js 16: The Proxy Recipe Costs the CDN, Not SSG

Rewriting in middleware does not make your pages dynamic — both variants stay prerendered. What it really costs is the shared cache and every request's critical path.

Read more
Tutorial11 min read

An Accessible Mega Menu in Next.js Without a Headless UI Library

It is a navigation landmark, not an application menu — and the ARIA menu pattern most tutorials copy removes your nav from every screen reader's link list. Six behaviours, eighty lines.

Read more