i18n in Clojure: Multi-Language Support with Accept-Language Detection

Every user-facing string in this app must answer a question before it renders: which language does this visitor read? Retrofit that question onto a working codebase and you are hunting down hundreds of hardcoded strings scattered across dozens of view functions, and every string the hunt misses ships in the wrong language. Answer it from the first commit and the cost is one function call per string; after that, a new language is a new file.

The app ships in English and Dutch. English is the default (the lingua franca of SaaS, and the fallback for any visitor whose browser asks for a language we do not support); Dutch is served to visitors whose Accept-Language header prefers it. The approach: English-default with per-request detection from the browser’s Accept-Language header, and graceful fallback to the default locale for any key whose translation is missing.

The entire i18n implementation is four small pieces: translation maps as plain data, a lookup function with a fallback chain, RFC 4647 language detection through Java’s built-in support, and the Ring middleware that wires them 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 UI strings, keyed by namespaced keyword (e.g. :home/sign-in).
  Kept deliberately flat and data-only so it hot-reloads cleanly.")

(def translations
  "Dutch translations."
  {;; Landing page
   :home/tagline-1 "Git, maar dan voor koken."
   :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 mailen je een magic link om in te loggen. Geen wachtwoord nodig."

   ;; Auth flow
   :auth/check-email "Check je e-mail"
   :auth/email-sent-to "We hebben een magic link 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 terug"
   :dashboard/no-recipes "Je hebt nog geen recepten aangemaakt."

   ;; Errors
   :error/title "Fout"
   :error/invalid-magic-link "Ongeldige of verlopen magic link. Vraag een nieuwe aan."

   ;; Navigation
   :nav/browse "Recepten"
   :nav/new "Nieuw recept"
   :nav/dashboard "Dashboard"
   :nav/admin "Beheer"

   ;; Meta
   :meta/description
   "Een recepten-versiesite — fork recepten, pas ze aan en zie precies wat er veranderde. Git, maar dan voor koken."})

And the English equivalent:

(ns myapp.i18n.en
  "English UI strings, keyed by namespaced keyword (e.g. :home/sign-in).
  Kept deliberately flat and data-only so it hot-reloads cleanly.")

(def translations
  "English translations."
  {;; Landing page
   :home/tagline-1 "Git, but for cooking."
   :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 email 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 back"
   :dashboard/no-recipes "You haven't created any recipes yet."

   ;; Errors
   :error/title "Error"
   :error/invalid-magic-link "Invalid or expired magic link. Please request a new one."

   ;; Navigation
   :nav/browse "Recipes"
   :nav/new "New recipe"
   :nav/dashboard "Dashboard"
   :nav/admin "Admin"

   ;; Meta
   :meta/description
   "A recipe versioning site — fork recipes, tweak them, and see exactly what changed. Git, but for cooking."})

These are trimmed excerpts: the maps in the repo carry every key the app renders, including a set of rotating landing-page taglines (:home/tagline-1 through :home/tagline-6), of which the headline above is the first. One string should raise an eyebrow – :auth/email-sent-to ends in a trailing space because the view completes the sentence with a styled <span> holding the address. That is sentence assembly by concatenation, and what it costs is settled at the end of the chapter. The rest of the shape is deliberate.

Namespaced keywords. Every translation key uses a namespace: :home/sign-in, :auth/check-email, :nav/browse. 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. English is structurally privileged (it is the default and the fallback target), but neither file is allowed to drift: both should define exactly the same set of keys, because a key missing from Dutch silently falls back to English at runtime. That parity 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, and the
  default for visitors whose Accept-Language matches neither :en nor :nl."
  :en)

(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:

  1. Look up the key in the requested locale. If the user’s locale is :en and the key is :home/sign-in, return "Sign in".
  2. Fall back to the default locale (English). If the key is missing in the requested locale, try English. This means you can add a new key to the English file and it will show up in English for Dutch users until the Dutch translation is added. Better than a blank string or an error.
  3. Fall back to the key name. If the key is missing everywhere, return the name part of the keyword (e.g., :home/sign-in becomes "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.

The header itself (the comma-separated list and its q weights) is defined by the HTTP spec, RFC 9110. Matching the languages it names against the languages you support is specified by RFC 4647, and Java has built-in support for both. 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/of (name %)) (keys locale-vars))
            match (Locale/lookup ranges supported)]
        (when match (keyword (.getLanguage ^Locale match)))))
    (catch Exception _ nil)))

The function leans on Java for every hard part. Locale$LanguageRange/parse takes the raw header and returns preference ranges already sorted by quality value – the parsing, the q= weights, the subtag handling are all the JDK’s problem. The supported list is nothing but the keys of locale-vars (:nl, :en) as Locale objects. Locale/lookup then does RFC 4647 “lookup” matching, walking the visitor’s preferences in order and returning the first supported match – nl-NL matches :nl; de-DE matches nothing and yields nil – and the winner converts back to a keyword via .getLanguage.

One interop note earns the Locale/of here. Its predecessor, the Locale(String) constructor, was deprecated in JDK 19, and on the Temurin 25 this project pins javac would flag a call to it – but Clojure’s compiler does not surface Java deprecation at all, so a stray (Locale. …) would compile without a murmur. The strict flags from the strict-compilation chapter catch reflection and boxed math, not deprecation. This is the class of interop rot you catch only by reading, which is why the current factory is the one to reach for on purpose rather than the constructor that comes to hand first.

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

This middleware ties detection to the request lifecycle. It lives in myapp.web.routes alongside the rest of the middleware stack (not in myapp.i18n – the i18n namespace stays a pure translation/detection library with no Ring dependency, and the web layer is what wires it into requests):

(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:

  1. 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 book never wires up a language-picker control, but the middleware is ready for one the day you add it.
  2. Accept-Language header. Auto-detect from the browser. This is what fires for every first-time visitor.
  3. Default locale. English. If the browser sends no Accept-Language header (rare but possible) or sends only unsupported languages, the user gets English.

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). Those three are Ring’s library middleware; with wrap-locale the stack crosses into the app’s own, followed by wrap-no-cache-authenticated, the guard that marks authenticated responses Cache-Control: no-store so the browser’s back-forward cache cannot replay a page after logout (the login-flow chapter explains it). Every handler that runs after wrap-locale can read (: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, the logo, most of the 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-1)]]

   [: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, and in the view path no interpolation engine (the one string in the app that needs interpolation is the magic-link email body, filled by plain format; the closing section weighs that choice). 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 the dashboard."
  [request]
  (cond
    (:user-eid request) (response/redirect "/dashboard")
    (get-in request [:session :user-email])
    (-> (html (views/home-page (:locale request)))
        (assoc :session nil))
    :else (html (views/home-page (:locale request)))))

Two forward references ride along: html is the tiny 200-with-text/html helper from the Hiccup views chapter, and :user-eid is put on the request by wrap-current-user, the middleware in the companion repo’s auth stack that resolves the session’s email to a user entity once per request (the admin chapter walks the gate family that reads it). The handler redirects on :user-eid rather than on the raw session email for a reason – a stale cookie naming a deleted user must render the landing page and clear the session, not bounce to /dashboard only for the auth gate to bounce it straight back. For this chapter, the part that matters is (:locale request) flowing into the view call.

The locale also flows into the HTML lang attribute via the layout function:

(defn- base-layout
  "Base HTML5 wrapper."
  [locale & body]
  (h/html
    {:mode :html}
    (h/raw "<!DOCTYPE html>")
    [:html {: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. What the failure prints is both key sets in full; at well over a hundred keys per file, (clojure.set/difference nl-keys en-keys) at the REPL names the offender faster than eyeballing that diff. The test does not check the quality of translations (that is a human job), but it enforces 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-default-locale-for-unknown-locale
  ;; default-locale is :en, so an unsupported locale falls back to English.
  (is (= "Sign in" (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?

Not because this app avoids those features. It already uses three of them, each solved with one plain primitive, and each carrying a cost:

  • Interpolation. The magic-link email body is a translation with a %s in it, filled by (format (t locale :email/magic-link-body) magic-link) in myapp.auth.email (the login-flow chapter shows the listing). format is the entire engine. The cost: positional format strings bind arguments in the order the code supplies them, so a translation that needs them in a different order must resort to Java’s indexed %1$s specifiers, and nothing in the i18n layer tells a translator that.
  • Sentence assembly. The check-your-email page renders (t locale :auth/email-sent-to) followed by a styled [:span] holding the address, which is why the English value is the fragment "We sent a magic link to " with its trailing space. The cost: the fragment hardcodes a word order. The address must end the sentence in every language; English and Dutch happen to agree, and the key-parity test cannot notice when a third language does not.
  • Date formatting. Timestamps go through a per-locale java.time DateTimeFormatter in myapp.web.views, built with (Locale/of (name locale)) and selected by the request’s locale, so a recipe date renders 3 jul 2025 on a Dutch page and 3 Jul 2025 on an English one. java.time localizes month names (and weekday names, when a pattern asks for them) natively; the whole feature is threading :locale into the formatter, no library required. The cost: the format pattern itself (d MMM yyyy, HH:mm) is fixed, so the names localize but the field order does not – a locale that conventionally writes the parts in a different order would need a localized format style, not just a swapped Locale.

So the honest form of the argument is not “we do not need those features.” It is that every feature this app needs so far sits one plain Clojure or JDK primitive away, and the fragilities the primitives bring (fixed argument order, fixed word order) stay invisible for as long as the shipped languages happen to agree. For English and Dutch they do. The machinery itself – t, detect-locale, wrap-locale, and the locale vars – stays a few dozen lines that handle two languages, fall back gracefully, auto-detect from the browser, and hot-reload during development.

Starting with a library means learning its API, understanding its configuration, and working around its opinions. Writing it ourselves means we understand every line, can change any behavior, and have zero dependencies to track. The threshold where that reverses is visible from the list above: plural rules, grammatical gender, argument reordering, translations produced by someone other than the developers. The moment a translator who does not read Clojure needs to move an argument inside a sentence, format strings and concatenated fragments stop being primitives and start being bugs, and a message-format layer built for reordering and plurals (tongue, or ICU MessageFormat) wins.

The choice at that threshold is worth drawing out, because it is really two choices, and only one of them is “a library.” The first is a format: ICU MessageFormat (and its recently-standardized Unicode successor, MessageFormat 2.0) is a language-neutral way to write a message with its plurals, genders, and reorderable placeholders held inline ({count, plural, one {# recipe} other {# recipes}}). Its deeper value lies past the pluralizing: it is the lingua franca the translation industry already speaks. A message in this format is an artifact a translator owns in their own tooling (Weblate, Crowdin, a TMS), not a Clojure format string only a developer can safely touch. Choosing it is choosing that your translations outlive this codebase’s choice of language and framework. The second choice is the runtime that reads the format on the JVM: ICU4J for the mature classic syntax, or its newer message2 implementation for MF2. And here the book’s own rule applies: MF2 is the direction of travel, but its JVM implementations are still young enough that adopting it today is a bet, not a default, so we would name it as the seam to cross rather than claim it as one we drilled. For two Germanic languages that share plural rules and word order, none of this pays; the primitives hold. The day a plural-rich or right-to-left language arrives, the move is not to reach for a Clojure-specific i18n library but past it, for the standard, because the thing you most want to be portable, the day you have real translators, is the translations.

When the app grows a third language, the mechanical work is small: create myapp/i18n/de.clj, add :de #'de/translations to locale-vars, and translate the keys. Detection, fallback, middleware, and the view layer need no changes. But “translate the keys” now reads with the costs priced in: German word order gets a vote on the concatenated fragment, and the date formatter hands each locale its own month names but still imposes a single field order on all of them. The architecture makes a new language cheap. The list above is what keeps it from being free.

The strings now have one path to travel, from a translation map to the page. What the pages themselves look like is the next problem: the Tailwind chapter builds the styling system, and the Hiccup views chapter builds the views whose every visible string is a t call.