Hero Image by Gerd Altmann from Pixabay
Handling User Language Preference End-to-End in Ruby on Rails
Introduction
Hello my friends!
Today I want to share with you how we handle user language preferences end-to-end across our Ruby on Rails applications at Rayo (and rayo.gr).
Most Rails tutorials on internationalization (I18n) stop after showing how to read params[:locale] in ApplicationController. However, in a real-world production SaaS product, language preference spans multiple touchpoints:
- First-time visitors arriving on different country-code top-level domains (
.gr,.nl,.com) with various browserAccept-Languageheaders. - Handoffs across separate Rails applications, such as moving from the public marketing website (
www.rayo.gr/www.rayosms.com) to the core web application (sms.rayo.gr/app.rayosms.com). - Sign-up and email confirmation, where the visitor does not have a
Userrecord yet, or confirms their email from a different device or browser. - Sign-in, sign-out, and user preferences, where a signed-in user's stored preference should follow them across devices and survive
reset_session. - Asynchronous background emails, where
ActionMailerjobs run outside the HTTP request cycle and have no access tosessionorrequest.
Let's walk step by step through how you can design and implement this cleanly in your own Ruby on Rails projects.
1. Resolving the Locale for Public Visitors
In our public marketing application (www_rayo_gr), visitors are not authenticated. Still, we want to pick the most natural language for them on their very first visit while respecting any explicit choice they make.
We encapsulate this logic in a controller concern, set_locale.rb:
Let's examine the precedence order in resolved_locale:
- Explicit query parameter (
params[:locale]): If the URL contains?locale=el(and:elis inI18n.available_locales), it always wins. - Session (
session[:locale]): Once resolved, we storesession[:locale] = locale_to_setso subsequent requests stay in the same language without requiring?locale=on every URL. - Domain ccTLD (
locale_from_tld): When a first-time visitor lands onwww.rayo.gr, they expect Greek (:el) even if their operating system or browser happens to haveen-USas its primaryAccept-Language. Similarly, a visitor on.nlgets Dutch (:nl). - Browser
Accept-Languageheader: On generic top-level domains like.com(www.rayosms.com),locale_from_tldreturnsnil, so we use thehttp_accept_languagegem (http_accept_language.compatible_language_from(I18n.available_locales)) to match the visitor's browser preferences against our supported locales. - Default locale (
I18n.default_locale): If nothing matches, we fall back to:en.
Switching Languages Safely
When a user clicks a language flag in the header, we route them to GET /set_locale?locale=XX handled by locale_controller.rb:
Notice two subtle but important details here:
- We call
skip_before_action :set_localeand invokesuperinside the action itself, then redirect back to the page the user came from. - We pass
allow_other_host: falsetoredirect_back_or_to(orredirect_backin older Rails versions) so the endpoint cannot be abused as an open redirect via a forgedRefererheader.
2. Carrying the Locale Across Separate Rails Applications
At Rayo, the marketing site (www.rayosms.com) and the core web platform (app.rayosms.com) are separate Rails applications. Because they run on different subdomains with separate session cookies, a visitor who switches the marketing site to Greek or Dutch would lose that selection when clicking Sign Up or Sign In unless we pass it explicitly.
In our marketing site's application_helper.rb, we append ?locale=#{I18n.locale} to every cross-application entry link:
Because the core web application's SetLocale concern checks requested_locale (params[:locale]) first and immediately persists it into its own session[:locale], the handoff between the two applications is completely seamless!
3. Adding Authenticated User Preference to the Precedence Chain
Inside the core web application (sms_rayo_gr), we have both anonymous visitors (on login, sign-up, and contact pages) and authenticated users.
Here is the core platform's rayo_set_locale.rb:
Look closely at line 29 of resolved_locale:
- Right after
requested_locale(params[:locale]), we checkcurrent_user.language_preference. - Why is
current_user.language_preferenceplaced abovesession[:locale]? Because an authenticated user's saved preference is the source of truth across all their sessions and browsers. - Why is
requested_localestill placed first? So that an explicit?locale=XXparameter continues to work on public actions or when explicitly triggered.
Storing the User's Language Preference
We store per-user settings in a JSON-backed user_application_settings association via our user_with_application_settings.rb concern:
Before saving, set_language_preference normalizes the locale code (locale.to_s.downcase) and validates it against our supported languages (Rayo::BrandSupportedLanguage.valid_language_code?).
4. Persisting Language Preference Through Sign-Up and Confirmation
There is a classic gap in many Rails applications:
- A new user visits
/sign_ups/new?locale=nland fills out the registration form in Dutch. - Your app creates a pending sign-up request and sends a confirmation email.
- The user opens the confirmation email on their mobile phone (where the session cookie does not exist) and clicks the confirmation link.
If you only stored the locale in session[:locale], two things go wrong:
- The background worker sending the confirmation email doesn't know the user signed up in Dutch.
- When the user clicks the confirmation link on their phone, the newly created User record won't have nl saved as their language_preference.
To solve this, we added a locale column to sign_up_requests and wired the flow from form submission to confirmation as shown in sign_up_and_confirm_locale.rb:
- When the sign-up form is submitted,
Rayo::Forms::SignUp#saverecordsI18n.locale.to_sonto theRayo::SignUpRequestrecord. - When the user clicks the email confirmation link,
Rayo::Forms::SignUpConfirm#confirmcreates theRayo::Userand immediately copies@sign_up_request.localeinto@user.set_language_preference(@sign_up_request.locale). - Finally,
Rayo::SignUpsController#confirmsetsI18n.localeandsession[:locale]fromsign_up_confirm.user.language_preferencebefore rendering the flash message and redirecting to the login page!
5. Synchronizing Locale on Sign-In and Preserving It on reset_session
What happens when an existing user signs in or signs out? Let's look at sessions_controller_locale.rb:
There are two practical lessons here for any Rails app:
- Backfilling legacy users on sign-in (
sync_user_locale_on_sign_in): If an existing user does not have alanguage_preferencesaved in the database yet, we initialize it from their currentsession[:locale]. Then we synchronizeI18n.localeandsession[:locale]withuser.language_preferenceright before creating theflashes.welcomenotice—so even the welcome flash message is rendered in their preferred language. - Preserving
session[:locale]acrossreset_session: Callingreset_sessionon failed login or on sign-out is good security practice (preventing session fixation), butreset_sessionwipes the entire session hash—includingsession[:locale]. If you don't savelocale = session[:locale]beforereset_sessionand restore it right after, a user who logs out will suddenly see the login page and the goodbye flash message revert to another language!
6. Letting Users Update Their Language Preference in the App
Once signed in, users can change their language at any time from the top-level navigation menu via Rayo::PreferencesController (preferences_controller.rb):
Notice that Rayo is a multi-tenant, white-label platform where each brand can configure its own subset of supported languages (current_brand.supported_languages). When the user saves their preference:
- We validate that
selected_languageis included incurrent_brand.supported_languages. - We persist it on
current_user.set_language_preference(selected_language). - We immediately update
I18n.localeandsession[:locale]in the current request so thatflash[:success] = t('preferences.have_been_updated')is translated into the newly chosen language right away.
7. Localizing Transactional and Background Emails (ActionMailer)
Finally, let's talk about emails. In our platform, emails are delivered asynchronously via background jobs (such as daily SMS delivery reports, credit purchase confirmations, password resets, and team member invitations).
When a background worker executes a mailer method:
- There is no HTTP request or session.
- I18n.locale in the worker process defaults to I18n.default_locale.
- Even worse, if an admin or brand owner triggers an action that sends an email to a customer (or vice versa), the current user in the controller is not the recipient of the email!
To guarantee that every email is rendered in the recipient's preferred language, we introduced with_recipient_locale in UserMailer (user_mailer_locale.rb):
Here is how with_recipient_locale works:
- Resolve the recipient user: If the mailer already has the
recipient_userobject, we pass it directly. Otherwise (for example, inreset_password_email), it looks up theRayo::Userscoped to@brandby matchingcontact_emailorusername. - Support a
fallback_localefor non-users: What if the recipient doesn't have aUseraccount yet?- For
request_to_confirm_email_address, we passfallback_locale: sign_up_request.locale(which we captured in Step 4!). - For
we_received_your_message(contact form), we passfallback_locale: params[:locale]. - For
member_invitation_received, when inviting someone who doesn't have an account yet, we passfallback_locale: @member_invitation.user&.language_preferenceso the invitation defaults to the inviter's language.
- For
- Wrap
mail(...)inI18n.with_locale(locale): Always useI18n.with_localeinstead of mutatingI18n.localeglobally inside a mailer or background job.I18n.with_localeensures the locale is restored as soon as the block finishes, preventing locale leakage across jobs running in the same worker process.
Closing Note
Handling user language preference properly is one of those engineering details that makes an application feel polished and trustworthy to international users.
By combining:
- a clear resolution hierarchy (params[:locale] -> current_user.language_preference -> session[:locale] -> ccTLD -> Accept-Language -> I18n.default_locale),
- cross-domain ?locale= handoffs,
- persisting the locale on pending sign-up requests before the User record even exists,
- preserving session[:locale] across reset_session, and
- wrapping mailer actions in I18n.with_locale resolved from the recipient's preference,
you can provide a seamless localized experience from a visitor's very first page view all the way to every background email they receive.
Thank you for reading!