Kita udah nyinggung “URL sadar locale” berkali-kali sejak 38 Search dan Predictive Search. Bab ini tempat semuanya dijelasin: gimana Shopify ngelayanin pembeli dari banyak negara dan bahasa, dan apa yang harus theme lakuin biar gak ngerusaknya.

Shopify bantu merchant jualan ke banyak negara dan skala internasional, yang berarti banyak merchant butuh jualan dalam banyak bahasa dan mata uang. Fitur payungnya Shopify Markets: merchant bikin market (misalnya “Eropa”, “Amerika Utara”), tiap market punya negara, mata uang, bahasa, dan bisa punya domain atau subfolder sendiri.

Theme nyediain dua hal: pemilih negara dan bahasa (lewat object localization dan {% form 'localization' %}), dan URL yang bener buat tiap konteks. Merchant Shopify Plus juga dapat deteksi negara otomatis lewat geolokasi. Dan merchant bisa nyesuain konten theme per market lewat store contextualization di theme editor, yang datanya tersimpan di markets.json dari 32 Config settings_schema.json, settings_data.json, markets.json.

Object yang Dipakai

Udah dibedah di 13 Objects Global > Object Localization:

  1. localization: available_countries, available_languages, country, language, market.
  2. country: name, iso_code, currency, popular?, dan trik image_url buat bendera.
  3. shop_locale: name, endonym_name, iso_code, primary, root_url, direction.
  4. form dari {% form 'localization' %}.

Pemilih Negara

Buat pembeli milih mata uang lewat negara. Kalau bahasa yang lagi dipilih gak didukung negara baru, bahasanya otomatis diganti ke default negara itu.

Tiga syarat:

  1. Ada di dalam {% form 'localization' %}.
  2. Ada input name="country_code" yang nilainya kode negara yang dipilih.
  3. Cuma ditampilin kalau available_countries.size > 1, gak ada gunanya pemilih dengan satu opsi.

Contoh tombol plus popover berisi tiap negara:

{% if localization.available_countries.size > 1 %}
  <localization-form>
    {% form 'localization' %}
      <div class="disclosure">
        <button type="button" class="disclosure__button" aria-expanded="false" aria-controls="CountryList">
          {{ localization.country.name }} ({{ localization.country.currency.iso_code }} {{ localization.country.currency.symbol }})
          <svg aria-hidden="true" focusable="false" role="presentation" class="icon icon-caret" viewBox="0 0 10 6">
            <path fill-rule="evenodd" clip-rule="evenodd" d="M9.354.646a.5.5 0 00-.708 0L5 4.293 1.354.646a.5.5 0 00-.708.708l4 4a.5.5 0 00.708 0l4-4a.5.5 0 000-.708z" fill="currentColor">
          </svg>
        </button>
        <ul id="CountryList" role="list" class="disclosure__list" hidden>
          {% for country in localization.available_countries %}
            <li class="disclosure__item" tabindex="-1">
              <a href="#"{% if country.iso_code == localization.country.iso_code %} aria-current="true"{% endif %} data-value="{{ country.iso_code }}">
                {{ country.name }} ({{ country.currency.iso_code }} {{ country.currency.symbol }})
              </a>
            </li>
          {% endfor %}
        </ul>
        <input type="hidden" name="country_code" value="{{ localization.country.iso_code }}" />
      </div>
    {% endform %}
  </localization-form>
{% endif %}

Perhatiin formatnya: nama negara lengkap + kode mata uang + simbol, “United States (USD $)”, sesuai pedoman UX di bawah. aria-current="true" nandain yang lagi aktif, data-value dibaca JavaScript. Contoh Dawn di sections/footer.liquid.

Pemilih Bahasa

Sama persis, dengan input name="language_code", data dari available_languages dan language, cuma kalau lebih dari satu bahasa:

{% if localization.available_languages.size > 1 %}
  <localization-form>
    {% form 'localization' %}
      <div class="disclosure">
        <button type="button" class="disclosure__button" aria-expanded="false" aria-controls="LanguageList">
          {{ localization.language.endonym_name | capitalize }}
          <svg aria-hidden="true" focusable="false" role="presentation" class="icon icon-caret" viewBox="0 0 10 6">
            <path fill-rule="evenodd" clip-rule="evenodd" d="M9.354.646a.5.5 0 00-.708 0L5 4.293 1.354.646a.5.5 0 00-.708.708l4 4a.5.5 0 00.708 0l4-4a.5.5 0 000-.708z" fill="currentColor">
          </svg>
        </button>
        <ul id="LanguageList" role="list" class="disclosure__list" hidden>
          {% for language in localization.available_languages %}
            <li class="disclosure__item" tabindex="-1">
              <a href="#"{% if language.iso_code == localization.language.iso_code %} aria-current="true"{% endif %} hreflang="{{ language.iso_code }}" lang="{{ language.iso_code }}" data-value="{{ language.iso_code }}">
                {{ language.endonym_name | capitalize }}
              </a>
            </li>
          {% endfor %}
        </ul>
        <input type="hidden" name="language_code" value="{{ localization.language.iso_code }}" />
      </div>
    {% endform %}
  </localization-form>
{% endif %}

Dua detail penting:

  1. endonym_name, nama bahasa dalam bahasa itu sendiri (“Deutsch”, bukan “German”). Pembeli Jerman yang nyasar di toko berbahasa Inggris tetap bisa nemuin bahasanya.
  2. hreflang dan lang di tiap link, biar screen reader ngucapin nama bahasanya dengan pelafalan yang bener.

❌ pembeli asing gak ngenalin bahasanya

{{ language.name }}

✅ nama dalam bahasanya sendiri

{{ language.endonym_name | capitalize }}

JavaScript Buat Submit Form

Karena selector-nya custom element dan gak ada tombol submit di form, submit-nya lewat JavaScript:

class LocalizationForm extends HTMLElement {
  constructor() {
    super();
    this.elements = {
      input: this.querySelector('input[name="language_code"], input[name="country_code"]'),
      button: this.querySelector('button'),
      panel: this.querySelector('ul'),
    };
    this.elements.button.addEventListener('click', this.openSelector.bind(this));
    this.elements.button.addEventListener('focusout', this.closeSelector.bind(this));
    this.addEventListener('keyup', this.onContainerKeyUp.bind(this));
    this.querySelectorAll('a').forEach(item => item.addEventListener('click', this.onItemClick.bind(this)));
  }
  hidePanel() {
    this.elements.button.setAttribute('aria-expanded', 'false');
    this.elements.panel.setAttribute('hidden', true);
  }
  onContainerKeyUp(event) {
    if (event.code.toUpperCase() !== 'ESCAPE') return;
    this.hidePanel();
    this.elements.button.focus();
  }
  onItemClick(event) {
    event.preventDefault();
    const form = this.querySelector('form');
    this.elements.input.value = event.currentTarget.dataset.value;
    if (form) form.submit();
  }
  openSelector() {
    this.elements.button.focus();
    this.elements.panel.toggleAttribute('hidden');
    this.elements.button.setAttribute('aria-expanded', (this.elements.button.getAttribute('aria-expanded') === 'false').toString());
  }
  closeSelector(event) {
    const shouldClose = event.relatedTarget && event.relatedTarget.nodeName === 'BUTTON';
    if (event.relatedTarget === null || shouldClose) {
      this.hidePanel();
    }
  }
}
customElements.define('localization-form', LocalizationForm);

Alurnya: klik tombol → toggle hidden di <ul> dan aria-expanded → klik item → isi hidden input dari data-value → form.submit() → Shopify ngeset cookie localization dan redirect ke URL konteks yang baru. Escape nutup panel dan balikin fokus ke tombol. Satu class dipakai buat dua selector karena struktur markup-nya sama.

Catatan Shopify: custom element dengan JavaScript itu default yang disarankan buat aksesibilitas, tapi sediain fallback kalau JavaScript mati, misalnya <noscript> berisi <select name="country_code"> dengan tombol submit biasa. (Ini beda dari pemilih varian di 39 Produk Variants dan High-variant Products yang fallback-nya udah gak wajib.)

URL Sadar Locale

Ini bagian yang paling sering bikin bug di theme multi bahasa. Toko dapat URL dinamis waktu jualan internasional:

  1. Waktu locale tambahan dipublikasi di domain, Shopify otomatis bikin path buat itu: shop.com dengan locale Prancis → shop.com/fr.
  2. Waktu merchant bikin market baru, Shopify bikin path buat market itu: market utama AS berbahasa Inggris di shop.com, market Kanada → shop.com/en-ca.

Jadi struktur URL bisa berubah kapan aja tergantung setting merchant. Kalau kamu hardcode /cart di link atau request Ajax, pengunjung yang lagi di /fr atau /en-ca dilempar balik ke default domain: bahasa dan mata uangnya berubah tanpa mereka minta. Ini bug yang nyata dan sering.

1. Di Liquid

Dua cara:

  1. Atribut url object manapun (product.url, collection.url, link.url), yang udah nyertain prefix locale.
  2. Object routes dari 13 Objects Global > routes.
<a href="{{ routes.cart_url }}">{{ 'templates.cart.go_to_cart'  | t }}</a>

Hasilnya /cart di locale utama, /fr/cart di Prancis.

2. Di JavaScript

Bangun URL dari window.Shopify.routes.root, root dinamis yang selalu diakhiri /. Di market Kanada nilainya "/en-ca/":

fetch(window.Shopify.routes.root + 'cart.js')
.then(response => response.json())
.then(data => showCartContents(data));

Request ke /en-ca/cart.js ngasih harga market Kanada, request ke /cart.js ngasih harga default. Ini kenapa semua contoh Ajax di 38 Search dan Predictive Search, 41 Produk Recommendations, Gift Card, dan Bundles, dan 51 Ajax API dan Section Rendering API pakai window.Shopify.routes.root.

❌ pembeli di /fr dilempar ke bahasa default

fetch('/cart/add.js', { ... })

✅ tetap di konteksnya

fetch(window.Shopify.routes.root + 'cart/add.js', { ... })

SEO Multi Bahasa

Selain pemilih, mesin pencari harus ngembaliin konten dan harga yang terlokalisasi. Dua hal:

  1. hreflang: Shopify otomatis nyertain tag hreflang lewat content_for_header, jadi kamu gak perlu ngerakit sendiri. Detailnya di 48 SEO Metadata, hreflang, robots.txt.
  2. Structured data: waktu output structured data produk, priceCurrency harus dari cart.currency, bukan mata uang toko, biar Google nampilin harga di mata uang market itu:
priceCurrency: {{ cart.currency.iso_code }},

Shopify ngingetin: info SEO di sini cuma umum, kalau ada masalah hasil pencarian dan mata uang, konsultasi ke ahli SEO.

Deteksi Localization Optimal

Kalau toko punya banyak versi lokal, tiap pengunjung sebaiknya dapat versi yang paling cocok. Mesin pencari bisa pre-localize, dan Shopify (Plus) bisa redirect otomatis, tapi pengunjung tetap bisa nyasar ke konteks market yang bukan lokasinya. Ajax API punya endpoint buat nyaranin localization yang lebih baik.

Prinsip pentingnya: jangan maksa pengunjung ke konteks yang terdeteksi. GeoIP gak sempurna (VPN), dan kadang orang emang mau kirim ke negara lain. Checkout Shopify tetap mastiin harga dan ketersediaan sesuai alamat pengiriman yang akhirnya dipilih. Jadi yang tepat: banner saran (“Sepertinya kamu di Kanada, mau belanja dalam CAD?”), bukan redirect paksa.

1. Query Saran

Endpoint browsing_context_suggestions.json pakai setting browser dan IP pengunjung. Bisa minta saran negara, bahasa, atau dua-duanya, dan ngecualiin nilai sekarang biar gak nyaranin yang udah aktif:

await fetch(
  window.Shopify.routes.root
    + 'browsing_context_suggestions.json'
    + '?country[enabled]=true'
    + `&country[exclude]=${window.Shopify.country}`
    + '&language[enabled]=true'
    + `&language[exclude]=${window.Shopify.language}`
).then(r => r.json());

window.Shopify.country dan window.Shopify.language itu konteks sekarang. Responsnya:

{
  "detected_values": {
    "country": {
      "handle": "CA",
      "name": "Canada"
    }
  },
  "suggestions": [
    {
      "parts": {
        "country": {
          "handle": "CA",
          "name": "Canada",
          "confidence": 1,
          "options": {
            "US": "États-Unis",
            "JP": "Japon",
            "CA": "Canada"
          }
        },
        "language": {
          "handle": "fr",
          "name": "français",
          "confidence": 1,
          "options": {
            "en": "anglais",
            "es": "espagnol",
            "fr": "français",
            "ja": "japonais"
          }
        }
      },
      "confidence": 1
    }
  ]
}

detected_values yang terdeteksi, suggestions saran lengkap dengan confidence (0–1) dan options (semua pilihan, namanya dalam bahasa yang disarankan, makanya “États-Unis”). Kalau gak ada saran (pengunjung udah di konteks optimal), suggestions kosong.

2. Terapin Saran

Saran diterapin dengan submit form localization. Versi minimal di JavaScript, bikin form tersembunyi dan submit:

function updateLocalization({country, language}) {
  const formId = crypto.randomUUID();
  const formHtml = `
    <form id="${formId}" action="/localization" method="POST" hidden>
      <input name="_method" value="PUT" />
      <input name="country_code" value="${country}" />
      <input name="language_code" value="${language}" />
    </form>
  `;
  document.body.insertAdjacentHTML("beforeend", formHtml);
  document.getElementById(formId).submit();
}
updateLocalization({country: "CA", language: "fr"});

Ini ngungkap apa yang {% form 'localization' %} bikin di balik layar: POST ke /localization dengan _method=PUT, country_code, dan language_code. Panggil ini dari tombol “Ya” di banner saran.

Pedoman UX Selector

1. Penempatan

Empat lokasi utama, dan kalau ada dua selector, selalu ditaruh bareng:

  1. Footer: di atas konten sub-footer, terpisah dari link navigasi footer.
  2. Dekat link akun pelanggan: kalau link akun dekat cart, selector juga dekat cart, dengan gaya yang mirip link akun.
  3. Dekat cart: kalau link akun jauh dari cart, taruh selector dekat cart biar kelihatan. Selector harus melengkapi, bukan bersaing sama cart: kalau cart di kanan, selector di kiri cart biar cart tetap gampang dijangkau.
  4. Di dalam navigation drawer: perlakukan sebagai link utilitas/footer, bukan link navigasi.

2. Styling

  1. Pakai styling dropdown theme sebanyak mungkin: tipografi, warna, tinggi, state hover.
  2. Boleh bikin komponen kustom (bukan <select> native), tapi sebagai popover, bukan modal, karena modal berlebihan buat beberapa opsi.
  3. State hover, focus, selected, pressed ngikutin pola link theme.
  4. Lebar menu ngikutin isinya biar string panjang gak wrap, tapi gak melewati viewport.
  5. Format: nama negara lengkap + kode mata uang + simbol: “United States (USD $)”, “United Kingdom (GBP £)”.

Ringkasan

  1. Shopify Markets = market dengan negara, mata uang, bahasa, dan URL sendiri. Theme nyediain pemilih dan URL yang bener, Plus dapat geolokasi otomatis, konten per market lewat store contextualization.
  2. Pemilih negara: {% form 'localization' %} + input country_code, dari localization.available_countries, cuma kalau > 1, format “Nama (KODE simbol)”.
  3. Pemilih bahasa: input language_code, dari available_languages, endonym_name, hreflang dan lang di link, cuma kalau > 1.
  4. JavaScript custom element: toggle panel, isi hidden input dari data-value, form.submit(), Escape nutup, plus fallback tanpa JavaScript.
  5. URL sadar locale: Shopify bikin /fr, /en-ca otomatis. Jangan hardcode, pakai object.url/routes di Liquid dan window.Shopify.routes.root (selalu diakhiri /) di JavaScript.
  6. SEO: hreflang otomatis lewat content_for_header, structured data pakai priceCurrency dari cart.currency.iso_code.
  7. Deteksi optimal: browsing_context_suggestions.json dengan country[enabled]/[exclude] dan language[enabled]/[exclude], terapin lewat form POST /localization dengan _method=PUT. Saranin, jangan paksa.
  8. UX: taruh di footer (atas sub-footer), dekat akun, atau kiri cart, atau sebagai utilitas di drawer; dua selector selalu bareng; popover bergaya dropdown theme, lebar ngikutin isi.

Bab selanjutnya kita bahas SEO: meta title dan description dari object page_title dan page_description, canonical URL, hreflang otomatis, Open Graph, structured data, metafield seo.hidden, dan kustomisasi robots.txt.