Contents
Introduction
This tutorial explains, in full detail, how to add a simple language selector to a WordPress theme, persist the users language choice in the browser using JavaScript, and make that choice available to the server for subsequent requests. The solution covers accessible markup, CSS for basic styling, a robust JavaScript persistence strategy (localStorage cookie), and PHP integration in functions.php so WordPress can respect the users language preference on page loads.
Overview
The approach has four main parts:
- Client-side markup: place a language selector in your header (or another common area).
- Client-side persistence: set the chosen language in localStorage and in a cookie so the server sees it on the next request.
- Optional immediate server action: when the user picks a language, you may reload the page with a query parameter so server-side code can react immediately.
- Server-side integration (functions.php): read the cookie or query parameter, sanitize it, map it to WP locale strings, and use the locale filter to switch WordPress locale before translations are loaded.
- localStorage is convenient and persists between visits, but it is only accessible in JavaScript (not sent to the server).
- Cookies are sent in HTTP requests setting a cookie in JavaScript allows subsequent page loads to carry the selection to PHP.
- Using both means the front-end can read quickly from localStorage and the server can read from the cookie.
Requirements considerations
- Decide whether you will serve complete translated pages from PHP (recommended for SEO) or switch client-side text only.
- If you use WordPress translation APIs (gettext .mo/.po or plugins like WPML/Polylang), use the locale filter to set the active locale early.
- Sanitize all values coming from the client. Accept only known language codes (whitelist).
- Be mindful of caching: object caching, page caching, or CDN may serve cached pages. If you serve different content per language, configure caches to vary by cookie or URL.
- Accessibility: ensure the selector is keyboard-accessible and labeled.
- SEO: prefer language-specific URLs (example.com/fr/) or hreflang tags if you serve multiple languages for crawlers.
Step 1 — Markup (accessible selector)
Place this markup in your header.php (inside the theme) where you want the selector to appear. This example uses a simple unordered list with buttons (or links). It degrades nicely and is ARIA-friendly.
lt!-- header.php: language selector snippet --gt ltnav class=language-switcher aria-label=Language selectorgt ltulgt ltligtltbutton data-lang=en type=buttongtEnglishlt/buttongtlt/ligt ltligtltbutton data-lang=fr type=buttongtFrançaislt/buttongtlt/ligt ltligtltbutton data-lang=es type=buttongtEspañollt/buttongtlt/ligt lt/ulgt lt/navgt
Alternative: a native select element is also good for accessibility. If you want to use a select instead:
ltlabel for=site-langgtLanguage:lt/labelgt ltselect id=site-lang name=site-langgt ltoption value=engtEnglishlt/optiongt ltoption value=frgtFrançaislt/optiongt ltoption value=esgtEspañollt/optiongt lt/selectgt
Step 2 — Basic styling
A small CSS snippet to make the selector inline and visually pleasant. Add to your themes style.css or enqueue separately.
/ Basic language switcher styles / .language-switcher ul { list-style: none margin: 0 padding: 0 display: flex gap: 0.5rem align-items: center } .language-switcher button { background: transparent border: 1px solid #ccc padding: 0.25rem 0.5rem cursor: pointer border-radius: 3px } .language-switcher button.is-active { background: #0073aa color: #fff border-color: #0073aa }
This JavaScript:
- Defines a list of valid languages.
- Loads the current language from localStorage, cookie, or browser default.
- Updates the UI to reflect the selected language.
- On selection, stores the language in localStorage and a cookie. Optionally reloads the page with ?lang=xx so PHP can act immediately.
/ lang-selector.js Responsibilities: - Read current language (localStorage, cookie, or navigator) - Update UI to show current language - Persist user choice in localStorage and cookie - Optionally reload with ?lang=xx so server reacts immediately / / Configuration / (function () { var COOKIE_NAME = site_lang var COOKIE_DAYS = 365 var LANG_QUERY_PARAM = lang var VALID_LANGS = [en, fr, es] // whitelist — must match server mapping / DOM helpers / function el(selector, root) { return (root document).querySelector(selector) } function all(selector, root) { return Array.prototype.slice.call((root document).querySelectorAll(selector)) } / Cookie helpers / function setCookie(name, value, days) { var expires = if (days) { var date = new Date() date.setTime(date.getTime() (days 24 60 60 1000)) expires = expires= date.toUTCString() } // Path=/ so cookie is available on all pages SameSite=Lax is a sensible default document.cookie = name = encodeURIComponent(value) expires path=/ samesite=lax } function getCookie(name) { var match = document.cookie.match(new RegExp((^ ) name =([^]))) return match ? decodeURIComponent(match[2]) : null } / localStorage helpers / function getStoredLang() { try { return localStorage.getItem(site_lang) } catch (e) { return null } } function setStoredLang(lang) { try { localStorage.setItem(site_lang, lang) } catch (e) { / ignore / } } / Query param helper / function getQueryParam(name) { var params = new URLSearchParams(window.location.search) return params.get(name) } / Validate language / function isValidLang(lang) { return VALID_LANGS.indexOf(lang) !== -1 } / Determine the initial language: priority: ?lang -> cookie -> localStorage -> navigator.languages -> default en / function determineInitialLang() { var q = getQueryParam(LANG_QUERY_PARAM) if (q isValidLang(q)) return q var ck = getCookie(COOKIE_NAME) if (ck isValidLang(ck)) return ck var ls = getStoredLang() if (ls isValidLang(ls)) return ls var nav = (navigator.languages navigator.languages[0]) navigator.language navigator.userLanguage if (nav) { var short = nav.split(-)[0] if (isValidLang(short)) return short } return en // fallback } / Update html lang attribute and UI state / function applyLangToUI(lang) { try { document.documentElement.lang = lang } catch (e) { } // Mark active buttons all(.language-switcher [data-lang]).forEach(function (btn) { if (btn.getAttribute(data-lang) === lang) btn.classList.add(is-active) else btn.classList.remove(is-active) }) // If theres a select element var sel = el(#site-lang) if (sel) sel.value = lang } / When a language is selected / function onLanguageSelected(lang, options) { options = options {} if (!isValidLang(lang)) return setStoredLang(lang) setCookie(COOKIE_NAME, lang, COOKIE_DAYS) applyLangToUI(lang) if (options.reloadWithQuery) { // Reload page adding or replacing ?lang=xx — this lets server detect it immediately var url = new URL(window.location.href) url.searchParams.set(LANG_QUERY_PARAM, lang) window.location.href = url.toString() } } / Attach listeners to buttons and select / function attachListeners() { all(.language-switcher [data-lang]).forEach(function (btn) { btn.addEventListener(click, function (e) { var lang = btn.getAttribute(data-lang) onLanguageSelected(lang, { reloadWithQuery: true }) // reload so PHP picks it up }) }) var sel = el(#site-lang) if (sel) { sel.addEventListener(change, function (e) { onLanguageSelected(sel.value, { reloadWithQuery: true }) }) } } / Initialize / var current = determineInitialLang() applyLangToUI(current) attachListeners() })()
Step 4 — Server-side (functions.php)
Add the following to your themes functions.php (or a custom plugin). It does three things:
- Registers/enqueues the JavaScript file and localizes a list of valid languages to avoid duplication of configuration.
- Hooks the locale filter to set WordPress locale based on query parameter or cookie.
- Maps simple two-letter codes to WP locale strings (en -> en_US, fr -> fr_FR, etc.).
English, fr => Français, es => Español ) wp_add_inline_script( mytheme-lang-selector, window.MYTHEME_I18N = . wp_json_encode( array( available => array_keys( available ), default => en, cookieName=> site_lang, queryParam=> lang ) ) . , before ) } add_action( wp_enqueue_scripts, mytheme_enqueue_lang_selector ) // 2) Map short codes to WP locales function mytheme_map_shortcode_to_locale( code ) { map = array( en => en_US, fr => fr_FR, es => es_ES, // add more mappings as needed ) return isset( map[ code ] ) ? map[ code ] : null } // 3) Hook the locale filter so WordPress loads translations for the chosen language function mytheme_set_locale_from_cookie_or_query( locale ) { // Only allow known values (whitelist) allowed = array( en, fr, es ) // Priority: ?lang -> cookie -> existing locale chosen = null if ( isset( _GET[lang] ) ) { lang = sanitize_text_field( wp_unslash( _GET[lang] ) ) if ( in_array( lang, allowed, true ) ) { chosen = lang // set cookie so subsequent requests carry the choice // Use COOKIEPATH and COOKIE_DOMAIN as defaults expires in 1 year setcookie( site_lang, chosen, time() YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ) // Also set _COOKIE for current request _COOKIE[site_lang] = chosen } } elseif ( isset( _COOKIE[site_lang] ) ) { lang = sanitize_text_field( wp_unslash( _COOKIE[site_lang] ) ) if ( in_array( lang, allowed, true ) ) { chosen = lang } } if ( chosen ) { mapped = mytheme_map_shortcode_to_locale( chosen ) if ( mapped ) { return mapped } } return locale // fallback to default } add_filter( locale, mytheme_set_locale_from_cookie_or_query ) ?>
Important notes about the PHP approach
- The locale filter runs early, before many translations are loaded — that is why its the right place to change the locale.
- Setting cookies in PHP (setcookie) will only affect subsequent requests unless you also update the _COOKIE superglobal as shown above for the current request handling.
- Make sure to whitelist/validate values. Never directly trust a client-sent locale string.
- If you use a multilingual plugin (WPML, Polylang), they will likely handle URL structure, language switching, and locale in a more integrated way decide between plugin or custom approach.
Step 5 — Handling translated content and URLs
There are multiple strategies to show translated content to users and crawlers:
- URL-prefix approach (recommended for SEO): Use different URLs per language, e.g. /fr/about. This is compatible with search engines and is easiest for caching. Your selector can redirect to the equivalent language URL. WordPress plugins or rewrite rules can help implement this.
- Query-parameter approach: site.com/page?lang=fr — simpler but less SEO-friendly unless you add rel=alternate hreflang links.
- Cookie-only approach: serve same URL but detect cookie to choose content. This is easiest to implement but not ideal for SEO because search engines may not crawl language variations properly.
- Use WordPress translation functions and .mo files: if you translate theme strings via __(), _e(), etc., switching locale via the locale filter will cause WordPress to load the appropriate translation files.
- Use plugins such as WPML, Polylang, or Weglot if you need a full-featured solution (content translation, admin interface, URL management).
Advanced: Persisting choice for AJAX and REST API requests
Cookies are sent with AJAX and REST requests by default (for same-origin). If you use fetch() with credentials: include ensure cookies are forwarded. Alternatively, you can send the language as a custom header (X-Site-Lang) on each AJAX call. For REST API, you can read the header using rest_pre_dispatch or a custom endpoint.
Accessibility, SEO and security checklist
- Ensure the selector is keyboard operable and has readable text (not just flags).
- Set the lthtml lang=xxgt attribute to the active language so screen readers and search engines get accurate info.
- Add rel=alternate hreflang links in head for each language variant (or let a plugin generate them).
- Whitelist allowed language codes sanitize all inputs server-side.
- Consider caching implications: if you vary content by cookie, configure caches to consider that cookie or prefer language-specific URLs.
Edge cases and testing
- Test with cookies disabled: JS will still store language in localStorage, but server wont get cookie — either rely on client-side switching or reload with ?lang=xx to allow server detection via query string.
- Test different browsers and private mode (some browsers restrict localStorage in third-party contexts).
- Test with caching layers and CDNs to ensure visitors see content in the selected language.
- Test SEO: crawlers should be able to reach each language version prefer language-specific URLs or properly configured hreflang.
Complete example files
Below are consolidated example files you can drop into a theme (adjust paths and names as appropriate).
functions.php (complete example)
English, fr => Français, es => Español ) wp_add_inline_script( mytheme-lang-selector, window.MYTHEME_I18N = . wp_json_encode( array( available => array_keys( available ), default => en, cookieName=> site_lang, queryParam=> lang ) ) . , before ) } add_action( wp_enqueue_scripts, mytheme_enqueue_lang_selector ) function mytheme_map_shortcode_to_locale( code ) { map = array( en => en_US, fr => fr_FR, es => es_ES, ) return isset( map[ code ] ) ? map[ code ] : null } function mytheme_set_locale_from_cookie_or_query( locale ) { allowed = array( en, fr, es ) chosen = null if ( isset( _GET[lang] ) ) { lang = sanitize_text_field( wp_unslash( _GET[lang] ) ) if ( in_array( lang, allowed, true ) ) { chosen = lang setcookie( site_lang, chosen, time() YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ) _COOKIE[site_lang] = chosen } } elseif ( isset( _COOKIE[site_lang] ) ) { lang = sanitize_text_field( wp_unslash( _COOKIE[site_lang] ) ) if ( in_array( lang, allowed, true ) ) { chosen = lang } } if ( chosen ) { mapped = mytheme_map_shortcode_to_locale( chosen ) if ( mapped ) { return mapped } } return locale } add_filter( locale, mytheme_set_locale_from_cookie_or_query ) ?>
header.php snippet
lt!-- header.php: place the markup somewhere in your header template --gt ltnav class=language-switcher aria-label=Language selectorgt ltulgt ltligtltbutton data-lang=en type=buttongtEnglishlt/buttongtlt/ligt ltligtltbutton data-lang=fr type=buttongtFrançaislt/buttongtlt/ligt ltligtltbutton data-lang=es type=buttongtEspañollt/buttongtlt/ligt lt/ulgt lt/navgt
lang-selector.js
/ Put this file at /js/lang-selector.js in your theme / (function () { var COOKIE_NAME = (window.MYTHEME_I18N window.MYTHEME_I18N.cookieName) site_lang var COOKIE_DAYS = 365 var LANG_QUERY_PARAM = (window.MYTHEME_I18N window.MYTHEME_I18N.queryParam) lang var VALID_LANGS = (window.MYTHEME_I18N window.MYTHEME_I18N.available) [en,fr,es] function el(selector, root) { return (root document).querySelector(selector) } function all(selector, root) { return Array.prototype.slice.call((root document).querySelectorAll(selector)) } function setCookie(name, value, days) { var expires = if (days) { var date = new Date() date.setTime(date.getTime() (days 24 60 60 1000)) expires = expires= date.toUTCString() } document.cookie = name = encodeURIComponent(value) expires path=/ samesite=lax } function getCookie(name) { var match = document.cookie.match(new RegExp((^ ) name =([^]))) return match ? decodeURIComponent(match[2]) : null } function getStoredLang() { try { return localStorage.getItem(site_lang) } catch (e) { return null } } function setStoredLang(lang) { try { localStorage.setItem(site_lang, lang) } catch (e) { / ignore / } } function getQueryParam(name) { var params = new URLSearchParams(window.location.search) return params.get(name) } function isValidLang(lang) { return VALID_LANGS.indexOf(lang) !== -1 } function determineInitialLang() { var q = getQueryParam(LANG_QUERY_PARAM) if (q isValidLang(q)) return q var ck = getCookie(COOKIE_NAME) if (ck isValidLang(ck)) return ck var ls = getStoredLang() if (ls isValidLang(ls)) return ls var nav = (navigator.languages navigator.languages[0]) navigator.language navigator.userLanguage if (nav) { var short = nav.split(-)[0] if (isValidLang(short)) return short } return (window.MYTHEME_I18N window.MYTHEME_I18N.default) en } function applyLangToUI(lang) { try { document.documentElement.lang = lang } catch (e) {} all(.language-switcher [data-lang]).forEach(function (btn) { if (btn.getAttribute(data-lang) === lang) btn.classList.add(is-active) else btn.classList.remove(is-active) }) var sel = el(#site-lang) if (sel) sel.value = lang } function onLanguageSelected(lang, options) { options = options {} if (!isValidLang(lang)) return setStoredLang(lang) setCookie(COOKIE_NAME, lang, COOKIE_DAYS) applyLangToUI(lang) if (options.reloadWithQuery) { var url = new URL(window.location.href) url.searchParams.set(LANG_QUERY_PARAM, lang) window.location.href = url.toString() } } function attachListeners() { all(.language-switcher [data-lang]).forEach(function (btn) { btn.addEventListener(click, function (e) { var lang = btn.getAttribute(data-lang) onLanguageSelected(lang, { reloadWithQuery: true }) }) }) var sel = el(#site-lang) if (sel) { sel.addEventListener(change, function (e) { onLanguageSelected(sel.value, { reloadWithQuery: true }) }) } } var current = determineInitialLang() applyLangToUI(current) attachListeners() })()
style.css snippet
/ Minimal CSS for the language switcher / .language-switcher ul { list-style: none margin: 0 padding: 0 display: flex gap: 0.5rem } .language-switcher button { background: transparent border: 1px solid #ccc padding: 0.25rem 0.5rem cursor: pointer border-radius: 3px } .language-switcher button.is-active { background: #0073aa color: #fff border-color: #0073aa }
Troubleshooting tips
- If server-side locale change does not appear to take effect, ensure your code runs early enough (the locale filter must run before translation files are loaded).
- Inspect cookies in your browser devtools to confirm the site_lang cookie is being set with path=/ and expected value.
- If you use aggressive caching (page cache / Varnish / CDN), ensure cache varies by language cookie or prefer language-prefixed URLs to separate cached versions.
- When testing, clear cookies and localStorage to test first-load behavior accurately.
Final notes
This pattern is intentionally simple and robust: an accessible UI, client-side persistence for immediate UX, a cookie for server awareness, and a PHP hook to switch the WordPress locale. For larger multilingual needs (translation management, per-post translations, automatic content duplication), evaluate mature multilingual plugins that provide UI and administration features. If you keep the solution custom, always adhere to whitelisting and caching best practices.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |