Kenapa ini penting
Konten bagus tidak berguna kalau tidak ditemukan. Judul tab, deskripsi di hasil Google, gambar saat link dibagikan, sitemap untuk mesin pencari, dan aturan robots semuanya ditentukan oleh metadata. Di React SPA kamu butuh library tambahan dan hasilnya sering tidak terbaca crawler. Di Next.js, metadata ditulis sebagai object JavaScript biasa dan dirender di server, langsung ada di HTML. Ini salah satu alasan terbesar perusahaan memilih Next.js untuk situs publik.
Konsep inti
Objek metadata statis
Export metadata dari layout.js atau page.js (hanya di Server Component):
export const metadata = {
title: 'Kopiku',
description: 'Toko kopi nusantara',
}
Next.js mengubahnya menjadi <title> dan <meta name="description">. Dua tag selalu ditambahkan otomatis walau kamu tidak menulis apa pun: <meta charset="utf-8"> dan <meta name="viewport" ...>.
Metadata digabung dari atas ke bawah: root layout memberi nilai dasar, page di bawahnya menimpa field yang sama.
Template judul
Supaya semua halaman berjudul "Nama Halaman | Kopiku" tanpa menulis ulang, pakai template di root layout:
title: { template: '%s | Kopiku', default: 'Kopiku' }
%s diganti judul dari halaman anak. default wajib ada, dipakai halaman yang tidak memberi judul. Template berlaku untuk segmen anak, bukan untuk segmen tempat ia ditulis.
metadataBase
Field seperti openGraph.images dan alternates.canonical butuh URL lengkap. Set metadataBase: new URL('https://kopiku.id') di root layout, lalu kamu cukup menulis path relatif. Tanpa ini, next build memberi peringatan dan memakai http://localhost:3000, sehingga gambar share sosial media menunjuk ke localhost dan tidak tampil di WhatsApp. Kami melihat peringatan ini sendiri di build proyek uji.
generateMetadata untuk halaman dinamis
Untuk halaman artikel, judul berasal dari data. Export fungsi async generateMetadata({ params, searchParams }) yang mengembalikan object metadata. params juga Promise, jadi harus di-await.
Kalau generateMetadata dan page sama-sama mengambil data yang sama, jangan khawatir double request: fetch identik di-memoize, fungsi "use cache" di-cache, dan untuk ORM pakai React.cache.
Dengan Cache Components, generateMetadata mengikuti aturan yang sama dengan komponen lain: data tanpa cache atau runtime data membuatnya dirender saat request. Untuk halaman yang seharusnya statis, cache datanya dengan "use cache".
Open Graph dan Twitter
Open Graph adalah standar meta tag (dibuat Facebook) yang dipakai WhatsApp, Facebook, LinkedIn, Telegram, dan lainnya untuk menampilkan kartu preview link. Isi openGraph: { title, description, url, siteName, images, locale, type } dan twitter: { card: 'summary_large_image', ... }. Kalau ada file opengraph-image (modul sebelumnya), gambar itu dipakai otomatis.
Canonical dan robots per halaman
alternates: { canonical: '/blog/halo-nextjs' }memberi tahu Google URL "resmi" sebuah halaman, berguna kalau halaman sama bisa diakses lewat beberapa URL (misalnya dengan?utm_source=...).robots: { index: false }meminta mesin pencari tidak mengindeks halaman tertentu, misalnya halaman hasil pencarian internal.
sitemap.js dan robots.js
app/sitemap.jsmengekspor fungsi default yang mengembalikan array{ url, lastModified, changeFrequency, priority }. Next.js menyajikannya sebagai XML di/sitemap.xml. Fungsi bolehasyncdan mengambil data, misalnya daftar artikel dari database.app/robots.jsmengembalikan{ rules, sitemap }dan disajikan sebagai/robots.txt.- Keduanya adalah Route Handler khusus yang di-cache secara default kecuali memakai API runtime.
- Untuk situs kecil, kamu juga bisa menaruh file statis
app/sitemap.xmldanapp/robots.txt.
Status 404 dan streaming
Satu hal yang kami temukan saat menguji: kalau notFound() dipanggil di bagian yang di-stream, browser bisa menerima status 200 (header HTTP sudah terkirim sebelum notFound() terjadi). Next.js menambahkan <meta name="robots" content="noindex"> agar halaman itu tidak diindeks. Untuk crawler seperti Googlebot, Next.js menunggu render selesai, dan hasilnya status 404 yang benar. Jadi SEO tetap aman. Hanya saja jangan heran kalau di DevTools browser statusnya 200.
Contoh kode
Root layout dengan metadata dasar:
// app/layout.jsx
import './globals.css'
export const metadata = {
metadataBase: new URL('https://kopiku.id'),
title: {
template: '%s | Kopiku',
default: 'Kopiku, Kopi Nusantara',
},
description: 'Toko kopi online dengan biji pilihan dari Aceh sampai Papua.',
openGraph: {
siteName: 'Kopiku',
locale: 'id_ID',
type: 'website',
},
twitter: { card: 'summary_large_image' },
}
export default function RootLayout({ children }) {
return (
<html lang="id">
<body>{children}</body>
</html>
)
}
// → halaman tanpa judul sendiri: <title>Kopiku, Kopi Nusantara</title>
Metadata statis di halaman:
// app/tentang/page.jsx
export const metadata = {
title: 'Tentang Kami', // → <title>Tentang Kami | Kopiku</title>
description: 'Cerita Kopiku sejak 2019.',
alternates: { canonical: '/tentang' }, // → https://kopiku.id/tentang (berkat metadataBase)
}
export default function TentangPage() {
return <h1>Tentang Kami</h1>
}
Metadata dinamis untuk artikel:
// app/blog/[slug]/page.jsx (bagian metadata)
import { getPost } from '@/lib/posts'
export async function generateMetadata({ params }) {
const { slug } = await params
const post = await getPost(slug) // fungsi "use cache" → tidak dobel query
if (!post) return { title: 'Artikel tidak ditemukan' }
return {
title: post.judul,
description: post.isi,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
title: post.judul,
description: post.isi,
type: 'article',
},
}
}
Hasil yang kami cek dengan curl di proyek uji (artikel halo-nextjs):
curl -s http://localhost:3000/blog/halo-nextjs | grep -o '<title>[^<]*</title>\|<meta property="og:[^>]*>'
# → <title>Halo, Next.js! | Kopiku</title> ← template dari root layout
# → <meta property="og:title" content="Halo, Next.js!"/>
# → <meta property="og:description" content="Artikel pertama kita."/>
# → <meta property="og:image" content="https://kopiku.id/blog/halo-nextjs/opengraph-image?..."/>
# → <meta property="og:image:width" content="1200"/>
# → <meta property="og:type" content="article"/>
Sitemap dari data:
// app/sitemap.js
import { getPosts } from '@/lib/posts'
const BASE_URL = 'https://kopiku.id'
export default async function sitemap() {
const posts = await getPosts()
const artikel = posts.map((post) => ({
url: `${BASE_URL}/blog/${post.slug}`,
lastModified: new Date('2026-10-01'),
changeFrequency: 'monthly',
priority: 0.7,
}))
return [
{ url: BASE_URL, lastModified: new Date('2026-10-01'), changeFrequency: 'weekly', priority: 1 },
{ url: `${BASE_URL}/blog`, changeFrequency: 'daily', priority: 0.8 },
...artikel,
]
}
// → /sitemap.xml:
// → <url><loc>https://kopiku.id/blog/halo-nextjs</loc><lastmod>2026-10-01T00:00:00.000Z</lastmod>...
Robots:
// app/robots.js
export default function robots() {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/dashboard/', '/tulis'] },
sitemap: 'https://kopiku.id/sitemap.xml',
}
}
// → /robots.txt:
// → User-Agent: *
// → Allow: /
// → Disallow: /dashboard/
// → Disallow: /tulis
// →
// → Sitemap: https://kopiku.id/sitemap.xml
Halaman yang tidak boleh diindeks:
// app/cari/page.jsx
export const metadata = {
title: 'Hasil Pencarian',
robots: { index: false, follow: true }, // → <meta name="robots" content="noindex, follow"/>
}
export default function CariPage() {
return <h1>Hasil pencarian</h1>
}
Kesalahan umum
- Export
metadatadari Client Component. Tidak didukung. Metadata hanya di Server Component (layout/pagetanpa'use client'). Kalau halaman perlu interaktif, pindahkan bagian interaktif ke komponen anak. - Lupa
metadataBase. URL gambar OG menjadihttp://localhost:3000/...dan preview link tidak tampil. Set di root layout. - Mengekspor
metadatadangenerateMetadatadi file yang sama. Pilih salah satu: statis atau dinamis. - Lupa
await paramsdigenerateMetadata. Judul menjadiundefined. Sama seperti page,paramsadalah Promise. - Judul dan deskripsi sama di semua halaman. Google menganggapnya kurang informatif. Tulis judul unik per halaman (pakai template untuk nama situs) dan deskripsi yang merangkum isi halaman.
Latihan
- Mudah, Tambahkan
title.template'%s · Kedai Kopi'dandefaultdi root layout, lalu beri judul'Menu'diapp/menu/page.jsx. Cek judul tab di browser. - Sedang, Buat
generateMetadatauntukapp/menu/[id]/page.jsx(latihan modul 4) yang mengisi judul dengan nama menu, deskripsi dengan harganya, dan canonical/menu/{id}. - Menantang, Buat
app/sitemap.jsyang berisi halaman utama,/menu, dan semua halaman/menu/[id]dari data yang sama, plusapp/robots.jsyang melarang/admin. Verifikasi dengan membuka/sitemap.xmldan/robots.txt.
Lihat solusi
1.
// app/layout.jsx
export const metadata = {
title: { template: '%s · Kedai Kopi', default: 'Kedai Kopi' },
}
export default function RootLayout({ children }) {
return (
<html lang="id">
<body>{children}</body>
</html>
)
}
// app/menu/page.jsx
export const metadata = { title: 'Menu' } // → tab: "Menu · Kedai Kopi"
export default function MenuPage() {
return <h1>Menu</h1>
}
2.
// lib/menu.js
export const MENU = [
{ id: '1', nama: 'Kopi Susu', harga: 18000 },
{ id: '2', nama: 'Americano', harga: 20000 },
{ id: '3', nama: 'Teh Tarik', harga: 15000 },
]
// app/menu/[id]/page.jsx
import { notFound } from 'next/navigation'
import { MENU } from '@/lib/menu'
export function generateStaticParams() {
return MENU.map((m) => ({ id: m.id }))
}
export async function generateMetadata({ params }) {
const { id } = await params
const menu = MENU.find((m) => m.id === id)
if (!menu) return { title: 'Menu tidak ditemukan' }
return {
title: menu.nama,
description: `${menu.nama} hanya Rp${menu.harga.toLocaleString('id-ID')} di Kedai Kopi.`,
alternates: { canonical: `/menu/${id}` },
}
}
export default async function MenuDetail({ params }) {
const { id } = await params
const menu = MENU.find((m) => m.id === id)
if (!menu) notFound()
return <h1>{menu.nama}</h1>
}
// → /menu/2 → <title>Americano · Kedai Kopi</title>
3.
// app/sitemap.js
import { MENU } from '@/lib/menu'
const BASE = 'https://kedaikopi.id'
export default function sitemap() {
return [
{ url: BASE, changeFrequency: 'weekly', priority: 1 },
{ url: `${BASE}/menu`, changeFrequency: 'weekly', priority: 0.9 },
...MENU.map((m) => ({
url: `${BASE}/menu/${m.id}`,
changeFrequency: 'monthly',
priority: 0.6,
})),
]
}
// app/robots.js
export default function robots() {
return {
rules: { userAgent: '*', allow: '/', disallow: '/admin' },
sitemap: 'https://kedaikopi.id/sitemap.xml',
}
}
// → /sitemap.xml berisi 5 <url>; /robots.txt berisi "Disallow: /admin"
Ringkasan
- Export
metadata(statis) ataugenerateMetadata(dinamis,paramsdi-await) darilayout/pageServer Component. title.template+defaultdi root layout memberi format judul seragam;metadataBasemembuat URL relatif menjadi URL lengkap.openGraphdantwittermengatur kartu preview link; fileopengraph-imageotomatis dipakai.alternates.canonicalmenentukan URL resmi;robots: { index: false }mencegah pengindeksan halaman tertentu.app/sitemap.jsdanapp/robots.jsmenghasilkan/sitemap.xmldan/robots.txtdari kode, dan di-cache secara default.