Next.js Metadata API: Dinamički SEO, Open Graph slike i strukturirani podaci

Praktičan vodič kroz Next.js 15 Metadata API u App Routeru: generateMetadata, dinamičke Open Graph slike, JSON-LD strukturirani podaci i kanonski URL-ovi — s primjerima koje možete odmah iskoristiti.

Next.js 15 Metadata API: SEO Vodič 2026

SEO u Next.js 15 više nije nešto što naknadno "zalijepite" na kraj projekta — i, iskreno, dobro je da nije. App Router donosi Metadata API koji integrira meta tagove, Open Graph slike, kanonske URL-ove i strukturirane podatke izravno u file-system route. Postavite li generateMetadata kako treba, Google dobiva točno ono što očekuje. Bez ručnog ubacivanja tagova u <head>, bez vanjskih biblioteka i, ono najbolje, bez onih iznenađenja kad shvatite da pola Twitter kartica nikad nije imalo preview sliku.

U ovom vodiču pokazat ću kako konfigurirati dinamičke meta tagove, generirati Open Graph slike u runtimeu, dodati JSON-LD strukturirane podatke i — što je možda i najvažnije — izbjeći greške koje uzrokuju duple title tagove ili nedostajuće preview kartice na društvenim mrežama.

Statički vs. dinamički metadata u App Routeru

Next.js App Router podržava dva načina definiranja metadata: statički objekt i dinamička funkcija. Oba se exportiraju iz layout.tsx ili page.tsx datoteka, a Next ih kombinira kroz hijerarhiju ruta. Što je u praksi vrlo zgodno jer ne morate ponavljati istu konfiguraciju na svakoj stranici.

Statički metadata objekt

Za stranice gdje su naslov i opis poznati već u build vremenu, dovoljan je jednostavan export:

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

export const metadata: Metadata = {
  title: 'Blog | Next.js Launchpad',
  description: 'Vodiči o Next.js 15, App Routeru i Vercel deploymentu.',
  keywords: ['Next.js', 'App Router', 'React Server Components'],
  openGraph: {
    title: 'Blog | Next.js Launchpad',
    description: 'Vodiči o Next.js 15.',
    url: 'https://example.com/blog',
    siteName: 'Next.js Launchpad',
    locale: 'hr_HR',
    type: 'website',
  },
}

export default function BlogPage() {
  return <main>...</main>
}

Dinamički metadata kroz generateMetadata

Za dinamičke rute (recimo /blog/[slug]), gdje se sadržaj učitava iz baze ili CMS-a, koristi se generateMetadata. Ta funkcija se izvršava na serveru i može biti async — što znači da slobodno fetcham podatke prije nego što vratim metadata objekt:

// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'

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

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

  if (!post) {
    return { title: 'Članak nije pronađen' }
  }

  const previousImages = (await parent).openGraph?.images || []

  return {
    title: post.title,
    description: post.excerpt,
    alternates: {
      canonical: `https://example.com/blog/${slug}`,
    },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author],
      images: [post.coverImage, ...previousImages],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

Važna napomena: u Next.js 15 params i searchParams su asinkroni. Morate ih await-ati. Ja sam se na ovome opekao prvi put kad sam migrirao projekt s 14 na 15 — TypeScript je vrištao, runtime je ispisivao upozorenja, a ja sam pola sata tražio "krivu" konfiguraciju koja zapravo nije bila kriva.

Naslijeđivanje i kompozicija metadata

Next.js gradi metadata od root layouta prema listu. Svaki segment može nadjačati ili dopuniti polja iz nadređenog. Ovo je ključno za konzistentnost — definirajte zajedničke vrijednosti jednom, u app/layout.tsx, a po stranicama nadjačajte samo ono što se razlikuje.

Template za naslove

Najčistija praksa? Iskoristite title.template u root layoutu da automatski dodate brand suffix:

// app/layout.tsx
export const metadata: Metadata = {
  title: {
    template: '%s | Next.js Launchpad',
    default: 'Next.js Launchpad — Vodiči za moderni React',
  },
  metadataBase: new URL('https://example.com'),
}

Sad svaka podstranica koja postavi title: 'Tutorial' automatski dobiva "Tutorial | Next.js Launchpad" u <title> tagu. A ako želite stranicu bez prefiksa (tipično landing page), koristite title: { absolute: 'Custom Title' }.

metadataBase i relativni URL-ovi

Postavljanjem metadataBase na URL produkcijske domene dobivate jednu malu, ali jako korisnu stvar: slobodno koristite relativne putanje za Open Graph slike — images: ['/og.png'] umjesto da svaki put pišete pune URL-ove. Bez toga, Next.js ispisuje upozorenje i u nekim slučajevima generira neispravne preview linkove. Ne baš zabavno otkrivanje na Slacku.

Open Graph slike generirane u runtimeu

Jedna od stvarno moćnih značajki Metadata API-ja je generiranje dinamičkih OG slika pomoću ImageResponse iz next/og. Ne treba vam Photoshop, ne treba vam vanjski servis — pišete običan JSX i CSS koji se izvršava na edge runtimeu.

Konvencija opengraph-image.tsx

Stvar je smiješno jednostavna: dodajte datoteku opengraph-image.tsx u bilo koji segment rute, i Next.js će automatski generirati sliku te postaviti odgovarajuće og:image tagove.

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

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

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

  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 64,
          background: 'linear-gradient(135deg, #0f172a, #1e293b)',
          color: 'white',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          padding: 80,
        }}
      >
        <div style={{ fontSize: 28, opacity: 0.7 }}>Next.js Launchpad</div>
        <div style={{ marginTop: 24, lineHeight: 1.2 }}>{post.title}</div>
      </div>
    ),
    { ...size }
  )
}

Slika je statički generirana tijekom builda za poznate rute, ali možete je servirati i dinamički ako koristite generateImageMetadata za varijacije po jeziku ili temi (npr. tamna vs. svijetla verzija).

Twitter (X) kartice

Twitter koristi vlastite tagove, ali možete koristiti istu sliku — bilo kroz konvenciju twitter-image.tsx, bilo posebnim definiranjem u generateMetadata. Za summary_large_image format omjer je 2:1, pa je 1200×630 px gotovo univerzalno siguran zadani izbor.

Strukturirani podaci (JSON-LD) za rich snippete

Google koristi strukturirane podatke u formatu schema.org/JSON-LD za prikaz rich rezultata u SERP-u: zvjezdice za recenzije, breadcrumb, autora članka, vrijeme čitanja. Metadata API ne renderira JSON-LD direktno, no možete ga jednostavno ubaciti kao <script> tag unutar komponente:

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

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Person',
      name: post.author,
      url: `https://example.com/autori/${post.authorSlug}`,
    },
    publisher: {
      '@type': 'Organization',
      name: 'Next.js Launchpad',
      logo: {
        '@type': 'ImageObject',
        url: 'https://example.com/logo.png',
      },
    },
  }

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

Mali, ali važan savjet: validirajte rezultat kroz Google Rich Results Test prije nego što išta deployate u produkciju. Pogrešne sheme znaju potpuno zbuniti Google, koji onda ignorira sve strukturirane podatke na stranici. Vidio sam to nekoliko puta — frustrirajuće je dijagnosticirati, a još više popraviti retroaktivno.

Kanonski URL-ovi i internacionalizacija

Za sajtove s više jezika ili duplikatima sadržaja (klasičan primjer: /blog/post i /blog/post?utm_source=...), kanonski URL govori Googleu koja je primarna verzija:

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

  return {
    alternates: {
      canonical: `/${locale}/blog/${slug}`,
      languages: {
        'en-US': `/en/blog/${slug}`,
        'hr-HR': `/hr/blog/${slug}`,
        'x-default': `/en/blog/${slug}`,
      },
    },
  }
}

Polje languages generira <link rel="alternate" hreflang="..."> tagove. Google koristi x-default kao fallback — onda kad korisnikov jezik ne odgovara nijednoj eksplicitno definiranoj verziji.

robots, viewport i ostali tagovi koje ljudi zaborave

Metadata API podržava i one tagove koje obično zaboravimo dok ne dođe SEO audit:

  • robots — kontrola indeksiranja po stranici (index: false za noindex)
  • viewport — od Next.js 14 izdvojen u zasebni export
  • verification — Google Search Console, Yandex, Bing
  • themeColor — boja toolbar-a na mobilnim uređajima (također unutar viewport exporta)
// app/layout.tsx
import type { Viewport } from 'next'

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

export const metadata: Metadata = {
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
  verification: {
    google: 'vas-google-verification-kod',
  },
}

Najčešće greške i kako ih izbjeći

  1. Dupli title tagovi: ako ručno dodate <title> u JSX uz Metadata API, dobit ćete dva taga. Uvijek ide samo jedan ili drugi pristup — preporuka: Metadata API.
  2. Nedostaje metadataBase: Open Graph slike s relativnim putanjama neće raditi na društvenim mrežama. Postavite metadataBase u root layoutu i mirno spavajte.
  3. generateMetadata u Client Componentu: funkcija mora biti exportirana iz Server Component stranice ili layouta. U Client Componentima jednostavno ne radi.
  4. Sinkroni params u Next.js 15: ako ne await-ate params, dobit ćete TypeScript grešku i runtime upozorenje (a ako ignorirate oboje, dobit ćete i tihu produkcijsku grešku — tipičan jackpot).
  5. OG slika prevelika: Facebook i LinkedIn imaju limit od oko 8 MB. ImageResponse generira PNG-ove koji su obično 50–200 KB, dakle sigurno ispod limita.

Testiranje i debug

Prije nego pošaljete promjene u produkciju, prođite kroz ovu kratku checklistu:

  • View source u browseru — provjerite da generirani <head> sadrži očekivane tagove
  • Facebook Sharing Debugger — testira OG tagove i refresha cache
  • Twitter Card Validator — preview kartice prije objave
  • Google Rich Results Test — validira JSON-LD i daje predviđanje SERP prikaza
  • LinkedIn Post Inspector — LinkedIn agresivno keširra OG slike, ovo ih osvježi (vjerujte mi, trebat će vam)

Često postavljana pitanja

Koja je razlika između metadata objekta i generateMetadata funkcije?

Statički metadata export koristi se za vrijednosti poznate u build vremenu. generateMetadata je async funkcija koja prima params i može dohvatiti podatke s API-ja, baze ili CMS-a. Koristite je za dinamičke rute poput /blog/[slug], /proizvod/[id] ili korisničke profile.

Mogu li koristiti Metadata API u Pages Routeru?

Ne. Metadata API je ekskluzivan za App Router. U Pages Routeru koristite next/head komponentu ili biblioteke poput next-seo. Ako migrirate s Pages na App Router, Metadata API zamijenit će skoro sve što ste prije radili kroz <Head>.

Hoće li Google indeksirati dinamičke Open Graph slike?

Da, ali samo ako su dostupne na javnoj URL putanji u trenutku crawlanja. opengraph-image.tsx generira statičke slike za sve rute poznate u build vremenu (kroz generateStaticParams). Za on-demand rute koristite ISR ili generateImageMetadata kako bi slike bile dostupne na zahtjev.

Kako dodati Open Graph sliku samo za određene rute?

Smjestite opengraph-image.tsx u taj segment rute. Konvencija prati hijerarhiju datoteka — slika definirana u app/blog/[slug]/opengraph-image.tsx primjenjuje se samo na pojedinačne blog postove, dok app/opengraph-image.png u rootu služi kao fallback za sve ostale rute.

Mogu li animirati ImageResponse za GIF ili video?

Ne. ImageResponse generira statičke PNG/JPEG slike — što je, realno, sasvim dovoljno za Open Graph i Twitter kartice, koje ionako ne podržavaju animacije u preview kontekstu. Ako baš trebate video preview, koristite og:video tag s linkom na MP4 datoteku hostiranu negdje drugdje.

Zaključak

Next.js Metadata API praktički uklanja potrebu za bibliotekama poput next-seo i daje fino-zrnatu kontrolu nad SEO signalima u svakoj točki rute. Najbitnije pravilo, ako pamtite samo jedno iz cijelog ovog vodiča: definirajte metadataBase i title.template u root layoutu, koristite generateMetadata za sve dinamičke rute, i obavezno validirajte rezultat kroz Rich Results Test prije produkcije. Uz dinamičke OG slike i JSON-LD strukturirane podatke, vaši Next.js sajtovi 2026. imat će sve što je potrebno za pojavljivanje u Google rich rezultatima i lijepo izgledane preview kartice na društvenim mrežama.

O Autoru Tomasz Wieczorek

Tomasz is a senior frontend engineer with eleven years of experience, currently a tech lead at a Berlin-based fintech where he migrated 180 routes from CRA to Next.js 14 App Router over nine months without a single user-facing outage. Before that, four years at Klarna building the merchant dashboard in Next.js, and two years at a small agency in Krakow shipping marketing sites for European retailers. He maintains two small open-source libraries in the React ecosystem, one for typed environment variables and one for a Suspense-friendly data fetching wrapper that predates the React 19 use() hook. He prefers Drizzle over Prisma, NextAuth over Clerk for self-hosted setups, and has strong feelings about putting business logic in Server Components. He writes the kind of tutorials that show the broken intermediate state, not just the working final code.