Boilerplate
Developer Guide

Metadata & SEO Guide

Overview

This guide covers implementing metadata, favicons, Open Graph tags, structured data (JSON-LD), SEO fundamentals, and Answer Engine Optimization (AEO) in Next.js 16. The goal is to make your site discoverable by search engines, presentable in social shares, and readable by AI assistants.

Key Files

ConceptLocation
Root metadatasrc/app/layout.tsx
Page-specific metadatasrc/app/[route]/page.tsx
Favicon (legacy)src/app/favicon.ico
Dynamic iconssrc/app/icon.tsx, src/app/apple-icon.tsx
OG image generationsrc/app/opengraph-image.tsx
Web app manifestpublic/manifest.webmanifest
Robots rulessrc/app/robots.ts
Sitemap generationsrc/app/sitemap.ts

When to Use What

ScenarioApproachWhy
Site-wide metadataStatic metadata in root layout.tsxSingle source of truth, inherited by all pages
Page-specific metadataStatic metadata export in page.tsxOverrides or extends parent metadata
Dynamic metadata (blog posts)generateMetadata() functionFetches data to build title, description, OG tags
Favicon for all browsersMinimal setup (ico + svg + apple-touch + manifest)Covers 99%+ of use cases with 4-5 files
Social share imagesStatic file or opengraph-image.tsx1200x630 images for Facebook, LinkedIn, Twitter
Rich search resultsJSON-LD structured dataEnables rich snippets, Knowledge Graph
AI assistant visibilityAEO patternsMakes content extractable by ChatGPT, Perplexity

Core Patterns

Next.js Metadata API

Next.js provides a type-safe Metadata API for defining SEO tags. Metadata is inherited from parent layouts and can be overridden or extended by child routes.

Static Metadata

For pages with fixed metadata, export a metadata object:

// src/app/layout.tsx
import type { Metadata, Viewport } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://yoursite.com'),
  title: {
    default: 'Your Site Name',
    template: '%s | Your Site Name', // For child pages
  },
  description: 'A concise description of your site (150-160 chars ideal)',
  keywords: ['keyword1', 'keyword2', 'keyword3'],
  authors: [{ name: 'Your Name', url: 'https://yoursite.com' }],
  creator: 'Your Name',
  robots: {
    index: true,
    follow: true,
  },
  openGraph: {
    type: 'website',
    locale: 'en_US',
    url: 'https://yoursite.com',
    siteName: 'Your Site Name',
    title: 'Your Site Name',
    description: 'A concise description of your site',
    images: [
      {
        url: '/og-image.png',
        width: 1200,
        height: 630,
        alt: 'Your Site Name',
      },
    ],
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Your Site Name',
    description: 'A concise description of your site',
    images: ['/og-image.png'],
    creator: '@yourhandle',
  },
}

export const viewport: Viewport = {
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#fafafa' },
    { media: '(prefers-color-scheme: dark)', color: '#09090b' },
  ],
  width: 'device-width',
  initialScale: 1,
}

Dynamic Metadata with generateMetadata

For pages that need to fetch data (blog posts, products), use generateMetadata:

// src/app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
import { getPostBySlug } from '@/layers/entities/post'
import { notFound } from 'next/navigation'

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPostBySlug(slug)

  if (!post) return {}

  // Extend parent OG images instead of replacing
  const previousImages = (await parent).openGraph?.images || []

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.createdAt.toISOString(),
      authors: [post.author.name],
      images: [
        {
          url: post.coverImage || '/og-image.png',
          width: 1200,
          height: 630,
        },
        ...previousImages,
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage || '/og-image.png'],
    },
  }
}

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params
  const post = await getPostBySlug(slug)

  if (!post) notFound()

  return <article>{/* ... */}</article>
}

Title Patterns

Next.js supports three title configuration patterns:

// Root layout - defines template and default
export const metadata: Metadata = {
  title: {
    template: '%s | Your Site',  // %s is replaced by child page title
    default: 'Your Site',        // Fallback when child has no title
  },
}

// Child page - uses template
export const metadata: Metadata = {
  title: 'About',  // Renders: "About | Your Site"
}

// Child page - ignores template
export const metadata: Metadata = {
  title: {
    absolute: 'Custom Title',  // Renders: "Custom Title" (no template)
  },
}
PatternUse Case
title.templateConsistent branding suffix/prefix
title.defaultFallback for pages without explicit title
title.absoluteOverride template for specific pages (e.g., home page)

Metadata Inheritance

Next.js automatically merges metadata from parent layouts:

// app/layout.tsx (parent)
export const metadata = {
  title: 'My App',
  openGraph: {
    title: 'My App',
    description: 'App description',
    siteName: 'My App',
  },
}

// app/about/page.tsx (child)
export const metadata = {
  title: 'About',
  // openGraph.title and openGraph.description inherited from parent
  // Only need to override what changes
}

// Result: title="About", og:title="My App", og:description="App description"

Key behavior:

  • Child metadata replaces parent for same keys (not deep merge)
  • Unspecified fields inherit from parent
  • To extend arrays (like OG images), use generateMetadata with parent parameter

Request Memoization

Fetch requests in generateMetadata are automatically memoized across the render tree:

// This fetch is automatically deduplicated across generateMetadata,
// generateStaticParams, layouts, pages, and Server Components
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await fetch(`https://api.example.com/posts/${slug}`).then(r => r.json())

  return { title: post.title }
}

export default async function Page({ params }: Props) {
  const { slug } = await params
  // Same fetch - reuses memoized result, no duplicate request
  const post = await fetch(`https://api.example.com/posts/${slug}`).then(r => r.json())

  return <article>{post.content}</article>
}

Memoization rules:

  • Only applies to GET method in fetch requests
  • Only lasts for the current request (not across requests)
  • Does not apply to Route Handlers

Using React.cache() for Non-Fetch Data

For database queries or other non-fetch data, use React.cache() to deduplicate:

// src/layers/entities/post/api/queries.ts
import { cache } from 'react'
import { prisma } from '@/lib/prisma'

// Memoize the database query
export const getPostBySlug = cache(async (slug: string) => {
  return prisma.post.findUnique({
    where: { slug },
    include: { author: true },
  })
})
// src/app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/layers/entities/post'

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPostBySlug(slug) // First call - executes query
  return { title: post?.title }
}

export default async function Page({ params }: Props) {
  const { slug } = await params
  const post = await getPostBySlug(slug) // Second call - returns cached result
  return <article>{post?.content}</article>
}

Streaming Metadata (Next.js 15.2+)

Next.js streams metadata separately from page content, improving perceived performance:

  • Page content renders immediately while metadata resolves
  • Metadata injected into <head> once generateMetadata completes
  • Disabled for bots (Twitterbot, Slackbot, etc.) that expect metadata in initial HTML

This is automatic—no configuration needed. Just be aware that slow generateMetadata functions won't block page rendering.

Static vs Dynamic: When to Use What

Content TypeApproachWhy
Fixed content (About, Contact)Static metadata exportNo overhead, resolved at build time
Blog posts, productsgenerateMetadata() functionNeeds data to build title/description
Client Component pagesWrap in layout.tsx with static metadataClient Components can't export metadata

Anti-pattern: Don't use generateMetadata for static pages—it adds unnecessary overhead:

// Bad: Using generateMetadata for static content
export async function generateMetadata(): Promise<Metadata> {
  return { title: 'About Us' }  // This never changes, why async?
}

// Good: Static export
export const metadata: Metadata = {
  title: 'About Us',
}

Per-Page Robots Control

Control crawling at the page level via metadata:

// Public page - allow indexing
export const metadata: Metadata = {
  robots: {
    index: true,
    follow: true,
  },
}

// Private/draft page - block indexing
export const metadata: Metadata = {
  robots: {
    index: false,
    follow: false,
  },
}

// Advanced: Google-specific directives
export const metadata: Metadata = {
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
}

Favicons & App Icons

Modern favicon setup requires only 4-5 files to cover all browsers and devices. Avoid generating dozens of sizes—most are unnecessary.

public/
├── favicon.ico          # 32x32 ICO for legacy browsers
├── icon.svg             # SVG for modern browsers (scales perfectly)
├── apple-touch-icon.png # 180x180 PNG for iOS home screen
└── manifest.webmanifest # PWA manifest with icon references

Add to your root layout's <head> via metadata:

// src/app/layout.tsx
export const metadata: Metadata = {
  icons: {
    icon: [
      { url: '/favicon.ico', sizes: '32x32' },
      { url: '/icon.svg', type: 'image/svg+xml' },
    ],
    apple: [
      { url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
    ],
  },
  manifest: '/manifest.webmanifest',
}

Web App Manifest

// public/manifest.webmanifest
{
  "name": "Your App Name",
  "short_name": "App",
  "description": "Your app description",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icon-512-maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

Icon Sizes Reference

SizePurposeRequired?
16x16Browser tabs, bookmark listsVia ICO
32x32Browser tabs (Retina), Windows taskbarYes (ICO)
180x180iOS home screen (Apple Touch Icon)Yes
192x192Android home screen (PWA)Yes
512x512PWA splash screen, app storesYes
512x512 maskableAndroid adaptive iconsRecommended

Dynamic Icon Generation

Next.js can generate icons programmatically using the ImageResponse API:

// src/app/icon.tsx
import { ImageResponse } from 'next/og'

export const size = { width: 32, height: 32 }
export const contentType = 'image/png'

export default function Icon() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 24,
          background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
          borderRadius: 6,
        }}
      >
        A
      </div>
    ),
    { ...size }
  )
}

Dark Mode Favicon

Support dark mode with media query variants:

export const metadata: Metadata = {
  icons: {
    icon: [
      { url: '/icon-light.svg', type: 'image/svg+xml' },
      {
        url: '/icon-dark.svg',
        type: 'image/svg+xml',
        media: '(prefers-color-scheme: dark)',
      },
    ],
  },
}

Open Graph & Social Cards

Open Graph (OG) tags control how your pages appear when shared on Facebook, LinkedIn, Twitter, Slack, and other platforms.

PlatformSizeAspect RatioNotes
Universal1200x6301.91:1Works everywhere, recommended default
Facebook1200x6301.91:1Minimum 600x315
LinkedIn1200x6271.91:1Nearly identical to Facebook
Twitter1200x67516:9Summary Large Image card
Minimum600x3151.91:1Below this, quality suffers

Static OG Image

Place a static image at public/og-image.png (1200x630):

export const metadata: Metadata = {
  openGraph: {
    images: [
      {
        url: '/og-image.png',
        width: 1200,
        height: 630,
        alt: 'Your Site Name',
      },
    ],
  },
}

Dynamic OG Image Generation

Generate OG images at build/request time with Next.js:

// src/app/opengraph-image.tsx
import { ImageResponse } from 'next/og'

export const runtime = 'edge'
export const alt = 'Your Site Name'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image() {
  return new ImageResponse(
    (
      <div
        style={{
          background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
          fontFamily: 'sans-serif',
        }}
      >
        <h1 style={{ fontSize: 72, fontWeight: 'bold', margin: 0 }}>
          Your Site Name
        </h1>
        <p style={{ fontSize: 32, opacity: 0.9, marginTop: 16 }}>
          Your tagline here
        </p>
      </div>
    ),
    { ...size }
  )
}

For dynamic pages (blog posts), create route-specific OG images:

// src/app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPostBySlug } from '@/layers/entities/post'

export const runtime = 'edge'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug)

  return new ImageResponse(
    (
      <div
        style={{
          background: '#1a1a1a',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          padding: 60,
          color: 'white',
        }}
      >
        <h1 style={{ fontSize: 56, fontWeight: 'bold', lineHeight: 1.2 }}>
          {post?.title || 'Blog Post'}
        </h1>
        <p style={{ fontSize: 28, opacity: 0.8, marginTop: 24 }}>
          {post?.excerpt || ''}
        </p>
        <div style={{ marginTop: 'auto', fontSize: 24, opacity: 0.6 }}>
          yoursite.com
        </div>
      </div>
    ),
    { ...size }
  )
}

Twitter-Specific Tags

Twitter (X) has its own card types:

export const metadata: Metadata = {
  twitter: {
    card: 'summary_large_image', // or 'summary', 'player', 'app'
    site: '@yoursite',          // Site's Twitter handle
    creator: '@authorhandle',   // Content creator's handle
    title: 'Page Title',
    description: 'Page description',
    images: {
      url: '/twitter-image.png',
      alt: 'Description of image',
    },
  },
}

Testing Social Previews

PlatformTool
FacebookSharing Debugger
TwitterCard Validator
LinkedInPost Inspector
Generalopengraph.xyz

Structured Data (JSON-LD)

JSON-LD (JavaScript Object Notation for Linked Data) helps search engines understand your content and display rich results (snippets, Knowledge Graph entries).

Implementation Pattern

Add JSON-LD as a script tag in your page component:

// src/app/page.tsx
export default function HomePage() {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'WebSite',
    name: 'Your Site Name',
    url: 'https://yoursite.com',
    description: 'Your site description',
    potentialAction: {
      '@type': 'SearchAction',
      target: 'https://yoursite.com/search?q={search_term_string}',
      'query-input': 'required name=search_term_string',
    },
  }

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

Security note: The .replace(/</g, '\\u003c') prevents XSS attacks by escaping < characters in the JSON.

Type Safety with schema-dts

Install the schema-dts package for TypeScript types:

pnpm add schema-dts
import type { WebSite, WithContext } from 'schema-dts'

const jsonLd: WithContext<WebSite> = {
  '@context': 'https://schema.org',
  '@type': 'WebSite',
  name: 'Your Site Name',
  url: 'https://yoursite.com',
}

Common Schema Types

Content TypeSchema TypeRich Result
Home/AboutOrganization, WebSiteKnowledge Panel, Sitelinks Search
Blog postsArticle, BlogPostingArticle rich results
ProductsProductProduct rich results, reviews
EventsEventEvent rich results
FAQ pagesFAQPageFAQ dropdowns in SERPs
How-to guidesHowToStep-by-step rich results
RecipesRecipeRecipe cards
Local businessLocalBusinessMaps, hours, contact info
PeoplePersonKnowledge Panel
BreadcrumbsBreadcrumbListBreadcrumb trails in SERPs

Organization Schema Example

const organizationJsonLd: WithContext<Organization> = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  name: 'Your Company',
  url: 'https://yoursite.com',
  logo: 'https://yoursite.com/logo.png',
  sameAs: [
    'https://twitter.com/yourcompany',
    'https://linkedin.com/company/yourcompany',
    'https://github.com/yourcompany',
  ],
  contactPoint: {
    '@type': 'ContactPoint',
    email: 'hello@yoursite.com',
    contactType: 'customer service',
  },
}

Article Schema Example

// src/app/blog/[slug]/page.tsx
import type { Article, WithContext } from 'schema-dts'

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params
  const post = await getPostBySlug(slug)

  const articleJsonLd: WithContext<Article> = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    datePublished: post.createdAt.toISOString(),
    dateModified: post.updatedAt.toISOString(),
    author: {
      '@type': 'Person',
      name: post.author.name,
      url: `https://yoursite.com/authors/${post.author.slug}`,
    },
    publisher: {
      '@type': 'Organization',
      name: 'Your Site Name',
      logo: {
        '@type': 'ImageObject',
        url: 'https://yoursite.com/logo.png',
      },
    },
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(articleJsonLd).replace(/</g, '\\u003c'),
        }}
      />
      <article>{/* Post content */}</article>
    </>
  )
}
const breadcrumbJsonLd: WithContext<BreadcrumbList> = {
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: [
    {
      '@type': 'ListItem',
      position: 1,
      name: 'Home',
      item: 'https://yoursite.com',
    },
    {
      '@type': 'ListItem',
      position: 2,
      name: 'Blog',
      item: 'https://yoursite.com/blog',
    },
    {
      '@type': 'ListItem',
      position: 3,
      name: post.title,
      item: `https://yoursite.com/blog/${post.slug}`,
    },
  ],
}

Validation Tools

ToolURL
Google Rich Results Testsearch.google.com/test/rich-results
Schema Markup Validatorvalidator.schema.org

SEO Fundamentals

Robots.txt

Control which pages search engines can crawl:

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

export default function robots(): MetadataRoute.Robots {
  const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://yoursite.com'

  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: [
          '/api/',
          '/admin/',
          '/_next/',
          '/private/',
        ],
      },
    ],
    sitemap: `${baseUrl}/sitemap.xml`,
  }
}

Sitemap

Generate a sitemap for search engine discovery:

// src/app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/layers/entities/post'

export default async function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://yoursite.com'

  // Static pages
  const staticPages: MetadataRoute.Sitemap = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.8,
    },
    {
      url: `${baseUrl}/blog`,
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.9,
    },
  ]

  // Dynamic pages (blog posts)
  const posts = await getAllPosts()
  const postPages: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.7,
  }))

  return [...staticPages, ...postPages]
}

Multiple Sitemaps for Large Sites

For sites with 50,000+ URLs, split into multiple sitemaps using generateSitemaps:

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

// Generate sitemap index with multiple sitemaps
export async function generateSitemaps() {
  const totalProducts = await getProductCount()
  const sitemapCount = Math.ceil(totalProducts / 50000)

  return Array.from({ length: sitemapCount }, (_, i) => ({ id: i }))
}

// Generate individual sitemap by ID
export default async function sitemap(props: {
  id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id)
  const start = id * 50000
  const end = start + 50000

  const products = await getProducts({ skip: start, take: 50000 })

  return products.map((product) => ({
    url: `https://yoursite.com/products/${product.slug}`,
    lastModified: product.updatedAt,
  }))
}

This generates /sitemap/0.xml, /sitemap/1.xml, etc., and a sitemap index at /sitemap.xml.

Image Sitemaps

Include images in your sitemap for better image search visibility:

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://yoursite.com/blog/my-post',
      lastModified: new Date(),
      images: [
        'https://yoursite.com/images/post-cover.jpg',
        'https://yoursite.com/images/post-diagram.png',
      ],
    },
  ]
}

Localized Sitemaps

For internationalized sites, include alternate language URLs:

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://yoursite.com/about',
      lastModified: new Date(),
      alternates: {
        languages: {
          en: 'https://yoursite.com/about',
          es: 'https://yoursite.com/es/about',
          de: 'https://yoursite.com/de/about',
        },
      },
    },
  ]
}

Canonical URLs

Prevent duplicate content issues:

export const metadata: Metadata = {
  metadataBase: new URL('https://yoursite.com'),
  alternates: {
    canonical: '/', // Resolves to https://yoursite.com/
    languages: {
      'en-US': '/en-US',
      'es': '/es',
    },
  },
}

Core Web Vitals

Google's performance metrics that affect rankings:

MetricTargetWhat It Measures
LCP (Largest Contentful Paint)< 2.5sLoading performance
INP (Interaction to Next Paint)< 200msInteractivity responsiveness
CLS (Cumulative Layout Shift)< 0.1Visual stability

Optimization tips:

  1. Improve LCP:

    • Use next/image for automatic image optimization
    • Preload critical fonts with next/font
    • Minimize render-blocking JavaScript
  2. Improve INP:

    • Break up long JavaScript tasks
    • Use React.memo() and useMemo() to prevent unnecessary re-renders
    • Defer non-critical JavaScript
  3. Improve CLS:

    • Always include width and height on images
    • Reserve space for dynamic content
    • Avoid inserting content above existing content

Image Optimization with next/image

The next/image component is critical for SEO and Core Web Vitals:

import Image from 'next/image'
import heroImage from './hero.png'

// Static import - automatic width/height/blur placeholder
<Image
  src={heroImage}
  alt="Hero image description"  // Required for accessibility + SEO
  placeholder="blur"            // Shows blur while loading
  preload                       // Preload for LCP images (above the fold)
/>

// Remote image - must specify dimensions
<Image
  src="https://example.com/image.jpg"
  alt="Remote image description"
  width={1200}
  height={630}
  loading="lazy"  // Default - defer loading until near viewport
/>

SEO-critical Image attributes:

AttributeImpactBest Practice
altAccessibility, image searchDescriptive, includes keywords naturally
width/heightCLS preventionAlways specify to reserve layout space
preloadLCP improvementOnly for above-the-fold hero/banner images
loading="lazy"PerformanceDefault for below-fold images
placeholder="blur"Perceived performanceUse for static imports

Remote image configuration:

// next.config.ts
const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
      {
        protocol: 'https',
        hostname: 'cdn.yoursite.com',
        pathname: '/uploads/**',
      },
    ],
  },
}

Semantic HTML

Use proper heading hierarchy and semantic elements:

<article>
  <header>
    <h1>Main Article Title</h1>
    <p>Article description or subtitle</p>
  </header>

  <section>
    <h2>First Section</h2>
    <p>Content...</p>

    <h3>Subsection</h3>
    <p>More content...</p>
  </section>

  <section>
    <h2>Second Section</h2>
    <p>Content...</p>
  </section>

  <footer>
    <p>Author info, published date</p>
  </footer>
</article>

Rules:

  • One <h1> per page (usually the page title)
  • Don't skip heading levels (h1 → h3)
  • Use <article>, <section>, <nav>, <aside>, <header>, <footer> appropriately
  • Use <main> for primary content (one per page)

Answer Engine Optimization (AEO)

AEO optimizes content for AI-powered search engines like ChatGPT, Perplexity, Google AI Overviews, and Microsoft Copilot. As AI assistants handle more searches, AEO is becoming as important as traditional SEO.

Why AEO Matters

  • Gartner predicts 25% of organic search traffic will shift to AI assistants by 2026
  • Over 50% of searches in 2025 don't result in clicks—users get answers directly from AI
  • Semrush predicts LLM traffic will overtake traditional Google search by end of 2027

AEO Content Patterns

1. Structure for Extraction

AI systems extract information better from well-structured content:

## What is [Topic]?

[Clear, concise 1-2 sentence definition]

## Key Features

- **Feature 1**: Brief explanation
- **Feature 2**: Brief explanation
- **Feature 3**: Brief explanation

## How to [Action]

1. First step with clear instruction
2. Second step with clear instruction
3. Third step with clear instruction

## [Topic] vs [Alternative]

| Aspect | [Topic] | [Alternative] |
|--------|---------|---------------|
| Speed | Fast | Medium |
| Cost | $10/mo | $20/mo |
| Ease | Easy | Complex |

2. Answer Questions Directly

Front-load answers—don't bury them:

<!-- Good: Answer first -->
## How long does X take?

X typically takes 2-3 hours. The exact duration depends on...

<!-- Bad: Answer buried -->
## How long does X take?

When considering the various factors involved in X, including preparation
time, execution, and cleanup, as well as potential complications that may
arise during the process, one must consider... [300 words later] ...about
2-3 hours.

3. Build Entity Authority

Help AI systems understand your expertise:

  • Use consistent terminology across your site
  • Link internally between related content
  • Create dedicated pages for key concepts
  • Include author bios with credentials
  • Reference authoritative external sources

4. Structured Data for AI

JSON-LD isn't just for Google—AI systems also parse it:

// FAQ schema helps AI extract Q&A pairs
const faqJsonLd: WithContext<FAQPage> = {
  '@context': 'https://schema.org',
  '@type': 'FAQPage',
  mainEntity: [
    {
      '@type': 'Question',
      name: 'What is the best approach for X?',
      acceptedAnswer: {
        '@type': 'Answer',
        text: 'The best approach is to...',
      },
    },
  ],
}

5. Content Completeness

AI prefers comprehensive, self-contained content:

  • Answer the full question, not just part of it
  • Include relevant context without requiring other pages
  • Cover common follow-up questions
  • Provide specific examples and data points

AEO Checklist

  • Clear heading hierarchy (H1 → H2 → H3)
  • Concise paragraphs (one main idea each)
  • Bullet points and numbered lists for key information
  • Comparison tables where relevant
  • FAQ sections with common questions
  • Definitions at the start of technical terms
  • Specific data points and statistics
  • Author credentials and expertise signals
  • Structured data (JSON-LD) for content type
  • Internal links to related content

SEO Checklist

Technical Foundation

  • robots.txt allows crawling of important pages
  • XML sitemap generated and submitted to Search Console
  • HTTPS enforced site-wide
  • Canonical URLs set to prevent duplicates
  • Mobile-responsive design
  • Core Web Vitals passing (LCP < 2.5s, INP < 200ms, CLS < 0.1)

On-Page SEO

  • Unique <title> on every page (50-60 characters)
  • Unique <meta description> on every page (150-160 characters)
  • One <h1> per page matching the topic
  • Semantic heading hierarchy (no skipped levels)
  • Descriptive alt text on images
  • Internal linking between related content
  • Clean, readable URLs with keywords

Social & Sharing

  • Open Graph tags on all public pages
  • og:image at 1200x630 for each page type
  • Twitter Card tags configured
  • Social preview tested on all platforms

Structured Data

  • Organization schema on home page
  • Article schema on blog posts
  • Breadcrumb schema for navigation
  • FAQ schema on FAQ pages
  • Validated with Rich Results Test

Icons & PWA

  • favicon.ico (32x32) present
  • SVG icon for modern browsers
  • Apple Touch Icon (180x180)
  • Web app manifest with 192x192 and 512x512 icons

Resources

Official Documentation

Testing Tools

Favicon Generators

Further Reading