Ringkasan Utama
Cara membuat SEO metadata di Next.js App Router: atur metadataBase dan title template di root layout, lalu ekspor generateMetadata di tiap page berisi title, description, canonical, alternate hreflang, dan objek openGraph yang mengulang semua field yang ingin dibagikan. Tambahkan sitemap.ts dan robots.ts, lalu baca HTML hasil build untuk memastikannya.
Kamu membuka view source situs Next.js yang baru jadi, dan di head hanya ada satu title tanpa yang lain. Atau kamu menambahkan title di layout, lalu semua halaman menampilkan title yang sama. Atau kartu bagikan di WhatsApp judulnya benar tetapi gambarnya hilang. Pemula biasanya tersangkut di salah satu dari tiga hal ini, karena Metadata API-nya kecil tetapi aturan penggabungannya tidak kelihatan.
Panduan ini membangun metadata satu halaman dua bahasa dalam enam langkah. Setiap nama field dan bentuk kode saya cocokkan dengan dokumentasi Next.js dan Google Search Central, dan contohnya memakai example.com sebagai placeholder. Pola yang sama dipakai situs ini: satu helper di lib/seo.ts membangun semua URL canonical dan hreflang, dan tag head serta sitemap sama-sama membacanya, jadi keduanya tidak bisa berbeda.
Mau memahami konsepnya dulu, termasuk JSON-LD dan alasan tiap tag ada? Ikuti pelajaran gratis Tutorial SEO Metadata Next.js: generateMetadata sampai JSON-LD di jalur Web app skills. Post ini adalah praktik langkah demi langkahnya.
Yang kamu butuhkan
- Proyek Next.js dengan App Router (folder app), memakai TypeScript.
- Sebuah route dengan segmen locale, misalnya app/[locale]/guide/page.tsx. Kalau situsmu satu bahasa, abaikan bagian locale.
- Node.js dan terminal, untuk menjalankan build produksi dan membaca HTML yang sebenarnya dengan curl.
- Alamat situs aslimu. Panduan ini memakai https://example.com sebagai pengganti.
Belum menyiapkan dua bahasa? Baca dulu panduan next-intl untuk i18n Next.js, lalu kembali ke sini untuk metadatanya.
Langkah 1: Bangun URL absolut dari satu helper
Mulai dari bagian yang dipakai ulang oleh semua langkah berikutnya: helper yang mengembalikan URL canonical dan hreflang yang absolut. Taruh di lib/seo.ts. Menulis URL manual di tiap page adalah cara versi Inggris dan Indonesia akhirnya saling menunjuk dengan salah.
// lib/seo.ts: satu-satunya tempat URL dibangun
export const BASE_URL = "https://example.com";
export function buildCanonical(locale: "en" | "id", 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), // cadangan: halaman Inggris
};
}Lalu atur metadataBase dan title template satu kali, di root layout. Menurut dokumentasi, path relatif di field URL tanpa metadataBase menyebabkan error build, dan title.template berlaku untuk segmen anak tetapi tidak untuk segmen yang mendefinisikannya, karena itu title.default wajib ada bersamanya.
// app/[locale]/layout.tsx
import type { Metadata } from "next";
import { BASE_URL } from "@/lib/seo";
export const metadata: Metadata = {
metadataBase: new URL(BASE_URL),
title: {
default: "Example", // wajib ada kalau memakai template
template: "%s | Example", // berlaku untuk segmen ANAK, bukan layout ini
},
};Belum ada output yang perlu dilihat. Buktinya muncul di Langkah 6. Page yang mengatur title sebagai string biasa kini mendapat ekor merek dari template; page yang tidak mengatur title mendapat title.default.
Langkah 2: Ekspor generateMetadata dari page
Pakai objek metadata tetap kalau nilainya tidak berubah, dan fungsi async generateMetadata kalau nilainya bergantung pada params route atau data. Di Next.js 15, params berupa Promise, jadi kamu await. Keduanya hanya bekerja di Server Component, dan tidak boleh diekspor bersamaan dari segmen yang sama.
Jangan menambahkan use client pada page yang mengekspor metadata. Biarkan page tetap Server Component dan pindahkan bagian interaktifnya ke file sendiri, seperti saran dokumentasi.
// Wrong: use client + metadata di file yang sama. Metadata hanya jalan di Server Component.
"use client";
export const metadata = { title: "Panduan" };
// Wrong: dua ekspor metadata dari satu segmen.
export const metadata = { title: "Panduan" };
export async function generateMetadata() { /* ... */ }// app/[locale]/guide/page.tsx: Server Component, tanpa "use client"
import type { Metadata } from "next";
type Props = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params; // di Next.js 15, params adalah Promise
return {
// absolute: judul sudah memuat merek, jadi template root tidak menambah lagi
title: { absolute: "Panduan SEO Metadata | Example" },
description: "Contoh deskripsi 140 sampai 155 karakter untuk halaman ini.",
};
}Title di sini memakai bentuk absolute, dengan merek yang sudah ditulis di dalamnya. Langkah berikutnya mengisi alternates. Kalau kamu mengambil data di dalam generateMetadata, dokumentasi menyebut fetch yang identik di-memoize antara generateMetadata, layout, dan page, jadi page sendiri bisa meminta data yang sama tanpa request kedua.
Langkah 3: Tambahkan canonical dan hreflang untuk tiap bahasa
Setiap versi bahasa menyebut dirinya sendiri sebagai canonical, dan alternates.languages mencantumkan semua versi. Google menyatakan setiap versi harus mencantumkan dirinya sendiri dan semua versi lain, tautannya harus timbal balik atau tag-nya diabaikan, dan URL harus lengkap (fully qualified). Google menyarankan path absolut pada elemen canonical dan canonical yang menunjuk ke dirinya sendiri pada halaman canonical.
import { buildCanonical, buildLanguageAlternates } from "@/lib/seo";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const lang = locale === "id" ? "id" : "en";
const path = "/guide";
return {
title: { absolute: "Panduan SEO Metadata | Example" },
alternates: {
canonical: buildCanonical(lang, path), // URL halaman ini sendiri
languages: buildLanguageAlternates(path), // en, id, x-default
},
};
}
// Wrong: canonical halaman /id/ menunjuk ke versi Inggris
// canonical: buildCanonical("en", path)Entri x-default adalah cadangan untuk pengunjung yang bahasanya tidak cocok dengan versi mana pun. Situs ini mengarahkannya ke halaman Inggris. Karena kedua sisi berasal dari fungsi yang sama, aturan timbal balik tidak bisa rusak karena salah ketik.
Google menganggap tiga cara menyatakan alternate, yaitu tag link HTML, header HTTP, dan sitemap, setara, jadi satu sudah cukup. Panduan ini memakai tag link dan juga mengisinya di sitemap pada Langkah 5.
Langkah 4: Tambahkan Open Graph dan Twitter tanpa kehilangan field
Tag Open Graph dan Twitter mengatur pratinjau tautan. Atur openGraph dengan title, description, url, type, dan images, serta twitter.card ke summary_large_image untuk pratinjau besar. Dokumentasi menandai URL gambar harus absolut.
Metadata digabung secara dangkal (shallow). Kalau layout mengatur openGraph dengan description dan gambar, lalu page mengatur openGraph hanya dengan title, kartu page itu hanya punya title. Seluruh objek diganti, bukan digabung.
// Wrong: layout punya openGraph lengkap, page mengganti SELURUH objeknya.
// layout: openGraph: { title, description, images }
// page: openGraph: { title: "Panduan" }
// hasil: hanya og:title. Description dan gambar hilang.// lib/shared-metadata.ts
export const sharedOpenGraph = {
siteName: "Example",
images: ["https://example.com/og/default.png"], // wajib absolut
};
// app/[locale]/guide/page.tsx
return {
title: { absolute: "Panduan SEO Metadata | Example" },
openGraph: {
...sharedOpenGraph, // gambar dan siteName ikut terbawa
title: "Panduan SEO Metadata",
description: "Ringkasan untuk kartu bagikan.",
url: buildCanonical(lang, path),
type: "article",
},
twitter: { card: "summary_large_image" },
};Solusinya adalah menarik field bersama ke satu objek lalu menyebarkannya (spread) ke tiap segmen, seperti pola di dokumentasi. Untuk gambar unik per halaman, lihat pembuatan OG image dinamis di Next.js. Aplikasi sosial menyimpan cache pratinjau, jadi kartu yang masih salah setelah tag diperbaiki bisa jadi hanya salinan lama.
Langkah 5: Buat sitemap.ts dan robots.ts
app/sitemap.ts mengembalikan array entri, masing-masing dengan url serta lastModified dan alternates.languages yang opsional (dukungan lokalisasi hadir di Next.js 14.2). Pakai ulang helper Langkah 1 agar sitemap sepakat dengan tag head. File ini disajikan sebagai /sitemap.xml.
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { buildCanonical, buildLanguageAlternates } from "@/lib/seo";
const PATHS = ["", "/guide"];
export default function sitemap(): MetadataRoute.Sitemap {
return PATHS.flatMap((path) =>
(["en", "id"] as const).map((locale) => ({
url: buildCanonical(locale, path),
lastModified: "2026-10-17", // tanggal konten benar-benar berubah
alternates: { languages: buildLanguageAlternates(path) },
})),
);
}app/robots.ts mengembalikan rules dengan userAgent, allow, dan disallow, plus alamat sitemap. Kedua file adalah Route Handler khusus yang di-cache secara default kecuali memakai API waktu-request. Jaga lastModified tetap jujur: pakai tanggal konten benar-benar berubah, bukan new Date() di setiap build.
// app/robots.ts
import type { MetadataRoute } from "next";
import { BASE_URL } from "@/lib/seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: "/private/" },
sitemap: `${BASE_URL}/sitemap.xml`,
};
}Beginilah bentuk robots.txt hasil generate untuk contoh di atas, mengikuti format output di dokumentasi.
# /robots.txt hasil generate
User-Agent: *
Allow: /
Disallow: /private/
Sitemap: https://example.com/sitemap.xmlLangkah 6: Periksa HTML hasil build, bukan source-nya
Source code bisa terlihat benar padahal halaman mengirim hal lain, karena title template dan penggabungan terjadi saat build. Jalankan build produksi, start, lalu baca respons yang sebenarnya dengan curl. Periksa halaman Indonesia, bukan hanya yang Inggris.
npm run build && npm start
# Dari terminal lain: baca head halaman Indonesia yang SEBENARNYA
curl -s http://localhost:3000/id/guide/ \
| grep -oE '<title>[^<]*</title>|<link rel="(canonical|alternate)"[^>]*>|<meta property="og:image"[^>]*>'
# Yang dicari (urutan atribut bisa berbeda):
# <title>Panduan SEO Metadata | Example</title>
# <link rel="canonical" href="https://example.com/id/guide/"/>
# <link rel="alternate" hreflang="en" href="https://example.com/en/guide/"/>
# <link rel="alternate" hreflang="id" href="https://example.com/id/guide/"/>
# <link rel="alternate" hreflang="x-default" href="https://example.com/en/guide/"/>
# <meta property="og:image" content="https://example.com/og/default.png"/>
# Dua file lainnya
curl -s http://localhost:3000/robots.txt
curl -s http://localhost:3000/sitemap.xml | head -20Cari empat hal: satu title dengan ekor merek tepat satu kali, canonical berupa URL halaman itu sendiri dalam bahasanya sendiri, satu link hreflang per bahasa plus x-default, dan og:image yang diawali https. Urutan atribut persisnya bisa berbeda dari dugaanmu, jadi cocokkan berdasarkan nama tag.
Kalau ada tag yang hilang di sini, tidak ada tool lain yang akan memperbaikinya. Kembali ke langkah yang mengurusnya.
Error umum dan cara memperbaikinya
Kebanyakan kesalahan metadata gagal tanpa suara: build lolos dan hanya outputnya yang salah. Tabel ini memetakan gejala yang kamu lihat ke penyebabnya.
| Gejala | Penyebab | Perbaikan |
|---|---|---|
| Build gagal di field URL metadata | Dipakai path relatif seperti /og.png dan metadataBase belum diatur. | Atur metadataBase ke new URL milik situsmu di root layout, atau tulis URL absolut. |
| Title halaman anak tidak punya ekor merek | Template didefinisikan di segmen yang sama dengan title, atau segmen parent hanya mengatur title absolute tanpa template sendiri. | Definisikan template di layout parent, dan pakai satu mode per subtree. Periksa HTML hasil build. |
| Merek muncul dua kali di title | Title sudah berakhir dengan merek dan template di root menambahkannya lagi. | Pakai title.absolute pada halaman yang titlenya sudah memuat merek. Situs ini melakukannya untuk setiap halaman yang panjang titlenya diatur. |
| Kartu bagikan kehilangan gambar atau description | Sebuah page mengatur openGraph, sehingga mengganti seluruh objek openGraph milik layout. | Spread objek bersama ke openGraph tiap segmen, atau ulangi field yang masih diinginkan. |
| Google mengabaikan hreflang saya | Tautannya tidak timbal balik, sebuah versi tidak mencantumkan dirinya sendiri, atau URL-nya relatif. | Hasilkan alternate semua bahasa dari satu fungsi dan jaga URL tetap absolut. |
| Canonical halaman Indonesia menunjuk ke halaman Inggris | URL Inggris disalin sebagai canonical untuk semua bahasa. | Setiap bahasa menyebut URL-nya sendiri sebagai canonical, dan hreflang yang menghubungkan versi-versinya. |
| Error saat mengekspor metadata | Page memakai use client, atau mengekspor metadata dan generateMetadata sekaligus. | Biarkan page tetap Server Component, pindahkan interaktivitas ke file terpisah, dan ekspor hanya salah satu dari keduanya. |
Checklist
- metadataBase dan title template beserta default diatur satu kali di root layout.
- Setiap page mengekspor salah satu dari metadata atau generateMetadata, dari Server Component.
- URL canonical dan hreflang berasal dari satu helper, absolut, dan tiap bahasa menyebut dirinya sendiri.
- openGraph di-spread dari objek bersama atau diulang penuh, dengan URL gambar absolut.
- sitemap.ts dan robots.ts ada, dan robots.ts menyebut alamat sitemap.
- Kamu sudah membaca HTML hasil build halaman Indonesia dengan curl dan melihat tiap tag satu kali.
Belajar lebih dalam
Post ini adalah jalur praktiknya. Pelajaran gratisnya menjelaskan kenapa tiap tag ada, menambahkan JSON-LD dari server component, dan menelusuri file nyata di balik situs ini, dengan kuis singkat di akhir.
Buka pelajarannya: Tutorial SEO Metadata Next.js
Aturan yang dibawa pulang: tentukan setiap URL satu kali, di satu helper, dan biarkan semua tag serta sitemap membacanya. Lalu periksa HTML hasil build, karena kesalahan yang penting di sini tidak pernah menggagalkan build.

