React
|stacknotice.com
11 min left|
0%
|2,200 words
React

Next.js 15 SEO: metadata, OG Images, Sitemap, and Structured Data (2026)

Complete SEO guide for Next.js 15 App Router. generateMetadata, dynamic Open Graph images, sitemap.xml, robots.txt, JSON-LD structured data, and canonical URLs.

C
Carlos Oliva
Software Developer
June 21, 202611 min read
Share:
Next.js 15 SEO: metadata, OG Images, Sitemap, and Structured Data (2026)

Next.js 15 App Router ships with a metadata API that handles everything from Open Graph tags to sitemaps to structured data — all in TypeScript, all co-located with your routes. This guide covers every layer of it with real production patterns.

The Two Ways to Define Metadata

Static metadata export — for pages where the content doesn't depend on route params or fetched data:

// app/about/page.tsx
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn about our team and mission.',
  openGraph: {
    title: 'About Us',
    description: 'Learn about our team and mission.',
    type: 'website',
  },
}
 
export default function AboutPage() {
  return <main>{/* content */}</main>
}

generateMetadata function — for pages that need data from params or the database:

// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
import { getPost } from '@/lib/posts'
 
interface Props {
  params: Promise<{ slug: string }>
}
 
export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
 
  if (!post) {
    return { title: 'Post not found' }
  }
 
  // Inherit images from parent metadata (layout)
  const parentImages = (await parent).openGraph?.images ?? []
 
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt.toISOString(),
      authors: [post.author.name],
      images: [
        { url: post.coverImage, width: 1200, height: 630, alt: post.title },
        ...parentImages,
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

Title Templates

Instead of manually appending the site name to every page title, configure a template in the root layout:

// app/layout.tsx
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: {
    template: '%s — Acme',
    default: 'Acme — Build faster',  // used when no title is set
  },
  description: 'Acme helps teams ship faster.',
}

Child pages only need their own title — the template handles the rest:

// app/pricing/page.tsx
export const metadata: Metadata = {
  title: 'Pricing',  // renders as: "Pricing — Acme"
}

The default applies to any page that doesn't define its own title. The template applies to all pages that do.

Dynamic Open Graph Images

Option 1: Static file — drop an opengraph-image.png (1200×630) in any route folder. Next.js picks it up automatically with no code.

Option 2: Generated at request time using ImageResponse:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'
 
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
 
export default async function OgImage({
  params,
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)
 
  return new ImageResponse(
    (
      <div
        style={{
          background: 'linear-gradient(135deg, #080B14 0%, #0D1117 100%)',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'flex-end',
          padding: '64px',
          fontFamily: 'sans-serif',
        }}
      >
        <div style={{ fontSize: '18px', color: '#38BDF8', letterSpacing: '3px', marginBottom: '20px' }}>
          YOUR SITE
        </div>
        <div
          style={{
            fontSize: post && post.title.length > 60 ? '44px' : '56px',
            fontWeight: 900,
            color: 'white',
            lineHeight: 1.15,
            maxWidth: '1000px',
          }}
        >
          {post?.title ?? 'Blog Post'}
        </div>
      </div>
    ),
    { ...size }
  )
}

The generated image URL (/blog/my-post/opengraph-image) is automatically referenced in the page's metadata. Next.js caches the output.

Custom fonts in OG images

Load custom fonts with fetch() inside the function:

const fontData = await fetch(new URL('./fonts/Inter-Bold.ttf', import.meta.url)).then(r => r.arrayBuffer())
return new ImageResponse((...), { ...size, fonts: [{ name: 'Inter', data: fontData, weight: 700 }] })

sitemap.xml

// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'
 
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()
  const baseUrl = 'https://example.com'
 
  const postEntries = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }))
 
  return [
    { url: baseUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 },
    { url: `${baseUrl}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
    ...postEntries,
  ]
}

Available at /sitemap.xml. For large sites with thousands of URLs, use generateSitemaps to split into multiple files.

robots.txt

// app/robots.ts
import type { MetadataRoute } from 'next'
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/api/', '/admin/', '/dashboard/'],
      },
    ],
    sitemap: 'https://example.com/sitemap.xml',
  }
}

Available at /robots.txt.

JSON-LD Structured Data

Structured data gives search engines explicit signals about your content. It can trigger rich results — article authorship, breadcrumbs, FAQs — in search.

Add it as a <script> tag in the page component, not through the metadata API:

// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/posts'
 
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return null
 
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    author: {
      '@type': 'Person',
      name: post.author.name,
      url: `https://example.com/authors/${post.author.slug}`,
    },
    publisher: {
      '@type': 'Organization',
      name: 'Acme',
      logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' },
    },
    datePublished: post.publishedAt.toISOString(),
    dateModified: post.updatedAt.toISOString(),
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://example.com/blog/${post.slug}`,
    },
  }
 
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{/* post content */}</article>
    </>
  )
}

Breadcrumb schema (add alongside the article schema):

const breadcrumbJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: [
    { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://example.com' },
    { '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://example.com/blog' },
    { '@type': 'ListItem', position: 3, name: post.title, item: `https://example.com/blog/${post.slug}` },
  ],
}

Validate with Google's Rich Results Test after publishing.

Canonical URLs

The canonical tag tells search engines which URL is the authoritative version — critical for paginated lists, filtered views, or content cross-posted elsewhere (like dev.to with a canonical back to your site).

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  return {
    alternates: {
      canonical: `https://example.com/blog/${slug}`,
    },
  }
}

For paginated routes, the first page should canonicalize to the non-paginated URL:

// app/blog/page/[page]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { page } = await params
  return {
    alternates: {
      canonical: page === '1'
        ? 'https://example.com/blog'
        : `https://example.com/blog/page/${page}`,
    },
  }
}

hreflang for Multilingual Sites

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { lang, slug } = await params
  return {
    alternates: {
      canonical: `https://example.com/${lang}/blog/${slug}`,
      languages: {
        'en': `https://example.com/en/blog/${slug}`,
        'es': `https://example.com/es/blog/${slug}`,
        'x-default': `https://example.com/en/blog/${slug}`,
      },
    },
  }
}

Metadata in Layouts

Metadata defined in a layout applies to all pages underneath it and can be overridden by child pages:

// app/blog/layout.tsx
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  openGraph: {
    type: 'article',
    siteName: 'Acme Blog',
  },
}
 
export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return <section>{children}</section>
}

All pages under /blog/ inherit openGraph.type: 'article' without having to set it explicitly.

Deduplicating Data Fetches

generateMetadata and the page component run independently — if both call getPost(slug), that's two database queries. Fix it with React's cache():

// lib/posts.ts
import { cache } from 'react'
 
export const getPost = cache(async (slug: string) => {
  return db.post.findUnique({ where: { slug } })
})

Now getPost('my-post') called in generateMetadata and again in page.tsx within the same request is deduplicated — one query, two uses.

This is the server components pattern applied to data fetching: fetch once, use everywhere.

Common Pitfalls

Missing descriptions on key pages — The meta description doesn't directly affect rankings, but it's your ad copy in search results. An empty one means Google picks random text from the page. Write them.

Generic OG images — A root-level opengraph-image.png is better than nothing. A dynamic per-post image with the actual title and design is noticeably better for CTR on social shares.

Duplicate or empty titles — Every page needs a unique, descriptive title. The template helps enforce the suffix, but the unique part still needs to come from your data.

Unvalidated structured data — Invalid JSON-LD is silently ignored by Google. Always test with the Rich Results Test before assuming it works.

Not testing what social platforms see — Use Twitter's Card Validator and Facebook's Sharing Debugger. They show you exactly what will appear on social posts, including cached data from previous versions.

Quick Verification

# Check metadata in rendered HTML
curl -s https://your-site.com/blog/your-post \
  | grep -E 'og:|twitter:|canonical|description'
 
# Validate sitemap structure
curl -s https://your-site.com/sitemap.xml | head -30
 
# Check robots.txt
curl -s https://your-site.com/robots.txt

After publishing, use Google Search Console's URL Inspection tool to see what Googlebot sees and whether structured data was parsed correctly.

The Next.js App Router guide covers the routing architecture that metadata builds on. For the performance side of Core Web Vitals — which also affects ranking — see the Next.js performance optimization guide.

#nextjs#seo#metadata#typescript#react
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.