Metadata API in Next.js 15: SEO, Open Graph & generateMetadata Gids (2026)

De Metadata API in Next.js 15 beheert SEO-tags, Open Graph en JSON-LD vanuit server components. Leer generateMetadata, sitemap.ts en hreflang.

Metadata API Next.js 15 Gids (2026)

Bijgewerkt: 23 juni 2026

De Metadata API in Next.js 15 is een ingebouwd systeem waarmee je SEO-tags, Open Graph-data, Twitter Cards en favicon-informatie declaratief vanuit je server components beheert, zonder dat je ooit handmatig <head> hoeft te bewerken. Je exporteert ofwel een metadata-object voor statische tags, ofwel een generateMetadata-functie voor dynamische tags, en de App Router voegt ze automatisch toe aan elke pagina. In deze gids bouwen we een productieklare metadata-strategie, inclusief sitemaps, structured data en lokalisatie.

Eerlijk gezegd was dit een van mijn favoriete migraties van Pages naar App Router. Ik heb in een vorig project een hele useEffect-laag aan client-side title-updates kunnen schrappen toen we overstapten op generateMetadata, en de Lighthouse-scores gingen meteen omhoog.

  • Statische metadata exporteer je als een metadata-constante; dynamische metadata komt uit een generateMetadata-functie die params en searchParams ontvangt.
  • Next.js 15 dedupliceert automatisch fetch-aanroepen tussen generateMetadata en je page component, dus dezelfde data ophalen is gratis.
  • File-based conventies (favicon.ico, opengraph-image.tsx, icon.tsx, sitemap.ts, robots.ts) genereren de juiste HTML-tags en route handlers zonder extra configuratie.
  • Voor rich results in Google moet je JSON-LD structured data toevoegen via een <script type="application/ld+json">-tag in je layout of page.
  • Title templates met %s en de alternates-property regelen branding-consistentie en internationale SEO via hreflang.
  • Async params en searchParams zijn nu Promises; vergeet je het await, dan zie je stille bugs in productie.

Wat is de Metadata API in Next.js 15?

De Metadata API is de officiele manier in de Next.js App Router om alle SEO-relevante <head>-tags te beheren: page titles, descriptions, canonical URLs, Open Graph-tags, Twitter Cards, viewport-instellingen, favicons en zelfs robots-directieven. In tegenstelling tot de oude Pages Router (waar je een aparte <Head>-component uit next/head moest renderen) werkt de App Router puur declaratief vanuit server components.

Wat dit krachtig maakt, is de manier waarop Next.js metadata kaskadeert door de layout-boom. Een metadata-export in app/layout.tsx levert site-brede defaults. Een export in app/blog/layout.tsx overschrijft die voor alle blog-routes, en een export in app/blog/[slug]/page.tsx overschrijft per artikel. Next.js merget de objecten diepgaand, dus je hoeft alleen de velden te specificeren die je wilt overschrijven. Dit gedrag elimineert duplicatie en maakt het bijna onmogelijk om "vergeten" metadata-tags te hebben.

In Next.js 15 (gebouwd op React 19) is de Metadata API verder uitgebreid met betere ondersteuning voor streaming, automatische deduplicatie van fetch-calls tussen generateMetadata en je page component, en file-based metadata conventies waarmee je een hele icon-set of sitemap kunt opzetten zonder ook maar een metadata-object te schrijven. Voor een breder overzicht van hoe data-ophalen werkt in deze architectuur is onze handleiding over data ophalen in Next.js App Router aanbevolen leesvoer voordat je dieper in dynamische metadata duikt.

Statische metadata: de metadata export

Voor pagina's met vaste titels en beschrijvingen, denk aan een over-pagina of een marketing-landingspagina, exporteer je gewoon een constante. Next.js detecteert deze tijdens build-tijd en injecteert de juiste HTML-tags. Hier is een complete statische metadata-export voor de root layout, met alle velden die je in praktijk wilt zetten:

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

export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: {
    default: 'Acme - Modern SaaS for Teams',
    template: '%s | Acme',
  },
  description: 'Acme helps distributed teams ship faster with collaborative tooling.',
  keywords: ['SaaS', 'team collaboration', 'project management'],
  authors: [{ name: 'Acme Team', url: 'https://example.com/about' }],
  creator: 'Acme Inc.',
  openGraph: {
    type: 'website',
    locale: 'nl_NL',
    url: 'https://example.com',
    siteName: 'Acme',
    images: [{ url: '/og-default.png', width: 1200, height: 630 }],
  },
  twitter: {
    card: 'summary_large_image',
    creator: '@acme',
  },
  robots: {
    index: true,
    follow: true,
    googleBot: { 'max-image-preview': 'large', 'max-snippet': -1 },
  },
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="nl">
      <body>{children}</body>
    </html>
  )
}

Let speciaal op metadataBase: dit is de absolute URL waartegen alle relatieve paden (zoals /og-default.png) worden geresolved. Zonder deze property logt Next.js een waarschuwing, en werken Open Graph-afbeeldingen vaak niet correct op crawlers van Facebook of LinkedIn omdat die geen relatieve URLs accepteren. Zet hem in productie altijd via een environment variable, zodat preview-deploys op Vercel automatisch hun eigen subdomain gebruiken.

Dynamische metadata met generateMetadata

Voor pagina's die afhankelijk zijn van runtime-data (productpagina's, blogposts, gebruikersprofielen) exporteer je in plaats daarvan een async generateMetadata-functie. Deze ontvangt dezelfde params en searchParams als je page component, en kan fetchen, databases queryen of services aanroepen. Next.js wacht op de promise voordat het de respons begint te streamen, dus crawlers zien altijd de juiste tags.

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

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

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return { title: 'Niet gevonden' }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author.name],
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
    },
    alternates: {
      canonical: `/blog/${slug}`,
    },
  }
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()
  return <article>{/* render post */}</article>
}

Een belangrijk detail in Next.js 15: params en searchParams zijn nu Promises, niet meer gewone objecten. Dit is een breaking change ten opzichte van Next.js 14 en kwam binnen met de async request-API-migratie. Vergeet je het await, dan krijg je een runtime error in development en stille bugs in production. Ik liep hier zelf tegenaan tijdens een upgrade: TypeScript bleef stil over een gemiste await omdat we ergens een any-cast hadden. Lees de officiele generateMetadata referentie voor de volledige type-signatuur.

Omdat getPost(slug) waarschijnlijk ook in je page component wordt aangeroepen, lijkt het alsof je twee keer dezelfde data ophaalt. Maar Next.js 15 dedupliceert fetch-requests met identieke URL + options binnen dezelfde render, en voor je eigen functies kun je React.cache() gebruiken om hetzelfde gedrag te krijgen:

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

export const getPost = cache(async (slug: string) => {
  return db.query.posts.findFirst({ where: (p, { eq }) => eq(p.slug, slug) })
})

Hoe stel ik Open Graph en Twitter Cards in?

Open Graph (gebruikt door Facebook, LinkedIn, Slack, Discord, iMessage) en Twitter Cards (X/Twitter) bepalen hoe je pagina's eruitzien wanneer ze gedeeld worden. Goede share-previews kunnen click-through rates makkelijk verdubbelen, dus dit is een laaghangende SEO-vrucht. De Metadata API ondersteunt beide formaten met dezelfde structuur.

export const metadata: Metadata = {
  openGraph: {
    title: 'Hoe je Next.js productie-snel maakt',
    description: 'Praktische technieken voor sub-100ms TTFB.',
    url: 'https://example.com/blog/nextjs-performance',
    siteName: 'Acme Blog',
    type: 'article',
    publishedTime: '2026-06-23T10:00:00Z',
    authors: ['https://example.com/team/anna'],
    images: [
      {
        url: 'https://example.com/og/nextjs-performance.png',
        width: 1200,
        height: 630,
        alt: 'Next.js performance dashboard met groene metrics',
      },
    ],
    locale: 'nl_NL',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Hoe je Next.js productie-snel maakt',
    description: 'Praktische technieken voor sub-100ms TTFB.',
    images: ['https://example.com/og/nextjs-performance.png'],
    creator: '@anna_dev',
    site: '@acme',
  },
}

De aanbevolen afmetingen voor Open Graph-afbeeldingen zijn 1200×630 pixels (1.91:1 ratio). Onder de 600 pixels breed valt Facebook terug naar een kleine vierkante preview, dus blijf boven die drempel. Voor Twitter is summary_large_image bijna altijd de juiste keuze. Alleen voor lange artikelen zonder visueel materiaal is summary beter. Zie ook de officiele Open Graph protocol-specificatie voor alle ondersteunde velden.

File conventions: favicon, opengraph-image en icons

Naast object-gebaseerde metadata kent de App Router file-based metadata conventies: speciale bestandsnamen die Next.js automatisch herkent en omzet in de juiste HTML. Dit is vaak handiger dan handmatig URL-paths in een metadata-object zetten, omdat je geen typos kunt maken en de bestanden automatisch worden gehashed voor cache-busting.

De belangrijkste conventies, allemaal te plaatsen in je app/-directory:

  • favicon.ico: klassieke browser-favicon, gerenderd als <link rel="icon">
  • icon.png of icon.tsx: modern PNG-icon of dynamisch gegenereerd icon
  • apple-icon.png of apple-icon.tsx: specifiek voor iOS home-screen
  • opengraph-image.png of opengraph-image.tsx: de OG-share-afbeelding
  • twitter-image.png of twitter-image.tsx: aparte Twitter-afbeelding als deze afwijkt

De .tsx-varianten zijn echt cool: ze gebruiken next/og (gebaseerd op Satori) om afbeeldingen op de edge te genereren uit JSX. Dit is perfect voor dynamische OG-cards per blogpost. Een minimaal voorbeeld:

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

export const alt = 'Acme blogpost share card'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

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

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          background: 'linear-gradient(135deg, #0f172a, #1e293b)',
          color: 'white',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          padding: 64,
          fontFamily: 'Inter',
        }}
      >
        <div style={{ fontSize: 32, opacity: 0.7 }}>Acme Blog</div>
        <div style={{ fontSize: 72, fontWeight: 700, marginTop: 24 }}>{post?.title}</div>
      </div>
    ),
    { ...size }
  )
}

Elke blogpost krijgt nu automatisch een unieke, mooi-uitziende share-afbeelding zonder dat je een designer of headless browser nodig hebt. De ImageResponse wordt op de edge gerenderd en agressief gecached.

Hoe genereer ik een sitemap in Next.js App Router?

Een sitemap helpt Google al je URLs te ontdekken en is essentieel voor sites met meer dan een paar honderd pagina's. In de App Router maak je gewoon een app/sitemap.ts-bestand dat een array van URL-objecten exporteert. Next.js serveert het automatisch op /sitemap.xml met de juiste content-type header.

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const base = 'https://example.com'
  const posts = await getAllPosts()

  const staticRoutes: MetadataRoute.Sitemap = [
    { url: base, lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
    { url: `${base}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
    { url: `${base}/over-ons`, changeFrequency: 'yearly', priority: 0.5 },
  ]

  const postRoutes: MetadataRoute.Sitemap = posts.map((p) => ({
    url: `${base}/blog/${p.slug}`,
    lastModified: new Date(p.updatedAt),
    changeFrequency: 'monthly',
    priority: 0.7,
  }))

  return [...staticRoutes, ...postRoutes]
}

Voor sites met meer dan 50.000 URLs (de Google-limiet per sitemap) kun je sitemap-indexen genereren door meerdere sitemap.ts-bestanden te exporteren met de generateSitemaps-helper. De parallelle app/robots.ts-conventie genereert je robots.txt:

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

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

Als je dynamische routes hebt die je niet wilt laten crawlen, zoals authentieke dashboards, combineer dit dan met header-gebaseerde beveiliging via onze handleiding over Next.js middleware voor authenticatie en beveiliging. robots.txt is een advies, geen handhaving.

JSON-LD structured data voor rich results

Voor Google's rich results (ster-ratings, recept-cards, FAQ-accordeons, breadcrumb-trails in de SERP) heb je structured data nodig in JSON-LD-formaat. De Metadata API exporteert hier geen aparte property voor; in plaats daarvan render je een <script>-tag rechtstreeks in je page component. Dit werkt prima omdat Google JavaScript executeert tijdens crawling.

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    image: [post.coverImage],
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: { '@type': 'Person', name: post.author.name, url: post.author.url },
    publisher: {
      '@type': 'Organization',
      name: 'Acme',
      logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' },
    },
    description: post.excerpt,
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{/* render post */}</article>
    </>
  )
}

De meest waardevolle schema-types voor de meeste sites zijn Article/BlogPosting, Product met reviews, FAQPage en BreadcrumbList. Bekijk de volledige schema.org-vocabulaire voor het type dat past bij jouw content. Valideer altijd met Google's Rich Results Test voordat je deployt. Een enkele getypte property en je rich result verschijnt niet; ik heb meer dan eens een middag verloren omdat datePublished als Date-object werd geserialiseerd in plaats van als ISO-string.

Title templates en canonical URLs

Een van de meest onderschatte features is de title.template-property. In plaats van op elke pagina handmatig "… | Acme" achter je titel te plakken, definieer je het patroon een keer in je root layout en geef je per pagina alleen de korte titel mee:

// app/layout.tsx
export const metadata: Metadata = {
  title: {
    default: 'Acme - Modern SaaS for Teams',
    template: '%s | Acme',
  },
}

// app/blog/[slug]/page.tsx
export const metadata: Metadata = {
  title: 'Hoe je Next.js productie-snel maakt', // wordt "Hoe je Next.js productie-snel maakt | Acme"
}

// app/blog/page.tsx
export const metadata: Metadata = {
  title: { absolute: 'Acme Blog - Alle artikelen' }, // negeert het template
}

De alternates.canonical-property is even belangrijk voor SEO. Canonical URLs vertellen Google welke versie van een pagina de "waarheid" is wanneer meerdere URLs vergelijkbare content tonen. Bijvoorbeeld een artikel dat zowel via /blog/post-x als via /categorie/javascript/post-x bereikbaar is. Zonder canonical splitst Google ranking signals over beide URLs en presteer je slechter. Voor het bouwen van REST-endpoints die met deze structuur samenwerken kan onze gids over route handlers in Next.js 15 handig zijn.

Internationalisering met alternates en hreflang

Heeft je site vertaalde versies, dan moet je hreflang-tags zetten zodat Google de juiste taalversie aan elke gebruiker toont. De Metadata API ondersteunt dit via alternates.languages:

export const metadata: Metadata = {
  alternates: {
    canonical: '/nl/blog/nextjs-metadata',
    languages: {
      'en-US': '/en/blog/nextjs-metadata',
      'nl-NL': '/nl/blog/nextjs-metadata',
      'de-DE': '/de/blog/nextjs-metadata',
      'x-default': '/en/blog/nextjs-metadata',
    },
  },
}

De x-default-key vertelt Google welke versie te tonen als geen enkele taalmatch klopt, vaak je Engelse versie. Vergeet deze niet, anders kiest Google soms verrassend. Voor sitemaps met multi-language content kun je de alternates-property ook in je sitemap.ts opnemen volgens dezelfde structuur; dit versnelt indexering van nieuwe taalversies aanzienlijk.

Veelgemaakte metadata-fouten en hoe je ze oplost

In code reviews van Next.js-projecten zien we steeds dezelfde categorieen fouten. Hier zijn de vijf belangrijkste, met hun oplossing:

  1. metadata op een client component. Een "use client"-bestand kan geen metadata of generateMetadata exporteren. Verplaats de export naar de bijbehorende layout.tsx, of split de pagina in een server-shell met een client child.
  2. metadataBase ontbreekt. Relatieve OG-image paths breken op crawlers. Zet metadataBase in je root layout met een environment variable.
  3. Async params niet awaiten. In Next.js 15 zijn params en searchParams Promises. const { slug } = params faalt; gebruik const { slug } = await params.
  4. Open Graph-image te klein. Onder 600px breed valt Facebook terug op een vierkante preview. Gebruik altijd 1200×630.
  5. Title templates op de homepage. Als je root layout een template heeft, krijgt je homepage anders "%s | Acme" te zien zonder ingevulde %. Zet expliciet title: { absolute: '...' } op app/page.tsx.

Nog een vaak gemiste optimalisatie: combineer je metadata-strategie met de juiste rendering-strategie in Next.js App Router. SSG met goede metadata zit binnen 50ms in de cache van Googlebot. SSR met dure database-calls in generateMetadata kan crawler-budget opvreten. Cache aggressief en gebruik React.cache() om dubbele queries te voorkomen.

Veelgestelde vragen

Hoe voeg ik metadata toe in Next.js 15?

Exporteer een constante genaamd metadata van het type Metadata uit een layout.tsx of page.tsx. Voor dynamische metadata gebruik je in plaats daarvan een async functie generateMetadata die params ontvangt en een Metadata-object retourneert.

Wat is het verschil tussen metadata en generateMetadata?

metadata is een statisch object dat tijdens build-tijd wordt geresolved; gebruik dit voor pagina's met vaste tags. generateMetadata is een async functie die runtime-data kan ophalen; gebruik dit voor dynamische routes zoals blogposts of productpagina's.

Hoe voeg ik een favicon toe in de App Router?

Plaats simpelweg een favicon.ico-bestand in je app/-directory. Next.js detecteert het automatisch en voegt de juiste <link rel="icon">-tag toe. Voor extra resoluties kun je icon.png en apple-icon.png toevoegen.

Werkt de Metadata API met client components?

Nee. metadata en generateMetadata kunnen alleen geexporteerd worden vanuit server components. Heb je een client-pagina, verplaats de metadata-export dan naar de omhullende layout.tsx of refactor naar een server-shell met client children.

Moet ik JSON-LD structured data toevoegen voor SEO?

Voor rich results in Google (FAQ-accordeons, recept-cards, ster-ratings) ja. Voor basis-ranking is het optioneel maar nuttig. Render JSON-LD via een <script type="application/ld+json">-tag in je page component met dangerouslySetInnerHTML.

Editorial Team
Over de Auteur Editorial Team

Our team of expert writers and editors.