Skip to main content
Skill web app · Pelajaran 3 dari 11 · 14 menit

Tutorial SEO Metadata Next.js: generateMetadata sampai JSON-LD

Tutorial SEO metadata Next.js dari situs dua bahasa nyata: generateMetadata, canonical dan hreflang, Open Graph, sitemap.ts, robots.ts, dan JSON-LD.

Oleh Matthews WongDitinjau pada

Poin utama

Metadata SEO di Next.js adalah kumpulan tag dan file yang memberi tahu crawler apa itu sebuah halaman: title dan description, URL canonical, alternate hreflang, kartu Open Graph dan Twitter, sitemap, aturan robots, dan JSON-LD. Di App Router semuanya dideklarasikan dari kode server, sehingga ikut dalam HTML yang pertama diterima crawler.

Setelah pelajaran ini, Anda bisa

  • Menulis generateMetadata dengan title, canonical, dan alternate bahasa yang benar.
  • Membuat sitemap dan file robots dari kode, serta tahu apa yang diabaikan Google.
  • Menghindari jebakan title template dan penggabungan openGraph yang gagal tanpa suara.
Lihat cara kerjanya

Ikuti diagramnya langkah demi langkah

Tekan Putar, atau melangkah dengan panah. Setiap langkah juga ditulis di bawah diagram.

Apa yang dibaca crawler dari satu halaman dua bahasa

Crawler membaca robots.txt dan sitemap, meminta sebuah halaman, lalu menerima tag head dari generateMetadata ditambah script JSON-LD dari server component, semuanya dalam satu respons HTML.

Langkah 1 dari 6. Dua file memberi tahu crawler ke mana harus pergi, satu fungsi menulis tag head, dan satu server component menulis JSON-LD. Keempatnya berjalan di server.

Tombol: ← → langkah · Home / End · K putar atau jeda

  1. Dua file memberi tahu crawler ke mana harus pergi, satu fungsi menulis tag head, dan satu server component menulis JSON-LD. Keempatnya berjalan di server.

  2. Crawler membaca robots.txt yang dihasilkan robots.ts. File ini menyebut path mana yang boleh di-crawl dan letak sitemap. Ia mengatur crawling, bukan indexing.

    export default function robots(): MetadataRoute.RobotsTebak langkah berikutnya Sebuah path di-disallow di robots.txt tetapi ada situs lain yang menautnya. Bisakah tetap diindeks? (Tidak, disallow menghapusnya · Ya, kalau ditaut dari tempat lain)
  3. sitemap.ts mengembalikan satu entri per URL, dengan versi bahasa lainnya di alternates.languages. Crawler jadi tahu bahwa /en/ dan /id/ saling berpasangan.

    alternates.languages = buildLanguageAlternates(path)
  4. Crawler meminta satu halaman. Next.js menjalankan generateMetadata untuk route itu sebelum halaman dikirim, jadi tag-nya ikut dalam HTML pertama tersebut.

    export async function generateMetadata(props)Tebak langkah berikutnya Pada halaman /id/, URL mana yang harus disebut tag canonical? (Halaman /en/, versi aslinya · URL /id/ miliknya sendiri)
  5. Hasilnya adalah title, description, canonical yang menunjuk ke URL ini sendiri, satu link hreflang per bahasa plus x-default, serta tag Open Graph dan Twitter.

    alternates.canonical = canonical alternates.languages = ...
  6. Server component halaman merender tag script bertipe application/ld+json dari data biasa. Ia berada dalam HTML yang sama, tanpa perlu JavaScript di klien untuk membacanya.

    script type=application/ld+json

Bagaimana cara kerja generateMetadata di Next.js App Router?

Anda mendeskripsikan head sebuah halaman dari layout.tsx atau page.tsx dengan salah satu dari dua cara: mengekspor objek metadata konstan kalau nilainya tetap, atau fungsi async generateMetadata kalau nilainya bergantung pada params route atau data. Keduanya tidak boleh diekspor bersamaan dari segmen yang sama, dan keduanya hanya berjalan di Server Component, karena metadata harus diselesaikan di server sebelum halaman dikirim. Kalau komponen klien butuh hook, biarkan page tetap Server Component dan pindahkan bagian interaktifnya ke file sendiri.

Baca selengkapnya (2)

Kalau halaman bisa di-prerender dan generateMetadata tidak menambah perilaku dinamis, tag-nya sudah ada di HTML awal. Kalau tidak, Next.js dapat men-stream-nya setelah UI pertama; untuk bot yang tidak bisa menjalankan JavaScript, render tetap ditahan sehingga tag tetap ada di head. Setiap fetch di dalam generateMetadata di-memoize antara generateMetadata, layout, dan page, jadi meminta data yang sama dua kali hanya memakan satu request.

Metadata digabung dari root layout sampai ke page, dan penggabungannya dangkal (shallow). Field bersarang seperti openGraph yang didefinisikan di segmen berikutnya mengganti seluruh objek openGraph dari segmen sebelumnya, bukan menambahinya. Karena itu route yang mengatur openGraph sendiri harus mengulang title, description, dan gambar yang masih diinginkan.

Untuk apa URL canonical dan alternate hreflang?

Link canonical menyebut URL yang diutamakan untuk sebuah halaman. Google menganggap elemen rel=canonical sebagai sinyal kuat dan entri sitemap sebagai sinyal lemah, menyarankan URL absolut, dan menyarankan canonical yang menunjuk ke dirinya sendiri pada halaman canonical. Itulah aturan di balik setup situs dua bahasa: setiap versi bahasa menyebut dirinya sendiri sebagai canonical, bukan menunjuk ke bahasa lain.

Baca selengkapnya (2)

Link hreflang menghubungkan versi-versi bahasa. Aturan Google ketat: anotasinya harus dua arah, jadi kalau dua halaman tidak saling menunjuk, tag-nya diabaikan; setiap versi harus mencantumkan dirinya sendiri selain versi lain; dan URL harus lengkap (fully qualified). Nilai khusus x-default menandai halaman yang ditampilkan ketika tidak ada bahasa terdaftar yang cocok dengan pengunjung. Kode bahasa mengikuti ISO 639-1, dengan kode wilayah ISO 3166-1 alpha-2 yang opsional, misalnya en-GB. Alternate bisa dideklarasikan di tag link HTML, header HTTP, atau sitemap; Google menganggap ketiganya setara.

Di Next.js keduanya berada di bawah alternates: canonical untuk satu URL, dan languages untuk peta kode bahasa ke URL. Path relatif diselesaikan terhadap metadataBase, dan memakai path relatif tanpa metadataBase menyebabkan error build. Situs ini menghindari masalah itu dengan membangun setiap URL sebagai string absolut di lib/seo.ts, sehingga helper yang sama memberi makan tag dan sitemap.

Bagaimana title template, Open Graph, dan tag Twitter saling melengkapi?

title menerima string atau objek. title.template (dengan title.default yang wajib) menambahkan prefiks atau sufiks pada title yang diatur di segmen anak, tetapi tidak pada segmen yang mendefinisikannya. title.absolute mengatur title yang mengabaikan template dari parent. Pada layout, absolute juga menjadi title bawaan untuk anak yang tidak mengatur title. Jebakannya ada di contoh nyata di bawah: layout yang hanya mengatur title absolute membuat anak-anaknya tidak punya template sama sekali.

Baca selengkapnya (1)

Open Graph adalah protokol terpisah yang dibaca oleh pratinjau tautan. Empat properti wajibnya adalah og:title, og:type, og:image, dan og:url, sedangkan og:description, og:locale, og:locale:alternate, dan og:site_name bersifat opsional. Next.js membangunnya dari objek openGraph, dan dokumentasi menyebut URL gambar harus absolut. Objek twitter bekerja serupa, dan card: summary_large_image meminta pratinjau besar. Title-nya boleh berbeda dari title halaman: kartu sosial tidak butuh ekor kata kunci.

Bagaimana sitemap.ts, robots.ts, dan JSON-LD bekerja di Next.js?

app/sitemap.ts mengekspor fungsi yang mengembalikan array entri dengan url, lastModified, changeFrequency, priority, images, videos, dan sejak Next.js 14.2 juga alternates.languages. app/robots.ts mengembalikan rules dengan userAgent, allow, dan disallow plus sitemap opsional. Keduanya adalah Route Handler khusus yang di-cache secara default kecuali memakai API waktu-request. Panduan sitemap Google menambahkan batasannya: 50 MB atau 50.000 URL per file, hanya URL absolut, dan nilai priority serta changefreq diabaikan. Google memakai lastmod hanya jika konsisten dan dapat diverifikasi akurat, jadi tanggal yang selalu hari ini tidak memberinya apa pun untuk dipakai.

Baca selengkapnya (2)

robots.txt mengatur lalu lintas crawler. Google menegaskan bahwa ia bukan cara menyembunyikan halaman dari pencarian: halaman yang di-disallow tetap bisa diindeks kalau situs lain menautnya, dan noindex atau password adalah cara menjauhkannya.

Untuk data terstruktur, panduan Next.js menyarankan merender JSON-LD sebagai tag script di layout atau page, memakai elemen script biasa dan bukan next/script, karena ia berupa data, bukan kode yang dieksekusi. Contohnya mengganti karakter kurang-dari dengan escape unicode-nya, karena JSON.stringify tidak membersihkan string yang bisa keluar dari script. Google menyarankan JSON-LD bila setup Anda memungkinkan dan melarang menandai informasi yang tidak terlihat oleh pengguna. Rich Results Test dan Schema Markup Validator dapat memeriksa hasilnya.

Cara situs ini melakukannya

Konfigurasi nyata di balik pelajaran ini

Kutipan dari repositori yang membangun dan men-deploy situs ini, apa adanya.

lib/seo.ts:18-29
export function buildCanonical(locale: SiteLocale, path = ""): string {
  const url = `${BASE_URL}/${locale}${path}`;
  return url.endsWith("/") ? url : `${url}/`;
}

export function buildLanguageAlternates(path = ""): Record<string, string> {
  return {
    en: buildCanonical("en", path),
    id: buildCanonical("id", path),
    "x-default": buildCanonical("en", path),
  };
}

Semua URL di situs ini dibangun di sini, dan tidak di tempat lain. buildCanonical selalu mengembalikan alamat absolut dengan garis miring di akhir, sesuai pengaturan trailingSlash situs. buildLanguageAlternates mengembalikan en, id, dan x-default, dan x-default menunjuk ke bahasa Inggris.

app/[locale]/(pages)/blog/[slug]/layout.tsx:228-239
  return {
    // Absolute: post titles are written to sit inside Google's ~63-character
    // budget, and the root layout's "%s | Matthews Wong" template would add 16
    // more and truncate every one of them. That template used to be suppressed
    // by the plain-string title in blog/layout.tsx, which no longer sets one.
    title: { absolute: title },
    description,
    keywords: blog.keywords,
    alternates: {
      canonical,
      languages: buildLanguageAlternates(routePath),
    },

Metadata sebuah post. Canonical-nya adalah URL halaman itu sendiri dalam bahasanya sendiri, dan peta bahasa berasal dari helper yang sama. Title-nya absolute karena sudah ditulis agar muat sekitar 63 karakter; template di root akan menambah ekor merek dan memotongnya.

app/[locale]/layout.tsx:91-97
    title: {
      default: seo.title,
      // Kept as a safety net for any route that sets a plain-string title.
      // Every page with length-budgeted copy sets `title: { absolute }`, which
      // bypasses this so the brand tail is not appended twice.
      template: "%s | Matthews Wong",
    },

Root layout masih menyimpan template, tetapi komentarnya sendiri menyebutnya jaring pengaman. Konvensi situs ini adalah setiap halaman memakai title.absolute dengan ekor merek yang sudah termasuk, jadi template jarang terpakai.

app/[locale]/(pages)/education/layout.tsx:28-30
    // Absolute: the title already carries the brand tail, so the root
    // layout's "%s | Matthews Wong" template would append a second one.
    title: { absolute: seo.title },

Layout ini hanya mengatur title absolute. Di Next.js 15.5.7, template yang diteruskan ke segmen anak berasal dari segmen terdekat yang mendeklarasikannya, jadi semua yang berada di bawah /education kehilangan template root. Halaman thesis di bawahnya menyebut hal ini di komentar dan mengatur title absolute sendiri.

app/sitemap.ts:237-245
        url: buildCanonical(locale, path),
        lastModified: routeLastModified[path],
        priority: route.priority,
        changeFrequency: route.changeFrequency,
        images: routeImages[path],
        videos: routeVideos[path]?.(locale),
        alternates: {
          languages: buildLanguageAlternates(path),
        },

Satu entri sitemap. URL, alternate bahasa, dan tanggal modifikasi terakhir semuanya berasal dari data, dan alternate memakai ulang helper dari lib/seo.ts, sehingga sitemap dan tag head tidak bisa berbeda. Google mengabaikan baris priority dan changeFrequency di sini; keduanya tidak berbahaya tetapi tidak bekerja.

app/robots.ts:7-23
const ALLOW = ["/", "/api/v1/"];
const DISALLOW = ["/private/", "/admin/", "/api/"];

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: ALLOW, disallow: DISALLOW },
      ...AI_CRAWLERS.map((userAgent) => ({
        userAgent,
        allow: ALLOW,
        disallow: DISALLOW,
      })),
    ],
    host: BASE_URL,
    sitemap: `${BASE_URL}/sitemap.xml`,
  };
}

robots.ts menyusun aturannya dari dua array bersama, sehingga crawler umum dan setiap crawler AI bernama mendapat kebijakan yang sama. Baris sitemap mengarahkan crawler ke satu daftar halaman yang nyata.

app/[locale]/(pages)/blog/[slug]/layout.tsx:482-487
      {articleSchema && (
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
        />
      )}

JSON-LD dari server component, seperti yang disarankan dokumentasi: tag script biasa yang diberi data polos. Yang akan saya ubah: dokumentasi membersihkan output dengan replace karakter kurang-dari, dan kode ini tidak. Datanya milik kita sendiri, tetapi kebiasaan itu hanya butuh satu pemanggilan.

Yang sering rusak

Kesalahan yang benar-benar membuang waktu

  • Title absolute di atas anak dengan title string biasa

    Layout dengan title absolute tanpa template membuat anak-anaknya tidak punya template. Halaman anak yang mengatur string biasa dan mengandalkan ekor merek dari root akan terbit tanpa ekor itu. Tidak ada yang gagal: tsc dan build lolos, hanya title hasil render yang berbeda. Pilih satu mode per subtree, dan periksa HTML hasil build, bukan source-nya.

  • openGraph bersarang yang menghapus milik parent

    Metadata digabung secara dangkal, jadi halaman yang mengatur openGraph.title membuang description dan gambar yang dideklarasikan layout. Kartu bagikan kehilangan gambarnya. Sebarkan (spread) objek bersama ke openGraph tiap segmen, atau atur seluruh objeknya sekali saja.

  • hreflang yang hanya menunjuk satu arah

    Kalau halaman Inggris mencantumkan halaman Indonesia tetapi halaman Indonesia tidak mencantumkan dirinya dan halaman Inggris balik, Google mengabaikan tag tersebut. Hasilkan kedua sisi dari satu fungsi, seperti lib/seo.ts, dan jaga URL tetap absolut.

  • URL gambar relatif tanpa metadataBase

    URL relatif di field metadata membutuhkan metadataBase, kalau tidak build gagal. Bahkan dengan itu, contoh Open Graph di dokumentasi menandai URL gambar harus absolut, jadi situs ini mengubah setiap gambar dengan toAbsoluteUrl sebelum sampai ke tag.

  • Memblokir di robots.txt untuk menyembunyikan halaman

    Disallow menghentikan crawling, bukan indexing. URL yang diblokir tetapi ditaut di tempat lain tetap bisa muncul di hasil pencarian. Pakai noindex atau autentikasi untuk halaman yang harus tetap tersembunyi.

Tiga pengaturan title dan efeknya pada segmen anak
title: stringDibungkus template milik parentTitle bawaan untuk anak yang tidak mengatur
title.template + defaultTidak berlaku untuk dirinya sendiriMembungkus setiap title anak
title.absoluteMengabaikan template milik parentMenjadi title bawaan; tidak ada template yang diteruskan
Coba sendiri

Lakukan di komputer Anda sendiri

Anda butuh Node.js dan proyek Next.js 15 App Router. Buat metadata untuk satu halaman dua bahasa, lalu baca HTML hasil build, bukan hanya percaya pada source-nya.

  1. Buat helper yang membangun URL canonical dan alternate yang absolut, lalu atur metadataBase di root layout dengan title template dan default.

    const BASE_URL = "https://example.com";
    export const canonical = (locale: string, path = "") => `${BASE_URL}/${locale}${path}/`;
    export const languages = (path = "") => ({
      en: canonical("en", path),
      id: canonical("id", path),
      "x-default": canonical("en", path),
    });
  2. Di sebuah page, ekspor generateMetadata. Kembalikan title absolute, description, alternates, dan objek openGraph yang mengulang semua yang ingin dibagikan.

    export async function generateMetadata({ params }) {
      const { locale } = await params;
      const url = canonical(locale, "/guide");
      return {
        title: { absolute: "Guide | Example" },
        description: "A guide.",
        alternates: { canonical: url, languages: languages("/guide") },
        openGraph: { title: "Guide", url, type: "article", images: ["https://example.com/og.png"] },
      };
    }
  3. Tambahkan app/sitemap.ts dan app/robots.ts. Pakai ulang helper untuk alternate, dan arahkan robots ke sitemap.

    export default function sitemap() {
      return [{ url: canonical("en", "/guide"), lastModified: "2026-10-17", alternates: { languages: languages("/guide") } }];
    }
  4. Render JSON-LD di komponen page sebagai tag script biasa, dan escape karakter kurang-dari pada outputnya.

    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
    />
  5. Build dan jalankan aplikasi, lalu baca head yang sebenarnya dengan curl. Periksa title, canonical, dan link hreflang pada halaman /id/. Lalu coba title string di bawah layout yang hanya mengatur title absolute dan lihat ekor merek menghilang.

    npm run build && npm start
    curl -s http://localhost:3000/id/guide/ | grep -o '<title>[^<]*</title>\|<link rel="[a-z]*"[^>]*>'
Kuis singkat

Tiga pertanyaan sebelum lanjut

Pilih jawaban untuk melihat kenapa benar atau salah. Tidak ada yang disimpan atau dinilai, jadi tebak saja dengan bebas.

  1. 1.Sebuah layout hanya mengatur title: { absolute: 'Docs | Acme' }. Halaman anak mengatur title: 'Setup', dan root layout punya template '%s | Acme'. Apa yang ditampilkan title anak?
  2. 2.Layout mengatur openGraph dengan title, description, dan gambar. Sebuah page mengatur openGraph hanya dengan title. Apa yang dimiliki kartu bagikan halaman itu?
  3. 3.Mana dari berikut ini yang menurut Google diabaikan dalam sitemap?
Pikirkan dulu

Jawab dalam hati, lalu cek

  1. 1.Mengapa canonical halaman /id/ harus menunjuk ke URL /id/ dan bukan ke /en/?Lihat jawabannya

    Jawaban:Canonical menyebut URL mana yang menjadi versi utama halaman ini. Menunjuknya ke halaman Inggris berarti memberi tahu Google bahwa halaman Indonesia adalah duplikat. Setiap bahasa menyebut dirinya sendiri, dan hreflang yang menghubungkan keduanya.

  2. 2.Mengapa memakai tag script biasa dan bukan next/script untuk JSON-LD?Lihat jawabannya

    Jawaban:next/script dibuat untuk memuat dan menjalankan JavaScript. JSON-LD adalah data, bukan kode yang dieksekusi, jadi dokumentasi menyebut tag script biasa sebagai pilihan yang tepat. Escape karakter kurang-dari, karena JSON.stringify tidak membersihkannya.

  3. 3.File atau tag mana yang dipakai untuk menjauhkan halaman dari Google, dan mana yang tidak?Lihat jawabannya

    Jawaban:Pakai noindex atau lindungi halaman dengan password. Disallow di robots.txt hanya memblokir crawling, dan URL-nya tetap bisa diindeks kalau situs lain menautnya.

Pelajari lebih dalam

Bahasan mendalam terkait di situs ini