Handling User Language Preference - Ruby on Rails

How to resolve, persist, and apply user language preferences across web requests, sign-up flows, and background emails

Posted by Panos Matsinopoulos on 07/Nov/2026 (12:30)
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:

  1. First-time visitors arriving on different country-code top-level domains (.gr, .nl, .com) with various browser Accept-Language headers.
  2. 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).
  3. Sign-up and email confirmation, where the visitor does not have a User record yet, or confirms their email from a different device or browser.
  4. Sign-in, sign-out, and user preferences, where a signed-in user's stored preference should follow them across devices and survive reset_session.
  5. Asynchronous background emails, where ActionMailer jobs run outside the HTTP request cycle and have no access to session or request.

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:

  1. Explicit query parameter (params[:locale]): If the URL contains ?locale=el (and :el is in I18n.available_locales), it always wins.
  2. Session (session[:locale]): Once resolved, we store session[:locale] = locale_to_set so subsequent requests stay in the same language without requiring ?locale= on every URL.
  3. Domain ccTLD (locale_from_tld): When a first-time visitor lands on www.rayo.gr, they expect Greek (:el) even if their operating system or browser happens to have en-US as its primary Accept-Language. Similarly, a visitor on .nl gets Dutch (:nl).
  4. Browser Accept-Language header: On generic top-level domains like .com (www.rayosms.com), locale_from_tld returns nil, so we use the http_accept_language gem (http_accept_language.compatible_language_from(I18n.available_locales)) to match the visitor's browser preferences against our supported locales.
  5. 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_locale and invoke super inside the action itself, then redirect back to the page the user came from.
  • We pass allow_other_host: false to redirect_back_or_to (or redirect_back in older Rails versions) so the endpoint cannot be abused as an open redirect via a forged Referer header.

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 check current_user.language_preference.
  • Why is current_user.language_preference placed above session[:locale]? Because an authenticated user's saved preference is the source of truth across all their sessions and browsers.
  • Why is requested_locale still placed first? So that an explicit ?locale=XX parameter 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:

  1. A new user visits /sign_ups/new?locale=nl and fills out the registration form in Dutch.
  2. Your app creates a pending sign-up request and sends a confirmation email.
  3. 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:

  1. When the sign-up form is submitted, Rayo::Forms::SignUp#save records I18n.locale.to_s onto the Rayo::SignUpRequest record.
  2. When the user clicks the email confirmation link, Rayo::Forms::SignUpConfirm#confirm creates the Rayo::User and immediately copies @sign_up_request.locale into @user.set_language_preference(@sign_up_request.locale).
  3. Finally, Rayo::SignUpsController#confirm sets I18n.locale and session[:locale] from sign_up_confirm.user.language_preference before 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:

  1. Backfilling legacy users on sign-in (sync_user_locale_on_sign_in): If an existing user does not have a language_preference saved in the database yet, we initialize it from their current session[:locale]. Then we synchronize I18n.locale and session[:locale] with user.language_preference right before creating the flashes.welcome notice—so even the welcome flash message is rendered in their preferred language.
  2. Preserving session[:locale] across reset_session: Calling reset_session on failed login or on sign-out is good security practice (preventing session fixation), but reset_session wipes the entire session hash—including session[:locale]. If you don't save locale = session[:locale] before reset_session and 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:

  1. We validate that selected_language is included in current_brand.supported_languages.
  2. We persist it on current_user.set_language_preference(selected_language).
  3. We immediately update I18n.locale and session[:locale] in the current request so that flash[: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:

  1. Resolve the recipient user: If the mailer already has the recipient_user object, we pass it directly. Otherwise (for example, in reset_password_email), it looks up the Rayo::User scoped to @brand by matching contact_email or username.
  2. Support a fallback_locale for non-users: What if the recipient doesn't have a User account yet?
    • For request_to_confirm_email_address, we pass fallback_locale: sign_up_request.locale (which we captured in Step 4!).
    • For we_received_your_message (contact form), we pass fallback_locale: params[:locale].
    • For member_invitation_received, when inviting someone who doesn't have an account yet, we pass fallback_locale: @member_invitation.user&.language_preference so the invitation defaults to the inviter's language.
  3. Wrap mail(...) in I18n.with_locale(locale): Always use I18n.with_locale instead of mutating I18n.locale globally inside a mailer or background job. I18n.with_locale ensures 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!

Panos Matsinopoulos


If you liked this post, you can buy me a cup of coffee to keep me company when writing the next one.