DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

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

Next.js 15 App Router handles SEO in TypeScript, co-located with your routes. This guide covers every layer — metadata, OG images, sitemaps, structured data — with real production patterns.

Two Ways to Define Metadata

Static export — when metadata doesn't depend on 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.', type: 'website' },
}
Enter fullscreen mode Exit fullscreen mode

generateMetadata — when metadata depends on route params or DB data:

// 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' }

  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],
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

Title Templates

Configure once in the root layout — child pages only set their own title:

// app/layout.tsx
export const metadata: Metadata = {
  title: {
    template: '%s — Acme',     // "Pricing — Acme"
    default: 'Acme — Build faster',
  },
}

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

Dynamic Open Graph Images

// 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, #0D1117)',
          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: '56px', fontWeight: 900, color: 'white', lineHeight: 1.15 }}>
          {post?.title ?? 'Blog Post'}
        </div>
      </div>
    ),
    { ...size }
  )
}
Enter fullscreen mode Exit fullscreen mode

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

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'

  return [
    { url: baseUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 },
    { url: `${baseUrl}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
    ...posts.map((post) => ({
      url: `${baseUrl}/blog/${post.slug}`,
      lastModified: post.updatedAt,
      changeFrequency: 'weekly' as const,
      priority: 0.8,
    })),
  ]
}
Enter fullscreen mode Exit fullscreen mode

Available at /sitemap.xml.

robots.txt

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

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

JSON-LD Structured Data

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

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
  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 },
    publisher: {
      '@type': 'Organization',
      name: 'Acme',
      logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' },
    },
    datePublished: post.publishedAt.toISOString(),
    dateModified: post.updatedAt.toISOString(),
  }

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

Canonical URLs

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  return {
    alternates: {
      canonical: `https://example.com/blog/${slug}`,
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

For paginated routes:

return {
  alternates: {
    canonical: page === '1'
      ? 'https://example.com/blog'
      : `https://example.com/blog/page/${page}`,
  },
}
Enter fullscreen mode Exit fullscreen mode

Deduplicating Data Fetches

generateMetadata and the page component run independently. Use cache() to prevent double DB calls:

// lib/posts.ts
import { cache } from 'react'

export const getPost = cache(async (slug: string) => {
  return db.post.findUnique({ where: { slug } })
})
Enter fullscreen mode Exit fullscreen mode

Same call in generateMetadata and page.tsx = one query, two uses.

Common Pitfalls

  • Missing descriptions — meta description is your ad copy in search results. Don't leave it empty.
  • Generic OG images — per-page dynamic images improve social CTR noticeably.
  • Duplicate titles — every page needs a unique title; the template handles the suffix, you handle the unique part.
  • Unvalidated JSON-LD — invalid structured data is silently ignored. Test with Google's Rich Results Test.
  • Not checking what social platforms see — use Twitter Card Validator and Facebook Sharing Debugger.

Quick Verification

# Check metadata in rendered HTML
curl -s https://your-site.com/blog/your-post | grep -E 'og:|twitter:|canonical'

# Validate sitemap
curl -s https://your-site.com/sitemap.xml | head -30
Enter fullscreen mode Exit fullscreen mode

Full guide at stacknotice.com/blog/nextjs-seo-guide-2026

Top comments (0)