惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
SegmentFault 最新的问题
G
Google Developers Blog
H
Help Net Security
月光博客
月光博客
阮一峰的网络日志
阮一峰的网络日志
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
P
Proofpoint News Feed
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
爱范儿
爱范儿
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 三生石上(FineUI控件)
大猫的无限游戏
大猫的无限游戏
人人都是产品经理
人人都是产品经理
GbyAI
GbyAI
D
Docker
Hugging Face - Blog
Hugging Face - Blog
I
InfoQ
博客园 - 司徒正美
Last Week in AI
Last Week in AI
Microsoft Security Blog
Microsoft Security Blog
美团技术团队
Stack Overflow Blog
Stack Overflow Blog
M
MIT News - Artificial intelligence

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Next.js 15 SEO: metadata, OG Images, Sitemap, and Structu...
Carlos Oliva Pascual · 2026-06-22 · via DEV Community

Carlos Oliva Pascual

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

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],
    },
  }
}

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"
}

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 }
  )
}

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,
    })),
  ]
}

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

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

Canonical URLs

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

For paginated routes:

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

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 } })
})

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


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