Metadata API în Next.js 16: Ghid Complet pentru SEO, Open Graph și Sitemap

Ghid complet Metadata API în Next.js 16: static vs generateMetadata, OG images dinamice cu ImageResponse, sitemap.ts, robots.ts, JSON-LD pentru rich snippets, canonical URLs și i18n. Cu exemple de cod și erori comune întâlnite în producție.

Metadata API Next.js 16: Ghid SEO 2026

Actualizat: 14 septembrie 2026

Metadata API din Next.js 16 este sistemul oficial prin care declari titluri de pagină, descrieri, canonical URLs, tag-uri Open Graph, Twitter cards, robots și sitemap direct din segmentele App Router. Ai două variante: fie static, prin exportul unui obiect metadata, fie dinamic, prin funcția asincronă generateMetadata. Framework-ul serializează valorile în tag-uri <head> pe server, deduplice cheile pe segmente imbricate și streamează totul înainte de conținut, astfel încât crawler-ele Google, Bing și feed-urile sociale să vadă meta-datele complete fără să execute JavaScript. În acest ghid parcurg întreaga suprafață a API-ului, cu exemple pe care le rulezi direct din app/.

  • Metadata API în Next.js 16 înlocuiește complet next/head. Orice segment din app/ poate exporta un obiect metadata sau o funcție generateMetadata asincronă.
  • generateMetadata primește params și searchParams ca Promise începând cu Next.js 15 și rămâne asincron în v16, deci trebuie să folosești await.
  • Metadata din segmentele copil se combină cu metadata din layout.tsx părinte: cheile scalar sunt suprascrise, iar openGraph și twitter se resetează dacă nu le moștenești explicit prin metadataBase.
  • Poți genera imagini Open Graph la runtime cu ImageResponse din next/og, direct în edge runtime, la 1200×630px.
  • Fișierele speciale sitemap.ts, robots.ts, opengraph-image.tsx și manifest.ts generează endpoint-uri publice fără să scrii vreo rută separată.
  • Pentru rich results în Google, adaugă JSON-LD ca <script type="application/ld+json"> într-un layout sau pagină. Metadata API nu îl gestionează nativ.

Ce este Metadata API în Next.js 16?

Metadata API este contractul dintre segmentele App Router și tag-urile din <head>. În loc să injectezi manual <meta> cu next/head (metoda din Pages Router pe care am eliminat-o la Vercel când migram checkout-ul Shopify în 2022), declari un obiect TypeScript-tipat cu forma Metadata, exportat din layout.tsx sau page.tsx. Framework-ul face următoarele:

  1. Colectează metadata de la fiecare segment din arborele de rute, de la root layout până la pagina curentă.
  2. Aplică regulile de merge. Cheile primitive (title, description, robots) sunt suprascrise de segmentele copil, iar obiectele imbricate (openGraph, twitter) sunt înlocuite complet, nu combinate.
  3. Serializează rezultatul în tag-uri HTML înainte ca React să înceapă streaming-ul body-ului.
  4. Injectează tag-uri suplimentare pentru fișierele speciale detectate (opengraph-image.tsx, icon.tsx, manifest.ts).

Consecința practică: crawler-ele care nu execută JavaScript (Twitterbot, Facebook External Hit, Slackbot, LinkedInBot, GPTBot) primesc toate meta-tag-urile în răspunsul HTML inițial. Nu te bazezi pe hidratare, nu ai flash-uri de titlu greșit în tab-ul browserului, iar useEffect-urile care setează titlul manual sunt un anti-pattern. Sincer, pe majoritatea proiectelor pe care le-am întâlnit ca consultant, prima curățenie după migrarea la App Router este ștergerea a 200-400 de linii de document.title = ... plus next/head.

Metadata statică vs dinamică: când folosești fiecare

Ai două căi: metadata statică, un obiect literal exportat direct, și metadata dinamică prin generateMetadata. Alegerea depinde exclusiv de dacă valorile finale sunt cunoscute la build sau depind de params, searchParams sau un fetch de date. Un tabel comparativ pentru a decide rapid:

CaracteristicăMetadata staticăgenerateMetadata
Sintaxăexport const metadata: Metadata = {...}export async function generateMetadata(...)
Acces la paramsNuDa, ca Promise<Params>
Poate face fetchNuDa, cu await fetch()
Deduplice fetch cu paginaN/ADa, prin React cache sau fetch memoization
Build time overheadZeroRulează la request/build pentru fiecare rută
Când o alegiTitluri fixe, homepage, about, layout rootPagini de produs, articole de blog, profiluri user

Recomandarea mea concretă: pornește cu metadata statică în root layout (site name, defaults pentru openGraph, metadataBase, robots), apoi folosește generateMetadata doar în rutele dinamice unde chiar ai nevoie de valori din CMS sau bază de date. Am văzut echipe care rulau generateMetadata pe fiecare pagină statică fetchând redundant din Sanity. Build time-ul lor pe Vercel a scăzut de la 8 minute la 90 secunde după refactoring.

Cum se folosește generateMetadata în Next.js 16?

Semnătura funcției primește { params, searchParams } ca Promise-uri, o schimbare introdusă în Next.js 15 pentru a permite request-uri concurente și menținută în v16. Trebuie să faci await, altfel TypeScript te va opri. Iată un exemplu complet pentru o pagină de articol care fetchează din baza de date, deduplicând apelul cu React.cache:

// app/blog/[slug]/page.tsx
import { cache } from 'react';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';

type Params = Promise<{ slug: string }>;

// cache() deduplice apelul între generateMetadata și componenta pagină
const getArticle = cache(async (slug: string) => {
  const res = await fetch(`https://api.example.com/articles/${slug}`, {
    next: { revalidate: 3600, tags: [`article:${slug}`] },
  });
  if (!res.ok) return null;
  return res.json() as Promise<{
    title: string;
    excerpt: string;
    coverImage: string;
    author: string;
    publishedAt: string;
    updatedAt: string;
  }>;
});

export async function generateMetadata(
  { params }: { params: Params }
): Promise<Metadata> {
  const { slug } = await params;
  const article = await getArticle(slug);

  if (!article) {
    return { title: 'Articol negăsit' };
  }

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

export default async function Page({ params }: { params: Params }) {
  const { slug } = await params;
  const article = await getArticle(slug); // cache hit, zero request suplimentar
  if (!article) notFound();
  return <article>{/* ... */}</article>;
}

Un pattern pe care îl folosesc frecvent: setează în root layout un title.template pentru a compune titluri consistente site-wide, apoi în pagini întorci doar partea variabilă. Ideea e simplă, dar economisește ore de duplicare:

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: {
    default: 'Exemplu, Ghiduri de dezvoltare',
    template: '%s | Exemplu',
  },
  description: 'Tutoriale practice pentru echipe de produs.',
};

// app/blog/[slug]/page.tsx devine automat "Titlu articol | Exemplu"
return { title: article.title };

Cum generezi Open Graph images dinamice cu ImageResponse?

Open Graph image dinamică se generează cu ImageResponse din pachetul next/og, care rulează pe edge runtime și convertește JSX în PNG folosind Satori. Rezultatul e un endpoint edge care poate randa mii de variații fără să pre-generezi niciun asset. Convenția Next.js 16: pui un fișier opengraph-image.tsx în orice segment și framework-ul îl expune automat la /opengraph-image pentru ruta respectivă. Iată o implementare completă pentru app/blog/[slug]/opengraph-image.tsx:

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

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

export default async function OGImage(
  { params }: { params: { slug: string } }
) {
  const res = await fetch(
    `https://api.example.com/articles/${params.slug}`,
    { next: { revalidate: 3600 } }
  );
  const article = await res.json();

  return new ImageResponse(
    (
      <div style={{
        display: 'flex',
        flexDirection: 'column',
        width: '100%',
        height: '100%',
        padding: '80px',
        background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)',
        color: 'white',
        fontFamily: 'Inter',
      }}>
        <div style={{ fontSize: 24, opacity: 0.7 }}>
          exemplu.com/blog
        </div>
        <div style={{
          fontSize: 72,
          fontWeight: 700,
          marginTop: 40,
          lineHeight: 1.1,
        }}>
          {article.title}
        </div>
        <div style={{
          fontSize: 28,
          opacity: 0.8,
          marginTop: 'auto',
        }}>
          {article.author} · {new Date(article.publishedAt).getFullYear()}
        </div>
      </div>
    ),
    { ...size }
  );
}

Câteva detalii pe care le trec cu vederea majoritatea tutorialelor: ImageResponse suportă doar un subset limitat de CSS. Flexbox funcționează, grid nu; poziționarea absolute funcționează, dar transform e limitat; nu ai acces la fonturi de sistem, deci trebuie să încarci fontul explicit dintr-un fișier .ttf sau .otf și să-l treci ca opțiune. Pentru fonts nelocale, referință-le prin fetch:

const inter = await fetch(
  new URL('./Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer());

return new ImageResponse(<JSX />, {
  ...size,
  fonts: [{ name: 'Inter', data: inter, weight: 700, style: 'normal' }],
});

Dacă ai nevoie doar de câteva variații fixe (nu per-slug), pune imaginea PNG statică direct în segment ca opengraph-image.png. Next.js va servi fișierul și va emite tag-ul corespunzător. Costul de runtime scade la zero, iar imaginea e cache-uită la CDN edge indefinit. Am comparat cele două abordări pe infrastructura de streaming discutată în ghidul de Suspense. Versiunea statică e cu ~180ms mai rapidă la primul crawl.

Cum adaugi sitemap.ts și robots.ts în App Router?

Fișierele speciale sitemap.ts și robots.ts din root-ul app/ generează automat /sitemap.xml și /robots.txt. Nu scrii XML sau text-plain manual. Exporți un default function care întoarce o structură TypeScript-tipată, iar framework-ul serializează corect. Un sitemap real pentru un site cu blog și pagini de produs:

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

export const revalidate = 86400; // regenerează zilnic

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

  // rute statice cunoscute la build
  const staticRoutes: MetadataRoute.Sitemap = [
    { url: `${base}/`, changeFrequency: 'daily', priority: 1.0 },
    { url: `${base}/despre`, changeFrequency: 'monthly', priority: 0.6 },
    { url: `${base}/contact`, changeFrequency: 'yearly', priority: 0.4 },
  ];

  // rute dinamice (articole)
  const articles = await fetch('https://api.example.com/articles', {
    next: { revalidate: 3600 },
  }).then((r) => r.json());

  const articleRoutes: MetadataRoute.Sitemap = articles.map(
    (a: { slug: string; updatedAt: string }) => ({
      url: `${base}/blog/${a.slug}`,
      lastModified: new Date(a.updatedAt),
      changeFrequency: 'weekly' as const,
      priority: 0.7,
    })
  );

  return [...staticRoutes, ...articleRoutes];
}

Pentru sitemap-uri cu peste 50.000 de URL-uri (limita XML per fișier conform specificației sitemaps.org), Next.js suportă sitemap-uri multiple prin generateSitemaps. Exportă o funcție care întoarce un array de { id } și primești id ca parametru în default function. Pentru robots.ts, sintaxa e mai simplă:

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

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

Verifică-ți întotdeauna robots.txt cu Google Search Console robots tester înainte de deploy. Onest, am întâlnit o echipă unde un disallow: '/blog/*' ambiguu ștersese 200.000 de indexări în două săptămâni, iar remediul (schimbarea la /blog/private/) a durat încă șase săptămâni până la re-crawl complet.

Structured Data (JSON-LD) și rich snippets

Metadata API nu emite JSON-LD automat, deci pentru rich results (recipe cards, article cards, breadcrumbs, FAQPage) trebuie să injectezi manual un <script type="application/ld+json"> într-un layout sau componentă. Recomandarea Google în documentația oficială Search Central e să pui script-ul în <head> sau <body>. Locul nu contează, dar conținutul trebuie să corespundă exact cu ce e vizibil în pagină:

// app/blog/[slug]/page.tsx
export default async function Page({ params }: { params: Params }) {
  const { slug } = await params;
  const article = await getArticle(slug);
  if (!article) notFound();

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

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

Canonical URLs, alternates și i18n

Câmpul alternates gestionează canonical URLs și tag-urile hreflang pentru site-uri multilingve. Regula de aur: setează un canonical absolut pe fiecare pagină pentru a evita duplicate content, în special când pagina e accesibilă cu și fără query params (?utm_source=...) sau cu variații de trailing slash. Pattern-ul recomandat pentru un site cu i18n prin sub-path (/ro, /en, /de):

// app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale, slug } = await params;
  const article = await getArticle(slug, locale);

  return {
    title: article.title,
    alternates: {
      canonical: `/${locale}/blog/${slug}`,
      languages: {
        ro: `/ro/blog/${article.translations.ro}`,
        en: `/en/blog/${article.translations.en}`,
        de: `/de/blog/${article.translations.de}`,
        'x-default': `/en/blog/${article.translations.en}`,
      },
    },
  };
}

Combinat cu metadataBase setat în root layout, toate URL-urile relative devin absolute în output-ul HTML. Dacă combini asta cu pattern-ul de proxy.ts pentru autentificare cu Auth.js v5 pe rute protejate, adaugă robots: { index: false } pe orice segment în spatele auth-ului. Altfel Google va indexa pagini de login și profile private.

Erori comune și cum le eviți

După trei ani în care am făcut audit de metadata pe zeci de codebase-uri, aceleași greșeli apar la fiecare a doua migrare. Iată cele mai frecvente cinci și cum le eviți:

1. Setarea title în useEffect

Anti-pattern moștenit din Pages Router. Botul Twitter nu execută JavaScript, deci va vedea titlul default din layout, nu cel setat client-side. Șterge tot document.title = ... și folosește generateMetadata.

2. Uitarea metadataBase în layout root

Fără metadataBase, URL-urile relative din openGraph.images devin invalide (browserul le rezolvă corect, dar crawler-ele care nu au context de host, nu). Setează întotdeauna metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!).

3. Metadata dinamică care crește build time-ul

Când generateStaticParams întoarce 10.000 de slug-uri și fiecare cheamă generateMetadata care face un fetch de 200ms, build-ul durează ~30 de minute. Soluția: batch-uri paralele cu Promise.all pe request-ul din interior, plus React.cache între metadata și pagină. Detaliile complete sunt în ghidul despre generateStaticParams și ISR.

4. OG images care nu se actualizează

Facebook și LinkedIn cache-uiesc OG images agresiv (până la 30 de zile). Dacă schimbi imaginea, forțează re-crawl prin Facebook Sharing Debugger. Alternativ, adaugă un query param cu hash-ul conținutului: ?v=abc123.

5. Combinarea greșită între metadata și <title> hardcodat

Dacă ai și metadata.title, și un <title> în JSX-ul unei componente (moștenire dintr-o migrare parțială), Next.js emite ambele iar browserul îl păstrează pe ultimul, care rareori e cel dorit. Grep-uiește codebase-ul pentru <title> înainte de deploy.

Întrebări frecvente

Care este diferența între metadata statică și generateMetadata?

Metadata statică e un obiect literal exportat direct, evaluat o singură dată la build. generateMetadata e o funcție asincronă care primește params și searchParams, rulează la request sau build per rută și poate face fetch de date. Folosește static pentru pagini cu titluri fixe și dinamic pentru rute care depind de conținut din CMS sau bază de date.

Pot folosi next/head în App Router?

Nu. next/head e specific Pages Router. În App Router folosești exclusiv Metadata API. Dacă vezi codul acesta într-un proiect App Router, e o migrare incompletă și trebuie înlocuit cu export const metadata sau generateMetadata în segmentul corespunzător.

Cum funcționează moștenirea metadata în App Router?

Metadata e colectată de la root layout până la pagina curentă. Cheile primitive (title, description, robots) sunt suprascrise de segmentele copil. Obiectele imbricate precum openGraph și twitter nu sunt combinate, sunt înlocuite complet. Setează defaults în root și override doar unde e nevoie.

Cât timp durează ca Google să indexeze schimbări în metadata?

Depinde de crawl frequency: 24-72h pentru site-uri active, până la 2-3 săptămâni pentru pagini rare. Poți forța re-crawl prin Search Console cu URL Inspection și butonul "Request indexing". Verifică robots.txt și canonical să nu blocheze indexarea.

Ce dimensiuni trebuie să aibă imaginea Open Graph în 2026?

1200×630px pentru summary_large_image (Twitter, Facebook, LinkedIn, Slack). Format PNG sau JPG, sub 5MB. Twitter respinge silențios imagini peste 5MB și cele cu aspect ratio în afara intervalului 2:1 la 1:1. Folosește ImageResponse din next/og pentru variații dinamice per articol.

Despre Autor Priya Raghavan

Priya spent four years on Vercel's developer experience team, where she wrote a chunk of the Turbopack incremental cache layer and rewrote the official App Router migration codemods used by tens of thousands of teams. Before that, she shipped the checkout funnel rewrite at Shopify on Next.js 12, cutting LCP from 3.4s to 1.1s on mid-tier Android. These days she runs an independent consultancy helping Series B companies untangle Pages Router monoliths into RSC-first App Router architectures. She has opinions about Server Actions, mostly negative ones, and is slowly coming around on Partial Prerendering after benchmarking it against six production workloads. She writes about cache semantics, streaming SSR, and the parts of the React docs that quietly contradict the Next.js docs. Eight years in frontend, three of them spent yelling at hydration mismatches.