Shopify Country Selector in Liquid, No JavaScript Needed

A Shopify country selector in Liquid is a <select name="country_code"> inside {% form 'localization' %}. The form posts to /localization, Shopify switches the buyer’s country (and with it the currency, and often the market URL), then redirects back. Render it only when localization.available_countries.size > 1, and give it a real submit button so it works without JavaScript.

That is the whole mechanism. What goes wrong in custom themes is the field names, the way the form gets submitted, and the URLs the buyer meets after the redirect.

What does the localization form actually send?

The form tag reference shows the generated markup: a POST to /localization with hidden form_type=localization, _method=put and return_to inputs. Your job is to add the fields Shopify reads.

There are two. country_code takes an ISO 3166-1 alpha-2 code from localization.available_countries. language_code takes an ISO code from localization.available_languages. You can put one selector in the form, or both.

One naming wrinkle catches people who copy from Dawn. Shopify’s multiple currencies and languages guide names the language field language_code, and so does Horizon’s snippets/localization-form.liquid. Dawn’s snippets/language-localization.liquid sends locale_code instead, in v16.1.1 and as far back as v2.0.0. Dawn is Shopify’s own reference theme, so the endpoint evidently accepts both, but only language_code is documented. Use that one.

The guide also states a rule worth designing around: if the buyer picks a country whose market does not support the current language, Shopify switches them to that country’s default language. A combined form where someone changes both selects at once can therefore land on a different language from the one they chose. The country object’s own available_languages property lists what each country’s market supports, if you want to warn people.

A country selector in Liquid that works without JavaScript

Shopify’s guide builds its example as a disclosure button with a list of <a href="#"> items, and submits the form from a custom element’s click handler. The same page then says you should “also include a fallback in case JavaScript is disabled.” Dawn used to ship that fallback as a <noscript> copy of the form with a native <select>. That is two forms to keep in sync.

Starting from the native version and stopping there is simpler. This snippet renders both selectors in one form, pins popular countries to the top, and submits on a button press:

{% doc %}
  Country and language selector. Works without JavaScript: the
  customer picks from native selects and presses the button.

  @example
  {% render 'localization-selector' %}
{% enddoc %}

{%- liquid
  assign show_country = false
  if localization.available_countries.size > 1
    assign show_country = true
  endif

  assign show_language = false
  if localization.available_languages.size > 1
    assign show_language = true
  endif

  assign popular_countries = localization.available_countries | where: 'popular?' | sort: 'name'
-%}

{%- if show_country or show_language -%}
  {%- form 'localization', id: 'LocalizationForm', class: 'localization-form', return_to: 'back' -%}
    {%- if show_country -%}
      <label for="LocalizationCountry">{{ 'localization.country_label' | t }}</label>
      <select id="LocalizationCountry" name="country_code">
        {%- if popular_countries.size > 1 -%}
          <optgroup label="{{ 'localization.popular_countries' | t }}">
            {%- for country in popular_countries -%}
              <option value="{{ country.iso_code }}">
                {{ country.name }} ({{ country.currency.iso_code }} {{ country.currency.symbol }})
              </option>
            {%- endfor -%}
          </optgroup>
        {%- endif -%}
        <optgroup label="{{ 'localization.all_countries' | t }}">
          {%- for country in localization.available_countries -%}
            <option
              value="{{ country.iso_code }}"
              {% if country.iso_code == localization.country.iso_code %}
                selected
              {% endif %}
            >
              {{ country.name }} ({{ country.currency.iso_code }} {{ country.currency.symbol }})
            </option>
          {%- endfor -%}
        </optgroup>
      </select>
    {%- endif -%}

    {%- if show_language -%}
      <label for="LocalizationLanguage">{{ 'localization.language_label' | t }}</label>
      <select id="LocalizationLanguage" name="language_code">
        {%- for language in localization.available_languages -%}
          <option
            value="{{ language.iso_code }}"
            lang="{{ language.iso_code }}"
            {% if language.iso_code == localization.language.iso_code %}
              selected
            {% endif %}
          >
            {{ language.endonym_name | capitalize }}
          </option>
        {%- endfor -%}
      </select>
    {%- endif -%}

    <button type="submit">{{ 'localization.update' | t }}</button>
  {%- endform -%}
{%- endif -%}

Add the five localization.* keys to locales/en.default.json and render the snippet wherever the selector belongs. Shopify’s selector UX guidelines put it at the top of the sub-footer or beside the cart, and keep the two selectors together. They also ask for the full country name with the currency code and symbol beside it, which is what the option label prints.

Two choices in there need explaining.

country.popular? is documented as “useful for sorting countries in a country selector”, and both Dawn and Horizon use where: 'popular?' to build a short list once a store has more than nine countries. A native select already jumps to an option as you type its first letters, so the long list stays usable without a search box. Popular countries appear twice. Only the copy in the full list carries selected, so the closed control shows the current country once.

Why return_to: 'back' with quotes?

The form tag docs list back as a return_to value meaning “the same page that the customer was on”. Horizon writes it unquoted. Shopify CLI 4.9.4’s shopify theme check flags the unquoted form as UndefinedObject: Unknown object 'back' used, because Liquid reads a bare word as a variable. The quoted string passes cleanly. The generated form already includes a return_to input either way, so this is mostly about keeping the linter quiet in CI.

Should the selector submit on change?

Both of Shopify’s reference themes avoid a visible button. Dawn submits from a click on a list link. Horizon’s language control is a native <select> with on:change="/changeLanguage", and that handler calls form.submit().

The first is fine. The second is the case WCAG’s Understanding SC 3.2.2 On Input describes: “changing the selected option in a list control changes its setting”, while a button that submits a form and loads a new page “is activating a control”. A select that reloads the page in a different language the moment its value changes is a change of context triggered by a setting, which SC 3.2.2 (Level A) only allows if the user was told beforehand. The guidance names blind and low-vision users as the people most likely to miss that the page changed underneath them.

A submit button removes the question. If a designer pushes back on the extra control, keep the disclosure-of-links pattern from Dawn rather than wiring change to submit().

Where does the buyer land after switching?

On Shopify Plus, Shopify uses geolocation to pre-select the most relevant enabled country for each visitor. The selector still matters there, because geolocation guesses and the buyer corrects.

After a manual switch, the storefront URL may carry a market or language prefix. Shopify creates /fr when a French locale is published and paths like /en-ca for a new market, per the locale-aware URLs section of the guide.

That breaks any hardcoded path. A link to /cart or an Ajax call to /cart.js sends the buyer back to the domain defaults, which the guide warns can mean the wrong language or currency. The selector then looks broken one click after it worked. In Liquid, use the routes object (routes.cart_url, routes.root_url) or an object’s own url. In theme JavaScript, build from window.Shopify.routes.root, which always ends in a slash:

const root = window.Shopify?.routes?.root ?? '/';
const response = await fetch(`${root}cart.js`);
const cart = await response.json();
console.log(cart.currency);

Two SEO details sit on the same page of the guide. Shopify adds hreflang tags for you through content_for_header, so do not hand-roll them. Product structured data should take priceCurrency from cart.currency.iso_code, not the shop’s base currency, so the markup carries the currency the buyer is actually shown. If your header and footer live in section groups, our Shopify section groups guide covers getting the selector into the group without a static section tag.

What we checked, and what we did not

The snippet above passes shopify theme check from Shopify CLI 4.9.4 with no offences, in a minimal theme with the locale keys in place. That is the extent of the testing. We did not render it on a live store with several markets enabled, so the redirect behaviour, the language reset and the locale_code versus language_code point all rest on Shopify’s documentation and on reading the Dawn (v16.1.1) and Horizon (main branch as of 4 November 2026) source. Shopify’s own toolkit validator for Liquid would not run in our environment because of a missing dependency.

If you are building a theme from scratch, start from this native form and add the custom disclosure only when design needs it, keeping the button. If you are on Dawn or Horizon, leave their selector alone unless an accessibility audit or a market launch gives you a reason, and check for hardcoded /cart paths before you touch the selector itself. And put shopify theme check in CI, as in our theme check CI walkthrough, so the next unquoted back gets caught before review.

Whoooop builds and fixes Shopify themes for stores selling into more than one market, including the currency and URL issues that show up after a Markets launch. See our Shopify development work.

Need this built properly?

Get in touch