kodingup.camp

Beranda/Next.js Mastery: Dasar, Routing & Rendering

Metadata & SEO

Modul 10 dari 104 menit bacaMetadata and SEO

Isi modul

    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

    sitemap.js dan robots.js

    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

    1. Export metadata dari Client Component. Tidak didukung. Metadata hanya di Server Component (layout/page tanpa 'use client'). Kalau halaman perlu interaktif, pindahkan bagian interaktif ke komponen anak.
    2. Lupa metadataBase. URL gambar OG menjadi http://localhost:3000/... dan preview link tidak tampil. Set di root layout.
    3. Mengekspor metadata dan generateMetadata di file yang sama. Pilih salah satu: statis atau dinamis.
    4. Lupa await params di generateMetadata. Judul menjadi undefined. Sama seperti page, params adalah Promise.
    5. 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

    1. Mudah, Tambahkan title.template '%s · Kedai Kopi' dan default di root layout, lalu beri judul 'Menu' di app/menu/page.jsx. Cek judul tab di browser.
    2. Sedang, Buat generateMetadata untuk app/menu/[id]/page.jsx (latihan modul 4) yang mengisi judul dengan nama menu, deskripsi dengan harganya, dan canonical /menu/{id}.
    3. Menantang, Buat app/sitemap.js yang berisi halaman utama, /menu, dan semua halaman /menu/[id] dari data yang sama, plus app/robots.js yang melarang /admin. Verifikasi dengan membuka /sitemap.xml dan /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

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    NextJS 16 SEO Crash Course - Metadata, Robots, Sitemap, OpenGraph...PedroTech · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.