Skip to content

Next.js7 min read

Next.js SEO Guide: Metadata, Sitemap, JSON-LD, OG

A practical Next.js SEO guide for Next.js 16: Metadata API, generateMetadata, canonical URLs, sitemap.ts, robots.ts, JSON-LD and dynamic OG images.

Next.js SEO in the App Router comes down to five built-in tools: the metadata export or generateMetadata for titles, descriptions and canonical URLs, app/sitemap.ts for your sitemap, app/robots.ts for crawl rules, a JSON-LD <script> for structured data, and opengraph-image.tsx for social preview images. All of them render on the server, so search engines get complete HTML without executing your JavaScript.

This guide walks through each one with code that works in Next.js 16.4, plus the mistakes I see most often in code reviews.

Key takeaways

  • Set metadataBase, a title template and default Open Graph data once in the root layout.
  • Use generateMetadata for anything that depends on params or data. params is a Promise since Next.js 15.
  • Add a canonical URL to every indexable page with alternates.canonical.
  • app/sitemap.ts and app/robots.ts generate /sitemap.xml and /robots.txt. Since Next.js 16, the id passed to split sitemaps is a Promise.
  • Render JSON-LD with a plain <script type="application/ld+json"> and escape < to avoid XSS.
  • With Cache Components on, uncached data in generateMetadata needs 'use cache' or an explicit dynamic marker.

Why Next.js is good for SEO (and where it is not automatic)

A plain React SPA ships an empty <div id="root"> and hopes the crawler renders it. I wrote about that problem in why your React SPA is not ranking. The App Router avoids it: Server Components produce HTML on the server, and the Metadata API puts <title>, <meta> and <link> tags in the response.

What Next.js does not do for you: write good titles, pick canonical URLs, decide what goes in the sitemap, or describe your content with structured data. That part is still on you.

Set up the Metadata API in the root layout

Start with defaults that every page inherits:

TSX
// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://www.example.com'),
  title: {
    default: 'Acme: Handmade Furniture',
    template: '%s | Acme',
  },
  description: 'Handmade furniture from Kathmandu, shipped worldwide.',
  alternates: { canonical: '/' },
  openGraph: {
    type: 'website',
    siteName: 'Acme',
    locale: 'en_US',
  },
  twitter: { card: 'summary_large_image' },
  robots: { index: true, follow: true },
}

A few details that matter:

  • metadataBase lets every other URL field be relative. Without it, a relative URL in openGraph.images or alternates.canonical is a build error.
  • title.template applies to child segments only. A page that sets title: 'Chairs' renders Chairs | Acme. Use title: { absolute: '...' } to skip the template.
  • Metadata merges shallowly. If a page sets openGraph: { title: 'Chairs' }, it replaces the whole openGraph object from the layout, including siteName. Spread shared fields from a constant if you need them on every page.

generateMetadata for dynamic pages

For pages driven by data, export generateMetadata. In Next.js 15 and later, params and searchParams are Promises.

TSX
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'

export async function generateMetadata({
  params,
}: PageProps<'/blog/[slug]'>): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${slug}/` },
    openGraph: {
      type: 'article',
      title: post.title,
      description: post.excerpt,
      publishedTime: post.publishedAt,
      authors: [post.author],
    },
  }
}

Both generateMetadata and the page call getPost. If getPost uses fetch, the request is memoized automatically. If it queries a database, wrap it with React's cache so it runs once per request, or make it a 'use cache' function so it is cached across requests:

TypeScript
// lib/posts.ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getPost(slug: string) {
  'use cache'
  cacheLife('days')
  cacheTag(`post:${slug}`)
  return db.post.findUnique({ where: { slug } })
}

generateMetadata with Cache Components

If you have cacheComponents: true, generateMetadata follows the same rules as components. If it does uncached work while the rest of the page is fully prerenderable, Next.js raises an error so you make the choice explicit. Most SEO metadata does not depend on cookies, so the fix is usually 'use cache' on the data function above. Caching also means the metadata is part of the prerendered HTML, which is what you want for crawlers. The caching rules are covered in Next.js caching explained.

Streaming metadata and bots

Since Next.js 15.2, metadata for dynamically rendered pages can stream after the initial UI. Next.js disables this for HTML-limited bots (it detects them by user agent, facebookexternalhit for example), which get metadata in the <head>. Prerendered pages never stream metadata. If you want blocking metadata for everyone, set htmlLimitedBots: /.*/ in next.config.ts, at the cost of slower responses.

Canonical URLs and trailing slashes

Duplicate URLs split ranking signals. Pick one format and stick to it everywhere: canonical tags, sitemap and internal links. If you use trailing slashes, set trailingSlash: true in next.config.ts and write canonicals with the slash. For filtered list pages (/shop?color=red), point the canonical at the unfiltered URL unless the filtered page is something you actually want indexed.

Create sitemap.ts in Next.js

app/sitemap.ts returns an array and Next.js serves it at /sitemap.xml:

TypeScript
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'

const BASE_URL = 'https://www.example.com'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()

  return [
    { url: `${BASE_URL}/`, lastModified: new Date('2026-10-01') },
    { url: `${BASE_URL}/blog/`, lastModified: new Date('2026-10-31') },
    ...posts.map((post) => ({
      url: `${BASE_URL}/blog/${post.slug}/`,
      lastModified: post.updatedAt,
      images: post.coverImage ? [post.coverImage] : undefined,
    })),
  ]
}

Use real lastModified dates. Setting every entry to new Date() tells search engines everything changed on every build, which makes the field useless.

For large sites, generateSitemaps splits the output into multiple files. Since Next.js 16, the id argument is a Promise:

TypeScript
// app/products/sitemap.ts
import type { MetadataRoute } from 'next'

export async function generateSitemaps() {
  return [{ id: 0 }, { id: 1 }]
}

export default async function sitemap(props: {
  id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id)
  const products = await getProductsPage(id, 50_000) // Google's limit is 50,000 URLs per file
  return products.map((p) => ({
    url: `https://www.example.com/products/${p.slug}/`,
    lastModified: p.updatedAt,
  }))
}

Create robots.ts in Next.js

TypeScript
// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: ['/admin/', '/api/'],
    },
    sitemap: 'https://www.example.com/sitemap.xml',
  }
}

Remember that robots.txt controls crawling, not indexing. To keep a page out of search results, use robots: { index: false } in that page's metadata and let crawlers reach it so they can see the tag.

Add JSON-LD structured data in Next.js

The official recommendation is a native <script> tag in your page or layout. JSON.stringify does not escape HTML, so replace < with its unicode escape to block XSS through user-controlled strings:

TSX
// app/blog/[slug]/page.tsx (inside the component that renders the post)
import type { BlogPosting, WithContext } from 'schema-dts'
import type { Post } from '@/lib/posts'

function ArticleJsonLd({ post }: { post: Post }) {
  const jsonLd: WithContext<BlogPosting> = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: { '@type': 'Person', name: post.author },
    mainEntityOfPage: `https://www.example.com/blog/${post.slug}/`,
  }

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
      }}
    />
  )
}

schema-dts is an optional community package that gives you types for schema.org. Do not use next/script here: JSON-LD is data, not code to execute. Validate the output with Google's Rich Results Test.

Useful types for most sites: Organization or Person on the home page, BlogPosting on articles, BreadcrumbList on nested pages, Product on product pages, and FAQPage only when the visible page actually shows those questions.

Dynamic OG images with opengraph-image.tsx

Drop an opengraph-image.tsx next to a page and Next.js generates the image and adds the og:image tags for you:

TSX
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'

export const alt = 'Blog post cover'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          padding: 80,
          fontSize: 64,
          background: '#0f172a',
          color: '#f8fafc',
        }}
      >
        {post?.title ?? 'Blog'}
      </div>
    ),
    size,
  )
}

ImageResponse supports flexbox and a subset of CSS. display: grid does not work. A static opengraph-image.png in a folder works too, and the more specific file wins over one higher in the tree. File-based metadata also overrides openGraph.images from the metadata export.

Common Next.js SEO mistakes

  • No metadataBase. Relative OG image URLs then fail the build or end up wrong.
  • Overriding openGraph on a page and losing siteName and images. Metadata merges shallowly.
  • Every sitemap entry with lastModified: new Date(). Use real dates.
  • Blocking a page in robots.txt and expecting it to drop out of the index. Use noindex instead.
  • Unescaped JSON-LD. Always replace <.
  • Client-side-only content. Text that appears only after a useEffect fetch may not be in the HTML crawlers see. Fetch it in a Server Component.
  • Ignoring performance. Page experience is part of search. See the image and font optimization guide for LCP and CLS fixes.

FAQ

Is Next.js good for SEO?

Yes. The App Router renders HTML on the server, so crawlers get real content without running JavaScript. You still have to set titles, descriptions, canonical URLs, a sitemap and structured data yourself, which the Metadata API makes straightforward.

Should I use the metadata object or generateMetadata?

Use the static metadata object when the values do not depend on data or the URL. Use generateMetadata when you need route params or fetched data, for example a blog post title. You cannot export both from the same segment.

How do I add JSON-LD in Next.js?

Render a native script tag with type application/ld+json inside your page or layout, and escape the < character in JSON.stringify output to prevent XSS. Do not use next/script for JSON-LD.

Does streaming metadata hurt SEO?

Next.js only streams metadata to clients that can run JavaScript, such as Googlebot. HTML-limited bots like facebookexternalhit get metadata in the head. You can change the bot list with the htmlLimitedBots config option.

Where do I put sitemap.ts and robots.ts?

In the root of the app directory, as app/sitemap.ts and app/robots.ts. Next.js serves them at /sitemap.xml and /robots.txt.

Wrapping up

Good Next.js SEO is mostly setup work you do once: root metadata with metadataBase and a title template, generateMetadata with canonicals on every dynamic page, a sitemap with honest dates, a robots file, JSON-LD and OG images. After that, each new page only needs a title, a description and a canonical. Keep the Metadata and OG images docs nearby for the full field list.

  • #nextjs
  • #seo
  • #metadata
  • #json-ld
  • #app-router

Next.js9 min read

Next.js i18n Guide: App Router, Routing and hreflang

Next.js internationalization with the App Router: locale routing in Proxy, server-side dictionaries, hreflang and a localized sitemap, with English and Nepali.

Read article