Panduan Structured Data Produk untuk Toko Online Indonesia

Panduan Structured Data Produk untuk Toko Online Indonesia

Gejalanya selalu mirip. Kamu sudah pasang JSON-LD Product di semua halaman produk. Tiga minggu lewat, harga dan rating tetap tidak muncul di hasil pencarian. Kamu buka Search Console, masuk ke menu Shopping > Product snippets, dan yang terlihat cuma satu baris merah:

Either "offers", "review", or "aggregateRating" should be specified — 1.284 halaman tidak valid.

Atau versi yang lebih menyebalkan: statusnya "Valid", jumlah item terdeteksi 1.284, tapi kolom Impressions di laporan Performance untuk Search Appearance "Product snippets" tetap nol. Markup terbaca, rich result tidak keluar.

Dua penyebab itu berbeda dan penanganannya berbeda. Yang pertama masalah struktur data. Yang kedua biasanya masalah eligibility — kontennya lolos parser tapi tidak lolos kebijakan Google, atau nilainya tidak konsisten dengan yang dilihat user di halaman.

Artikel ini membedah keduanya, dengan konteks toko online Indonesia: harga IDR, stok pre-order, dan produk yang juga tayang di Tokopedia atau Shopee.

Anatomi JSON-LD Product: property wajib vs opsional

Google membedakan tiga kategori: wajib (tanpa ini tidak ada rich result), rekomendasi (menambah tampilan), dan opsional (dipakai untuk matching, bukan tampilan).

PropertyStatusCatatan
nameWajibHarus sama persis dengan judul produk di halaman
imageWajibMinimal 1, rasio 1:1 / 4:3 / 16:9, resolusi ≥ 1200px
offersWajib**Salah satu dari offers, review, aggregateRating
offers.priceWajibAngka murni, tanpa Rp dan tanpa titik ribuan
offers.priceCurrencyWajibIDR — kode ISO 4217, tiga huruf
offers.availabilityRekomendasiURL schema.org lengkap, bukan string bebas
offers.priceValidUntilRekomendasiISO 8601. Tanggal lewat = harga tidak ditampilkan
brandRekomendasiObjek Brand, bukan string
skuRekomendasiIdentitas internal kamu
gtin / gtin13RekomendasiBarcode EAN. Kosongkan kalau produk handmade
aggregateRatingOpsionalWajib ada review asli di halaman
reviewOpsionalButuh author dan reviewRating

Berikut schema Product lengkap yang lolos Rich Results Test:

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Tas Ransel Kulit Sapi Asli Garut 20L",
  "image": [
    "https://tokokamu.co.id/images/ransel-garut-1200x1200.jpg",
    "https://tokokamu.co.id/images/ransel-garut-1200x900.jpg"
  ],
  "description": "Ransel kulit sapi nabati 20 liter, jahitan tangan, kompartemen laptop 14 inci.",
  "sku": "ZLV-RNS-2001",
  "gtin13": "8991234567890",
  "mpn": "RNS2001",
  "brand": {
    "@type": "Brand",
    "name": "Sagara Leather"
  },
  "offers": {
    "@type": "Offer",
    "url": "https://tokokamu.co.id/produk/ransel-kulit-garut-20l",
    "priceCurrency": "IDR",
    "price": "749000",
    "priceValidUntil": "2026-12-31",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition",
    "seller": {
      "@type": "Organization",
      "name": "Toko Kamu"
    },
    "shippingDetails": {
      "@type": "OfferShippingDetails",
      "shippingRate": {
        "@type": "MonetaryAmount",
        "value": "22000",
        "currency": "IDR"
      },
      "shippingDestination": {
        "@type": "DefinedRegion",
        "addressCountry": "ID"
      }
    }
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.7",
    "reviewCount": "128",
    "bestRating": "5",
    "worstRating": "1"
  },
  "review": [
    {
      "@type": "Review",
      "author": { "@type": "Person", "name": "Anindya P." },
      "datePublished": "2026-05-14",
      "reviewRating": {
        "@type": "Rating",
        "ratingValue": "5",
        "bestRating": "5"
      },
      "reviewBody": "Kulitnya tebal, jahitan rapi. Muat laptop 14 inci plus charger."
    }
  ]
}

Tiga hal yang sering salah di sini:

  1. price ditulis "Rp 749.000". Parser membacanya sebagai nilai tidak valid. Tulis "749000". Titik dan koma hanya boleh sebagai desimal, bukan pemisah ribuan.
  2. brand ditulis sebagai string. "brand": "Sagara Leather" masih diterima, tapi bentuk objek lebih aman untuk jangka panjang.
  3. gtin13 diisi asal. Kalau produkmu handmade dan tidak punya barcode, hapus field-nya. Barcode palsu memicu error Invalid object type for field gtin13.

Menyuntikkan schema dari MySQL ke template Handlebars

Ini titik paling rawan. Kesalahan terbesar bukan di SQL-nya, tapi di cara Handlebars menuliskan output.

Bangun objek schema di route, bukan di template. Template hanya menerima satu string JSON yang sudah jadi:

// routes/product.js
router.get('/produk/:slug', (req, res, next) => {
  const sql = `SELECT p.id, p.name, p.slug, p.sku, p.gtin13, p.description,
                      p.price, p.stock, p.preorder, p.image_url, b.name AS brand_name,
                      AVG(r.rating) AS avg_rating, COUNT(r.id) AS review_count
               FROM products p
               LEFT JOIN brands b ON b.id = p.brand_id
               LEFT JOIN reviews r ON r.product_id = p.id AND r.approved = 1
               WHERE p.slug = ? AND p.published = 1
               GROUP BY p.id`;

  db.query(sql, [req.params.slug], (err, rows) => {
    if (err) return next(err);
    if (!rows.length) return res.status(404).render('404');

    const p = rows[0];
    const base = 'https://tokokamu.co.id';

    const schema = {
      '@context': 'https://schema.org',
      '@type': 'Product',
      name: p.name,
      image: [base + p.image_url],
      description: p.description,
      sku: p.sku,
      brand: { '@type': 'Brand', name: p.brand_name },
      offers: {
        '@type': 'Offer',
        url: `${base}/produk/${p.slug}`,
        priceCurrency: 'IDR',
        price: String(Math.round(p.price)),
        priceValidUntil: priceValidUntil(),
        availability: availabilityOf(p),
        itemCondition: 'https://schema.org/NewCondition'
      }
    };

    if (p.gtin13) schema.gtin13 = p.gtin13;

    if (p.review_count > 0) {
      schema.aggregateRating = {
        '@type': 'AggregateRating',
        ratingValue: Number(p.avg_rating).toFixed(1),
        reviewCount: String(p.review_count),
        bestRating: '5',
        worstRating: '1'
      };
    }

    res.render('pages/product', {
      product: p,
      productSchema: JSON.stringify(schema)
    });
  });
});

Perhatikan blok if (p.review_count > 0). Jangan pernah kirim aggregateRating dengan reviewCount: "0" — itu error otomatis di Rich Results Test.

Di template, gunakan triple-stash:

{{#if productSchema}} <script type="application/ld+json">{{{productSchema}}}</script> {{/if}} 

Kalau kamu pakai double-stash {{productSchema}}, Handlebars meng-escape tanda kutip menjadi &quot;. Hasilnya di HTML:

<script type="application/ld+json">{&quot;@context&quot;:&quot;https://schema.org&quot;...</script>

JSON itu tidak bisa di-parse. Search Console akan melaporkan halamanmu sebagai "tidak ada item terdeteksi" — bukan error, cuma kosong. Ini penyebab paling umum dari "schema sudah dipasang tapi tidak terbaca".

Gotcha express-minify-html

Proyek yang memakai express-minify-html punya jebakan kedua. Minifier bisa memangkas isi tag <script> yang tidak dikenalinya sebagai JavaScript. Pastikan konfigurasi minifier tidak menyentuh application/ld+json:

app.use(minifyHTML({
  override: true,
  htmlMinifier: {
    removeComments: true,
    collapseWhitespace: true,
    minifyJS: false,
    processScripts: [],           // jangan proses ld+json
    ignoreCustomFragments: [/\{\{[\s\S]*?\}\}/]
  }
}));

Dan satu hal lagi: JSON.stringify tidak meng-escape </script>. Kalau deskripsi produk mengandung teks itu, halamanmu pecah. Amankan dengan:

const safeSchema = JSON.stringify(schema).replace(/</g, '\\u003c');

Varian, stok pre-order, dan currency IDR pada Offer

Untuk produk satu harga, Offer tunggal cukup. Untuk produk dengan rentang harga (misalnya ukuran S sampai XXL), pakai AggregateOffer:

{
  "@type": "AggregateOffer",
  "priceCurrency": "IDR",
  "lowPrice": "129000",
  "highPrice": "189000",
  "offerCount": "5",
  "availability": "https://schema.org/InStock"
}

Nilai availability harus berupa URL schema.org, bukan teks bebas. Pemetaan yang relevan untuk toko Indonesia:

Kondisi stokNilai availability
Ready, kirim hari inihttps://schema.org/InStock
Stok habishttps://schema.org/OutOfStock
Pre-order, produksi belum jalanhttps://schema.org/PreOrder
Restock terjadwalhttps://schema.org/BackOrder
Produk dihentikanhttps://schema.org/Discontinued
function availabilityOf(p) {
  if (p.preorder) return 'https://schema.org/PreOrder';
  if (p.stock > 0) return 'https://schema.org/InStock';
  return 'https://schema.org/OutOfStock';
}

Untuk PreOrder, tambahkan availabilityStarts supaya Google tahu kapan barang mulai dikirim:

"offers": {
  "@type": "Offer",
  "priceCurrency": "IDR",
  "price": "1250000",
  "availability": "https://schema.org/PreOrder",
  "availabilityStarts": "2026-09-01T00:00:00+07:00",
  "priceValidUntil": "2026-08-31"
}

Perhatikan offset +07:00 untuk WIB. Kalau server-mu berjalan di UTC dan kamu pakai new Date().toISOString() mentah, tanggal bisa mundur satu hari untuk transaksi malam hari.

priceValidUntil yang kedaluwarsa

Ini penyebab senyap paling sering. Kalau priceValidUntil sudah lewat, Google berhenti menampilkan harga di rich result — tanpa memunculkan error di Search Console. Jangan hardcode. Hitung otomatis:

function priceValidUntil(days = 90) {
  const d = new Date(Date.now() + days * 86400000);
  return d.toISOString().slice(0, 10); // "2026-10-16"
}

Ini juga harus konsisten dengan halaman. Kalau JSON-LD bilang 749000 sementara halaman menampilkan Rp 699.000 karena ada diskon flash sale yang dirender di sisi klien, Google menganggapnya mismatch. Ambil harga final dari sumber yang sama dengan yang dirender ke user.

Duplikasi konten dengan Tokopedia dan Shopee

Banyak toko lokal menjual produk yang sama di website sendiri dan di marketplace. Deskripsi identik, foto identik, sering kali harga berbeda. Google harus memilih satu versi kanonis, dan biasanya marketplace yang menang karena otoritas domainnya jauh lebih besar.

Yang bisa kamu lakukan:

  • Tulis ulang deskripsi di website sendiri. Minimal 40-50% berbeda dari listing marketplace. Tambahkan detail yang tidak ada di sana: cara perawatan, asal bahan, panduan ukuran.
  • Jangan pakai sameAs yang menunjuk ke listing marketplace pada objek Product. Simpan sameAs untuk akun sosial di objek Organization.
  • Pasang canonical self-referencing di setiap halaman produk: <link rel="canonical" href="https://tokokamu.co.id/produk/slug">.
  • Jaga harga sama atau lebih murah di website sendiri. Kalau harga di halamanmu lebih mahal dari yang muncul di marketplace untuk produk identik, klik dari rich result akan pantul.
  • Foto berbeda. Minimal satu foto eksklusif untuk website, di-host di domainmu sendiri.

Review dan rating: yang sah vs yang melanggar

Google menonaktifkan rich result rating untuk seluruh domain kalau menemukan pola manipulatif. Pemulihannya lambat, bisa berbulan-bulan.

Melanggar:

  • aggregateRating yang di-hardcode di template dan sama di semua produk ("ratingValue": "4.9" untuk 3.000 produk).
  • Rating yang tidak ditampilkan ke user di halaman, hanya ada di JSON-LD.
  • Self-serving review — review ditulis oleh pemilik toko atau tim.
  • Menyalin aggregateRating dari Tokopedia untuk halaman website sendiri. Rating itu milik listing marketplace.
  • Rating tanpa satu pun review individual di halaman.

Sah:

  • Review dari pembeli terverifikasi, tersimpan di tabel reviews, approved = 1.
  • Nilai ratingValue dihitung dari data, bukan konstanta.
  • Review tampil visual di halaman, dan yang di JSON-LD adalah subset dari yang tampil.
  • reviewCount mencerminkan jumlah review yang benar-benar ada.

Aturan praktisnya sederhana: kalau user tidak bisa melihat rating itu dengan matanya sendiri di halaman, jangan taruh di structured data.

Validasi dan error yang sering muncul

Pakai tiga alat berurutan:

  1. Rich Results Test (search.google.com/test/rich-results) — satu-satunya yang menjawab "apakah ini memenuhi syarat rich result". Tes URL live, bukan paste kode, supaya kamu ikut menguji hasil render Handlebars dan minifier.
  2. Schema Markup Validator (validator.schema.org) — memeriksa kevalidan schema.org secara umum, termasuk property di luar cakupan Google.
  3. Search Console > Shopping > Product snippets — data agregat dari halaman yang sudah di-crawl. Ini yang menunjukkan skala masalah.

Error yang paling sering muncul dan artinya:

PesanPenyebab
Either "offers", "review", or "aggregateRating" should be specifiedObjek offers hilang, atau nama field salah ketik
Missing field "priceCurrency"Lupa IDR, atau ditulis Rp / Rupiah
Invalid value in field "price"Harga mengandung Rp, titik ribuan, atau spasi
Missing field "name" (in "review.author")author ditulis sebagai string, bukan objek Person
Unparsable structured dataDouble-stash {{ }} meng-escape kutip jadi &quot;
No items detectedJSON-LD terpotong minifier, atau <script> tanpa type
Invalid enum value in field "availability"Ditulis "InStock" tanpa prefix https://schema.org/

Setelah perbaikan, klik Validate Fix di Search Console. Google butuh sekitar 2-4 minggu untuk re-crawl dan mengubah status jadi "Passed". Jangan menilai keberhasilan dari Rich Results Test saja — alat itu menguji satu URL, laporan Search Console menguji seluruh situs.

Cara cek apakah rich result benar-benar muncul: buka Performance > Search Appearance, filter Product snippets, bandingkan CTR sebelum dan sesudah. Kenaikan CTR 15-30% pada query bermerek adalah hasil yang wajar untuk implementasi yang benar.

Checklist implementasi

Jalankan berurutan:

  • Buat helper availabilityOf() dan priceValidUntil() di utilitas kamu.
  • Bangun objek schema di route, kirim ke view sebagai satu string hasil JSON.stringify.
  • Escape karakter < menjadi < sebelum dikirim ke template.
  • Render dengan triple-stash {{{productSchema}}} di dalam <script type="application/ld+json">.
  • Set minifyJS: false dan processScripts: [] di konfigurasi minifier.
  • Isi price sebagai angka murni, priceCurrency selalu "IDR".
  • Hitung priceValidUntil dinamis, jangan hardcode.
  • Kirim aggregateRating hanya bila reviewCount > 0, nilai dari AVG() di database.
  • Pastikan setiap rating di JSON-LD juga tampil visual di halaman.
  • Hapus gtin13 untuk produk tanpa barcode resmi.
  • Tulis ulang deskripsi produk agar berbeda dari listing marketplace.
  • Pasang canonical self-referencing di semua halaman produk.
  • Tes 5 URL live di Rich Results Test: stok ready, habis, pre-order, ada review, tanpa review.
  • Submit sitemap produk, tandai Validate Fix di Search Console.
  • Cek ulang laporan Product snippets 14 hari kemudian.

Kalau lima belas poin itu beres, sisa masalahmu bukan di structured data lagi.