i18n in Clojure: Multi-Language Support with Accept-Language Detection
If your SaaS targets a specific market, you will need translations from day one. Not "someday when we go international" -- from the first commit. Adding i18n to an existing codebase means hunting down hundreds of hardcoded strings scattered across dozens of view functions. Adding it from the start means every string goes through the same path, and supporting a new language is just adding a file.
My primary market is the Netherlands, so Dutch is the default language. But the app also needs to work in English -- for international users, for demos, and because English is the lingua franca of SaaS. The approach: Dutch-first with English fallback, detected automatically from the browser's Accept-Language header.
This post walks through the entire i18n implementation: translation maps as plain data, a lookup function with a fallback chain, RFC 4647 language detection using Java's built-in support, and Ring middleware that wires it all together.
Translation Maps as Plain Clojure Data
There is no framework here. Translations are plain Clojure maps in plain Clojure files, one per language. Here is a trimmed version of the Dutch translations:
(ns myapp.i18n.nl
"Dutch (Nederlands) translations. Primary locale -- all keys must be defined here.
English falls back to Dutch for any missing keys.")
(def translations
"Dutch UI strings, keyed by namespaced keyword."
{;; Landing page
:home/title "myapp — Serieuze boekhoudsoftware die je gewoon begrijpt"
:home/tagline "Boekhouden zonder boekhoudkennis."
:home/get-started "Aan de slag"
:home/email-label "E-mailadres"
:home/email-placeholder "jij@voorbeeld.nl"
:home/sign-in "Inloggen"
:home/magic-link-explanation
"We sturen je een inloglink per e-mail. Geen wachtwoord nodig."
;; Auth flow
:auth/check-email "Controleer je e-mail"
:auth/email-sent-to "We hebben een inloglink gestuurd naar "
:auth/link-expires
"Klik op de link in de e-mail om in te loggen. De link verloopt over 15 minuten."
:auth/sign-out "Uitloggen"
;; Dashboard
:dashboard/welcome "Welkom bij myapp"
:dashboard/placeholder "Je boekhouddashboard verschijnt hier."
;; Errors
:error/title "Fout"
:error/invalid-magic-link "Ongeldige of verlopen inloglink. Vraag een nieuwe aan."
;; Navigation
:nav/daily "Dagelijks"
:nav/billing "Factureren"
:nav/insights "Inzicht"
:nav/settings "Beheer"
;; Meta
:meta/description
"Serieuze boekhoudsoftware die je gewoon begrijpt."})
And the English equivalent:
(ns myapp.i18n.en
"English translations. Secondary locale -- should define the same keys as nl.clj.")
(def translations
"English UI strings, keyed by namespaced keyword."
{;; Landing page
:home/title "myapp — Serious accounting software you can use without understanding accounting"
:home/tagline "Accounting without the accounting knowledge."
:home/get-started "Get Started"
:home/email-label "Email address"
:home/email-placeholder "you@example.com"
:home/sign-in "Sign in"
:home/magic-link-explanation
"We'll send you a magic link to sign in. No password needed."
;; Auth flow
:auth/check-email "Check your email"
:auth/email-sent-to "We sent a magic link to "
:auth/link-expires "Click the link in the email to sign in. The link expires in 15 minutes."
:auth/sign-out "Sign out"
;; Dashboard
:dashboard/welcome "Welcome to myapp"
:dashboard/placeholder "Your accounting dashboard will appear here."
;; Errors
:error/title "Error"
:error/invalid-magic-link "Invalid or expired magic link. Please request a new one."
;; Navigation
:nav/daily "Daily"
:nav/billing "Billing"
:nav/insights "Insights"
:nav/settings "Settings"
;; Meta
:meta/description
"Serious accounting software you can use without understanding accounting."})
A few things to notice.
Namespaced keywords. Every translation key uses a namespace: :home/sign-in, :auth/check-email, :nav/settings. This prevents collisions as the app grows and makes it easy to find where a translation is used -- grep for :auth/check-email and you find both the translation definition and every view that uses it.
One file per language. Not one file per page, not one giant EDN resource file. One namespace per language. The Dutch file is the canonical set of keys. The English file should define exactly the same keys. This is easy to enforce with a test (more on that later).
Plain def, not defonce. The translations are defined with a regular def, which means a file watcher (like Beholder) can hot-reload them during development. Change a translation, save the file, see it in the browser. No restart needed.
The t Function
The core of the i18n system is a single function:
(ns myapp.i18n
"Internationalization.
Provides the t function for translating namespaced keys by locale."
(:require
[myapp.i18n.en :as en]
[myapp.i18n.nl :as nl])
(:import
[java.util Locale Locale$LanguageRange]))
(set! *warn-on-reflection* true)
(def default-locale
"Fallback locale when a key is missing in the requested locale."
:nl)
(def ^:private locale-vars
"Map of locale keyword to the var holding its translation map.
Var derefs ensure hot-reloaded translations take effect immediately."
{:nl #'nl/translations
:en #'en/translations})
(defn t
"Translate key for locale. Falls back to default locale, then to key name."
[locale k]
(or
(when-let [v (locale-vars locale)]
(get @v k))
(get @(locale-vars default-locale) k)
(name k)))
The t function takes a locale keyword and a translation key, and returns a string. It has a three-step fallback chain:
- Look up the key in the requested locale. If the user's locale is
:enand the key is:home/sign-in, return"Sign in". - Fall back to the default locale (Dutch). If the key is missing in the requested locale, try Dutch. This means you can add a new key to the Dutch file and it will show up in Dutch for English users until the English translation is added. Better than a blank string or an error.
- Fall back to the key name. If the key is missing everywhere, return the name part of the keyword (e.g.,
:home/sign-inbecomes"sign-in"). This is a development safety net -- you see untranslated keys in the UI immediately, and they are ugly enough that you fix them.
Why Var References?
The locale-vars map stores var references (#'nl/translations) rather than direct references to the maps. This is a subtle but important detail for development workflow.
When the file watcher detects a change to myapp/i18n/nl.clj, it reloads the namespace, which re-evaluates the def and updates the var's value. If locale-vars held a direct reference to the old map value, you would need to restart the REPL to pick up changes. With var references, @v dereferences the var at call time, always getting the current value. Hot reload just works.
In production this makes no difference -- the translations are loaded once and never change. The var deref adds negligible overhead (it just reads the var's current root binding -- a volatile field read, not a map rebuild). But during development it means you can iterate on translations without restarting anything.
Accept-Language Detection
When a Dutch user visits the app, their browser sends a header like:
Accept-Language: nl-NL,nl;q=0.9,en;q=0.8
This tells the server: "I prefer Dutch (Netherlands), then Dutch in general, then English." The quality values (q=0.9, q=0.8) express relative preference.
Parsing this header correctly is specified by RFC 4647, and Java has built-in support for it. No need for a library:
(defn detect-locale
"Detect best matching locale from an Accept-Language header value.
Uses Java's built-in RFC 4647 language matching. Returns nil if no match."
[accept-language]
(try
(when accept-language
(let [ranges (Locale$LanguageRange/parse accept-language)
supported (mapv #(Locale. (name %)) (keys locale-vars))
match (Locale/lookup ranges supported)]
(when match (keyword (.getLanguage ^Locale match)))))
(catch Exception _ nil)))
Here is what happens step by step:
-
Parse the header.
Locale$LanguageRange/parsetakes the raw header string and returns a list ofLanguageRangeobjects, sorted by quality value. Java handles the parsing, the quality values, the subtag matching -- all of it. -
Build the supported locale list. We take the keys from
locale-vars(:nl,:en) and convert them toLocaleobjects. This is our "we support these" list. -
Match.
Locale/lookupperforms RFC 4647 "lookup" matching: it walks through the user's preferences in order and returns the first one that matches a supported locale. If someone sendsnl-NL, it matches:nl. If they sendde-DE, it matches nothing and returnsnil. -
Convert back. The matched
Localeobject is converted back to a keyword with.getLanguage.
The try/catch around the whole thing is a safety net. Browsers can send malformed headers, and we would rather return nil (falling through to the default locale) than crash the request.
This function does not cover every edge case in the RFC. It does not handle region-specific variants or script subtags. But for a two-language app, it is exactly right. When you add a third language, you add one entry to locale-vars and this function handles the matching automatically.
The wrap-locale Middleware
The middleware ties detection to the request lifecycle:
(defn wrap-locale
"Determines locale from session, Accept-Language header, or default.
Assocs :locale onto request."
[handler]
(fn [request]
(let [locale (or
(get-in request [:session :locale])
(i18n/detect-locale (get-in request [:headers "accept-language"]))
i18n/default-locale)]
(handler
(assoc request
:locale locale)))))
Three-step resolution, in priority order:
- Session. If the user has explicitly chosen a language (via a language picker, for example), that choice is stored in the session and takes priority. This is not implemented yet in the UI, but the middleware is ready for it.
- Accept-Language header. Auto-detect from the browser. This is what fires for every first-time visitor.
- Default locale. Dutch. If the browser sends no Accept-Language header (rare but possible) or sends only unsupported languages, the user gets Dutch.
The result is a :locale keyword (:nl or :en) assoc'd onto the request map, available to every handler downstream.
Middleware Stack Position
Where wrap-locale sits in the middleware stack matters:
(ring/ring-handler
(ring/router routes)
(ring/create-default-handler)
{:middleware [[params/wrap-params]
[keyword-params/wrap-keyword-params]
[session/wrap-session
{:store (cookie/cookie-store {:key (config/get-config :session-key)})
:cookie-name "session"
:cookie-attrs {:http-only true
:secure true
:same-site :lax
:max-age (* 30 24 60 60)}}]
[wrap-locale]
[wrap-no-cache-authenticated]]})
It comes after wrap-session (because it reads :session) and after wrap-params/wrap-keyword-params (no dependency, but those need to be early). It comes before the application-specific middleware like wrap-no-cache-authenticated. Every handler that runs after wrap-locale can access (:locale request) without thinking about detection logic.
Using Translations in Views
With the middleware in place, every handler receives a request with a :locale key. Handlers pass it to view functions, and views use t to look up strings. Here is a simplified version of the home-page we build in the Hiccup views chapter -- the layout chrome (public-layout, logo, Tailwind classes) is stripped out so the t calls stand out:
(ns myapp.web.views
(:require [myapp.i18n :refer [t]]))
(defn home-page
"Landing page (simplified -- see the Hiccup views chapter for the full version)."
[locale]
[:div.space-y-8
[:div.text-center
[:p.mt-2.text-lg (t locale :home/tagline)]]
[:div.bg-surface.py-8.px-6
[:h2.text-2xl.font-semibold (t locale :home/get-started)]
[:form {:method "POST" :action "/auth/request"}
[:div
[:label {:for "email"} (t locale :home/email-label)]
[:input {:type "email"
:id "email"
:name "email"
:placeholder (t locale :home/email-placeholder)}]]
[:div.mt-6
[:button {:type "submit"} (t locale :home/sign-in)]]]
[:p.mt-4.text-xs (t locale :home/magic-link-explanation)]]])
The pattern is consistent everywhere: (t locale :namespace/key). No special syntax, no template directives, no interpolation engine. Just a function call that returns a string. Hiccup treats it like any other string value.
The handler bridges the request and the view:
(defn home
"Landing page handler. Redirects authenticated users to dashboard."
[request]
(if (get-in request [:session :user-email])
(response/redirect "/dashboard")
{:status 200
:headers {"Content-Type" "text/html"}
:body (str (views/home-page (:locale request)))}))
The locale also flows into the HTML lang attribute via the layout function:
(defn- base-layout
"Base HTML5 wrapper."
[locale & body]
(page/html5
{:lang (name locale)}
[:head
[:meta {:name "description"
:content (t locale :meta/description)}]
;; ...
]
[:body body]))
This means <html lang="nl"> or <html lang="en"> is set correctly for every page, which matters for screen readers, search engines, and browser features like auto-translation prompts.
Keeping Translations in Sync
One risk with separate translation files is drift -- adding a key to Dutch and forgetting to add it to English. A simple test catches this:
(deftest both-locales-have-same-keys
(let [nl-keys (set (keys myapp.i18n.nl/translations))
en-keys (set (keys myapp.i18n.en/translations))]
(is (= nl-keys en-keys)
"Dutch and English should define the same set of keys")))
When you add a key to nl.clj and forget en.clj, this test fails and tells you exactly which keys are missing. It does not check the quality of translations -- that is a human job -- but it ensures coverage.
The test suite also verifies the fallback chain and header detection:
(deftest t-returns-dutch-for-nl-locale
(is (= "Inloggen" (i18n/t :nl :home/sign-in))))
(deftest t-returns-english-for-en-locale
(is (= "Sign in" (i18n/t :en :home/sign-in))))
(deftest t-falls-back-to-dutch-for-unknown-locale
(is (= "Inloggen" (i18n/t :fr :home/sign-in))))
(deftest t-falls-back-to-key-name-for-missing-key
(is (= "nonexistent" (i18n/t :nl :home/nonexistent))))
(deftest detect-locale-dutch-browser
(is (= :nl (i18n/detect-locale "nl-NL,nl;q=0.9,en;q=0.8"))))
(deftest detect-locale-respects-quality
(is (= :en (i18n/detect-locale "nl;q=0.8,en;q=0.9"))))
(deftest detect-locale-unsupported-language-returns-nil
(is (nil? (i18n/detect-locale "de-DE,de;q=0.9"))))
(deftest detect-locale-malformed-header-returns-nil
(is (nil? (i18n/detect-locale ";;;garbage!!!"))))
The quality-value test is worth noting: when a browser sends nl;q=0.8,en;q=0.9, English wins despite Dutch appearing first in the header. The RFC 4647 matching handles this correctly because Locale$LanguageRange/parse sorts by quality value.
Why Not a Library?
There are Clojure i18n libraries: tongue, tempura, and others. They handle pluralization, interpolation, number formatting, date formatting, and more. Why not use one?
Because I do not need those features yet. The entire i18n system is about 30 lines of code. It handles two languages, falls back gracefully, auto-detects from the browser, and hot-reloads during development. When I need pluralization or interpolation, I will either add a few more lines to t or pull in a library at that point.
Starting with a library means learning its API, understanding its configuration, and working around its opinions. Starting with 30 lines of code means I understand every line, can change any behavior, and have zero dependencies to track. The Algorithm says: question every requirement, delete what you can, simplify before optimizing. A 30-line solution that covers the actual requirements beats a full-featured library that covers hypothetical ones.
What You Have Now
After implementing this:
- Translation maps as data. Two Clojure files, one per language, with namespaced keyword keys. Adding a new string is adding a map entry.
- A
tfunction with graceful fallback. Requested locale, then default locale, then key name. No crashes, no blank strings. - Automatic language detection. The browser's Accept-Language header is parsed using Java's RFC 4647 implementation. Dutch users get Dutch. English users get English. Everyone else gets Dutch.
- Ring middleware. One middleware function resolves the locale and puts it on the request. Every handler downstream gets it for free.
- View integration. Hiccup views call
(t locale :key)wherever they need text. The HTMLlangattribute is set correctly. - Test coverage. Key parity between languages is enforced. The fallback chain is verified. Header parsing edge cases are covered.
The whole system is about 30 lines of implementation code, two translation files, and a middleware function. No dependencies beyond what Java provides. It handles the actual requirements (two languages, auto-detection, fallback) without ceremony.
When the app grows to need a third language, the work is: create myapp/i18n/de.clj, add :de #'de/translations to locale-vars, and translate the keys. The detection, fallback, middleware, and view layer all work without changes. That is the payoff of getting the architecture right from the start.