DEV Community

Cover image for Next.js Metadata API: Complete SEO Setup Guide
Connor Fitzgerald
Connor Fitzgerald

Posted on

Next.js Metadata API: Complete SEO Setup Guide

Modern SSR apps live or die by metadata quality. If search engines and AI crawlers cannot parse titles, canonicals, and schema, rankings stall and snippets degrade.

This guide shows Next.js developers how to implement a production ready Next.js metadata setup. It covers the Next.js Metadata API, Open Graph, Twitter, canonical tags, schema markup, sitemaps, RSS, internal linking, and automation patterns. If you ship React with SSR or ISR, the key takeaway is simple: centralize SEO data, generate it programmatically, and validate before publish.

Why Next.js metadata matters for SSR SEO

Crawlability and snippet control

SSR ensures bots receive fully rendered pages, but without structured metadata you forfeit control of titles, descriptions, and link relationships. Good metadata guides how your content appears in SERPs and AI results.

Consistency across thousands of URLs

Manual updates do not scale. Programmatic metadata allows you to ship uniform, validated tags across product pages, docs, and blogs.

Signals for AI overviews and citations

Clear canonicalization, rich schema, and unambiguous Open Graph increase the odds that AI systems attribute and link back to your pages.

The Next.js Metadata API in practice

App Router primitives

Next.js exposes a built in Metadata API in the App Router. You can export a generateMetadata function in route segments and return structured fields.

// app/blog/[slug]/page.tsx
import { fetchPostBySlug } from "@/lib/data";
import type { Metadata } from "next";

export async function generateMetadata(
  { params }: { params: { slug: string } }
): Promise<Metadata> {
  const post = await fetchPostBySlug(params.slug);
  const url = new URL(`/blog/${post.slug}`, process.env.NEXT_PUBLIC_SITE_URL);

  return {
    title: post.seoTitle ?? post.title,
    description: post.seoDescription ?? post.excerpt,
    alternates: { canonical: url.toString() },
    openGraph: {
      type: "article",
      url: url.toString(),
      title: post.ogTitle ?? post.title,
      description: post.ogDescription ?? post.excerpt,
      images: post.ogImage ? [{ url: post.ogImage, width: 1200, height: 630 }] : undefined,
    },
    twitter: {
      card: "summary_large_image",
      title: post.twTitle ?? post.title,
      description: post.twDescription ?? post.excerpt,
      images: post.ogImage ? [post.ogImage] : undefined,
    },
    robots: {
      index: true,
      follow: true,
      nocache: false,
    },
  };
}

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await fetchPostBySlug(params.slug);
  return <article dangerouslySetInnerHTML={{ __html: post.html }} />;
}
Enter fullscreen mode Exit fullscreen mode

Layout level defaults

Set sitewide defaults in a root layout and override per route. This prevents missing titles on edge pages.

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),
  title: {
    default: "Acme Docs and Blog",
    template: "%s | Acme",
  },
  description: "Technical guides, changelogs, and API docs for Acme.",
  openGraph: {
    siteName: "Acme",
    type: "website",
  },
  twitter: { card: "summary_large_image" },
};
Enter fullscreen mode Exit fullscreen mode

Canonicals, locales, and pagination

Canonical URLs

Always define a canonical to avoid duplicate content across query variations, previews, and marketing UTM variants.

alternates: { canonical: url.toString() }
Enter fullscreen mode Exit fullscreen mode

Hreflang for locales

If you serve multiple locales, supply language alternates.

// inside generateMetadata
alternates: {
  canonical: url.toString(),
  languages: {
    "en-US": url.toString(),
    "fr-FR": url.toString().replace("/en/", "/fr/"),
  },
}
Enter fullscreen mode Exit fullscreen mode

Pagination rel links

For paginated lists, include prev and next alternates to help crawlers navigate.

alternates: {
  canonical: listUrl,
  types: {
    "application/rss+xml": `${listUrl}.xml`,
  },
}
Enter fullscreen mode Exit fullscreen mode

Schema markup for React and Next.js

Choose JSON-LD over microdata

JSON LD keeps your components clean and is recommended by Google.

// app/blog/[slug]/Schema.tsx
export function BlogPostingJsonLd({ post }: { post: any }) {
  const data = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.excerpt,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt ?? post.publishedAt,
    author: [{ "@type": "Person", name: post.authorName }],
    mainEntityOfPage: {
      "@type": "WebPage",
      "@id": `${process.env.NEXT_PUBLIC_SITE_URL}/blog/${post.slug}`,
    },
    image: post.ogImage ? [post.ogImage] : undefined,
  };

  return (
    <script type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
  );
}
Enter fullscreen mode Exit fullscreen mode

Common schema types

  • Article or BlogPosting for posts and changelogs
  • Product for product pages with price and availability
  • FAQPage for dedicated FAQ pages (not inline in generic posts)
  • SoftwareApplication for SaaS landing pages with operatingSystem and offers

Validation workflow

  • Add unit tests that snapshot JSON LD for critical pages
  • Run structured data tests in CI using PageSpeed Insights or the Rich Results Test API
  • Fail builds when required fields are missing

Next.js sitemap generation and robots rules

Route handlers for sitemaps

The Next.js file based sitemap supports dynamic URL emission.

// app/sitemap.ts
import { MetadataRoute } from "next";
import { fetchAllSlugs } from "@/lib/data";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const base = process.env.NEXT_PUBLIC_SITE_URL!;
  const posts = await fetchAllSlugs();

  const postUrls = posts.map((slug) => ({
    url: `${base}/blog/${slug}`,
    lastModified: new Date().toISOString(),
    changeFrequency: "weekly" as const,
    priority: 0.7,
  }));

  return [
    { url: base, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
    ...postUrls,
  ];
}
Enter fullscreen mode Exit fullscreen mode

robots.txt and crawl budget hints

// app/robots.ts
import { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  const base = process.env.NEXT_PUBLIC_SITE_URL!;
  return {
    rules: [{ userAgent: "*", allow: "/" }],
    sitemap: `${base}/sitemap.xml`,
  };
}
Enter fullscreen mode Exit fullscreen mode

Multiple sitemaps at scale

Split large sites into index sitemaps per type. Create route handlers like app/sitemaps/posts.xml/route.ts and app/sitemaps/docs.xml/route.ts, then point app/sitemap.xml to an index that lists them.

Programmatic SEO patterns for blogs and docs

Central metadata registry

Keep a single function that resolves all metadata for any URL. This eliminates divergence between UI, OG, and schema.

// lib/seo.ts
import type { Metadata } from "next";

export type SeoInput = {
  kind: "post" | "doc" | "product";
  slug: string;
};

export async function resolveMetadata(input: SeoInput): Promise<Metadata> {
  // Load data by kind, compute title, description, canonical, and OG
  // Return a complete Metadata object with sane defaults
}
Enter fullscreen mode Exit fullscreen mode

Deterministic slug and title rules

Define a template for each content type. Example: "%s | Acme" for titles and kebab case slugs. Enforce via tests and a CI check that rejects duplicates.

Internal linking automation

Generate related links per post from embeddings or taxonomies. Render them consistently and include them in on page content and schema via sameAs or mentions where appropriate.

// lib/related.ts
export async function relatedLinks(slug: string): Promise<Array<{ href: string; title: string }>> {
  // Compute related links from tags or embeddings
  return [];
}
Enter fullscreen mode Exit fullscreen mode

Open Graph image generation

Use an edge function or @vercel/og to create social images programmatically. Cache by slug to avoid cold starts.

// app/og/[slug]/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";

export async function GET(_: Request, { params }: { params: { slug: string } }) {
  const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());
  return new ImageResponse(
    (
      <div style={{ fontSize: 64 }}>{post.title}</div>
    ),
    { width: 1200, height: 630 }
  );
}
Enter fullscreen mode Exit fullscreen mode

Automating blog publishing in Next.js

Content sources

  • Filesystem markdown or MDX for simple stacks
  • Headless CMS if you need editorial roles
  • Generated content pipelines that validate metadata before merge

Zero touch pipeline

Design a pipeline that moves from idea to publish without manual SEO fixes:

  • Propose slug, title, description, and canonical
  • Lint and test metadata and schema
  • Generate OG image and RSS entry
  • Merge to main, deploy, revalidate ISR

Scheduling without cron

Use a queue table with publishAt timestamps. A serverless job processes due items and triggers a commit or an API publish endpoint.

// pseudo code
await queue.enqueue({
  type: "publish-post",
  payload: { slug },
  runAt: new Date("2026-03-20T10:00:00Z"),
});
Enter fullscreen mode Exit fullscreen mode

Webhooks and ISR revalidation

On publish, send webhooks that:

  • Revalidate the slug route with Next.js revalidateTag or revalidatePath
  • Ping your sitemap endpoint to refresh lastModified
  • Notify analytics and search indexing APIs when relevant

React SEO best practices in SSR apps

Render critical text server side

Ensure titles, headings, and primary content render on the server so crawlers do not wait on client hydration.

Avoid layout shifts in hero images

CLS hurts discoverability indirectly via page experience metrics. Reserve dimensions and lazy load below the fold.

Keep link semantics correct

Use   with href for navigable elements. Do not rely solely on client handlers for navigation.

Minimize duplicate H1s

Your title is not part of the content in this guide, but on your site ensure one primary H1 per page.

Putting it together: a reusable SEO module

File structure

  • app/layout.tsx for defaults
  • app/robots.ts and app/sitemap.ts for discovery
  • app/blog/[slug]/page.tsx with generateMetadata
  • components/Seo or lib/seo.ts for shared logic
  • components/Schema for JSON LD renderers per type

Type safe metadata helpers

Create small builders that return Metadata with required fields set.

// lib/builders.ts
import type { Metadata } from "next";

export function buildArticleMeta(input: {
  title: string;
  description: string;
  url: string;
  image?: string;
}): Metadata {
  return {
    title: input.title,
    description: input.description,
    alternates: { canonical: input.url },
    openGraph: {
      type: "article",
      url: input.url,
      title: input.title,
      description: input.description,
      images: input.image ? [{ url: input.image, width: 1200, height: 630 }] : undefined,
    },
    twitter: {
      card: "summary_large_image",
      title: input.title,
      description: input.description,
      images: input.image ? [input.image] : undefined,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

CI checks to prevent regressions

  • Validate required fields for each route kind
  • Assert canonical is absolute
  • Verify og:image exists and is reachable
  • Snapshot JSON LD and compare shapes

Quick comparison: options to manage Next.js metadata

Here is a concise comparison of common approaches to managing metadata at scale.

Approach Control Editorial UX Setup cost Best for
Hand coded per page High Low Low Small sites, prototypes
Shared helpers and tests Very high Medium Medium Growing blogs and docs
Headless CMS fields High High Medium high Teams with editors and approvals
Programmatic generation Very high Medium Medium Large catalogs, product led SEO

Example: blog route with internal linking

Data fetch and related links

// app/blog/[slug]/page.tsx
import { relatedLinks } from "@/lib/related";

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await fetchPostBySlug(params.slug);
  const links = await relatedLinks(params.slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
      <hr />
      <aside>
        <h2>Related reading</h2>
        <ul>
          {links.map(l => (
            <li key={l.href}><a href={l.href}>{l.title}</a></li>
          ))}
        </ul>
      </aside>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

Metadata wired to the same source

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await fetchPostBySlug(params.slug);
  return buildArticleMeta({
    title: post.seoTitle ?? post.title,
    description: post.seoDescription ?? post.excerpt,
    url: `${process.env.NEXT_PUBLIC_SITE_URL}/blog/${post.slug}`,
    image: post.ogImage,
  });
}
Enter fullscreen mode Exit fullscreen mode

Common pitfalls and how to avoid them

Missing metadataBase

Without metadataBase, relative URLs in openGraph can be invalid. Always set it in the root layout.

Duplicated canonicals across variants

Compute canonicals from a single source of truth. Strip query parameters and normalize trailing slashes.

Inconsistent titles between OG and HTML

Use one builder that populates both the HTML title and Open Graph title.

Oversized descriptions

Keep meta descriptions under roughly 160 characters. Truncate programmatically and avoid cutting words in half.

Non deterministic OG image generation

Cache OG images by content hash so URLs are stable across deploys.

The Bottom Line

  • Use the Next.js metadata API to centralize titles, descriptions, canonicals, and social tags
  • Generate JSON LD per page type and validate in CI to prevent regressions
  • Automate sitemaps, robots, and OG images to keep pace with publishing
  • Drive internal linking programmatically to improve discovery and depth
  • Build a zero touch pipeline so every deploy ships SEO safe by default

A clean, programmatic Next.js metadata architecture turns SEO from a manual chore into part of your build system. Ship once, scale forever.

See more here: https://autoblogwriter.app/

Top comments (0)