International marketing

Rails Internationalization from URLs to JavaScript

Rails Internationalization from URLs to JavaScript
Updated on
May 18, 2026

Rails ships with an internationalization framework built in. You don't need much more to get started – the I18n API is part of the stack, and it handles the basics well: translating static strings, formatting dates and numbers, and managing pluralization across locales.

But "built-in" doesn't mean "complete." The moment your requirements extend to localized URLs, JavaScript translations, or multilingual SEO, you're working outside what Rails provides natively. Those gaps require deliberate architecture decisions.

Let’s go over both layers: what the Rails I18n API handles, and where you'll need to build – or delegate.

What Rails I18n Covers and Where It Stops

The I18n API gives you two core methods: I18n.t for translating strings and I18n.l for localizing dates and numbers. Both read from YAML or Ruby files in config/locales/, and Rails loads them automatically at startup.

Out of the box, that covers static UI strings, date and number formatting, and ActiveRecord validation messages. For most single-language-to-one-language projects, it's enough to ship.

The gaps show up fast once you go further.

Rails I18n has no opinion on URL structure: no localized routes, subdomain detection, or path prefixes. It has no mechanism for translating database-stored content like product names or blog posts. JavaScript files sit entirely outside the pipeline. And multilingual SEO, things like hreflang tags, translated slugs, and language-specific sitemaps, require separate work that the framework doesn't touch.

You need to know where the boundary sits before you start building.

Setting Up the I18n Foundation

You can kick things off with the rails-i18n gem.

Rails only ships English locale data, so without it, I18n.l(Date.today) fails in non-English locales and framework-level strings like Active Record validation messages stay in English regardless of the current locale.

# Gemfile

gem "rails-i18n"

Then configure your application defaults:

# config/application.rb

config.i18n.default_locale = :en

config.i18n.available_locales = [:en, :fr]

config.i18n.enforce_available_locales = true

enforce_available_locales = true raises an error if your app tries to set a locale outside that list. Without it, a typo or a malformed URL parameter silently sets an unsupported locale in production.

While modern Rails scans one level deep, it often misses deeply nested folders (like config/locales/views/products/). Adding this line ensures every subdirectory is included as your app grows:

config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}")]

This lets you split translation files by domain:

config/locales/  
	en/    
		models.yml    
		views.yml    
		mailers.yml  
	fr/    
		models.yml    
		views.yml    
		mailers.yml

⚠️ YAML parses values like true and false as booleans. If you need them as literal text, quote them explicitly. Modern Ruby/YAML versions now treat yes and no as strings, but quoting them remains a safe habit for compatibility:

en:  
	answers:    
		affirmative: "yes"    
		negative: "no"

This catches a class of bug that only surfaces when a translation returns true instead of ”yes” and your view renders nothing, or worse, the string ”true”.

Translation Helpers and Lazy Lookup in Views

In views, t and l are the two helpers you'll use constantly.

l localizes dates, times, and numbers according to the current locale. With rails-i18n installed, format definitions for 100+ locales come included. Without it, calling l(Date.today) in a non-English locale will usually return a 'translation missing' string or an unformatted date, as Rails lacks the necessary localized format patterns.

<%= l(Date.today) %>

<%= l(Date.today, format: :long) %>

t translates strings by key. The full form is t("products.index.title"), but in views you can use the lazy lookup shorthand:

<%= t(".title") %>

The leading dot tells Rails to resolve the key relative to the current view path. In app/views/products/index.html.erb, t(".title") expands to t("products.index.title") automatically. It keeps keys short and enforces a consistent naming convention without extra effort.

For interpolation, use %{variable} in your YAML and pass the value as a keyword argument:

en:  
	welcome: "Hello, %{name}"
<%= t(".welcome", name: current_user.name) %>

If a translation contains HTML you trust, append _html to the key name. Rails marks it safe automatically, without requiring an html_safe call.

en:  
	notice_html: "Please <strong>confirm</strong> your email."

Lazy lookup only works where Rails can infer the view path, which means it doesn’t work in background jobs and API controllers. In those contexts, use the full key every time.

Choosing A Locale Detection Strategy

Before writing any locale-detection code, pick a strategy. There are five main approaches, and the right one depends on your app's structure and SEO requirements.

Strategy Example Best for
URL parameter /products?locale=fr Internal tools, quick prototyping
Path prefix /fr/products Public sites needing multilingual SEO
Subdomain fr.example.com Distinct regional brands
TLD example.fr Country-specific domains you already own
DB preference Stored on user record Authenticated apps with user settings

Path prefix, like /fr/products, is the most common choice for public-facing Rails apps. It keeps locale explicit in every URL, which search engines can index independently per language.

Subdomain and TLD strategies work well when you want a stronger regional identity, but they add DNS configuration and SSL certificate overhead.

URL parameters, like /products?locale=fr, are fine for internal tools where SEO doesn't matter.

DB-stored preferences work for authenticated apps where the locale follows the user, not the URL.

Whichever strategy you pick, there's one implementation mistake that causes subtle production bugs: using I18n.locale = directly.

# Don't do this

before_action { I18n.locale = params[:locale] }

I18n.locale = writes to Thread.current.

If you’re using a Puma web server, it reuses threads across requests. If a request doesn't explicitly set the locale, it inherits whatever the previous request left behind.

In local development with a single thread, this never surfaces. In production, it causes intermittent wrong-language responses that are hard to reproduce and harder to trace.

Use I18n.with_locale inside an around_action instead:

around_action :switch_locale‍d


ef switch_locale(&action)  
	locale = params[:locale] || I18n.default_locale  
	I18n.with_locale(locale, &action)
end

with_locale restores the previous locale after the block exits, regardless of how the request ends.

Implementing Localized Routes and Subdomain Detection

Scope your routes under /:locale and override default_url_options so Rails automatically prepends the current locale to every URL helper:

# config/routes.rb
scope "/:locale" do
  resources :products
  root "home#index"
end

# app/controllers/application_controller.rb
around_action :switch_locale

def switch_locale(&action)
  locale = params[:locale]
  # Fallback to default if the param is missing or unsupported
  valid_locale = I18n.available_locales.map(&:to_s).include?(locale) ? locale : I18n.default_locale
  I18n.with_locale(valid_locale, &action)
end

def default_url_options
  { locale: I18n.locale }
end

With default_url_options set, products_path renders as /fr/products automatically when the current locale is :fr.

If you want the default locale to omit the prefix, make the segment optional with scope "(:locale)". This serves /products for English and /fr/products for French, but adds ambiguity. A path like /about could match either a missing locale or a controller named about, depending on your route order.

For subdomain detection, read from request.subdomains.first and validate against available_locales before setting anything:

def extract_locale_from_subdomain
  subdomain = request.subdomains.first
  return nil if subdomain.blank? || subdomain == "www"
  subdomain if I18n.available_locales.map(&:to_s).include?(subdomain)
end

The www check prevents it from being treated as a locale. On localhost, request.subdomains returns an empty array, so subdomain detection fails in development unless you use a Pow server or add entries to /etc/hosts.

One thing to configure separately: default_url_options defined in ApplicationController doesn't propagate to mailers or background jobs. Set it explicitly for those contexts:

Rails.application.routes.default_url_options = { host: "example.com", locale: :en }

Localized routes give you the URL structure, but hreflang tags, translated slugs, and multilingual sitemaps remain entirely manual after this setup.

Weglot's reverse proxy integration handles that layer automatically, generating hreflang tags and language-specific URLs without additional configuration.

Pluralization, Fallbacks, and the rails-i18n Gem

English pluralization is simple. One form for singular, one for everything else.

en:
  messages:
    one: "%{count} message"
    other: "%{count} messages"

Most languages aren't that simple.

Bulgarian, for example, has a regular plural and a special “counting plural” for some words in the masculine form. So ден (day) becomes дни (many days) normally, but два дена (two days) when counted.

bg:
  cities:
    one: "%{count} ден"
    few: "%{count} дена"
    many: "%{count} дни"

Without rails-i18n, Rails has no knowledge of those rules. Your Bulgarian translations would either error or fall back to the other form for every count.

The gem ships pluralization logic for 100+ locales, so things resolve correctly in Bulgarian, Russian, Arabic, Polish, and any other language with non-English plural rules.

Fallbacks are a separate concern and off by default. Without them, any missing translation key renders a translation missing string in production. That's the kind of thing that ships and shows up in screenshots.

Enable fallbacks and define a default destination in config/application.rb:

config.i18n.fallbacks = [I18n.default_locale]

With fallbacks = true, a missing key in the current locale falls back to default_locale. For regional variants, you can define explicit chains:

config.i18n.fallbacks = { "fr-CA": :fr, "en-GB": :en }

Configure this early. Retrofitting fallback behavior into an app that already has partial translations in production is harder than setting it up at the start.

Passing Translations to Stimulus Controllers and JavaScript

JavaScript has no access to the Rails I18n pipeline.

There's no t helper, YAML loader, or built-in bridge between your translation files and your Stimulus controllers. You have to pass translations explicitly from the server side.

The simplest approach for Stimulus is the values API. Define the translation as a value on the controller, set it in the HTML, and read it in JavaScript:

# app/views/products/index.html.erb
<div data-controller="notification"
     data-notification-message-value="<%= t('.success_message') %>">

The value is rendered server-side using the standard t helper, so it respects the current locale automatically. On the JavaScript side, declare it as a static value and read it through the values API:

// app/javascript/controllers/notification_controller.js
export default class extends Controller {
  static values = { message: String }

  show() {
    alert(this.messageValue)
  }
}

Each translation is explicit and scoped to the controller that needs it. Nothing leaks into global scope.

If a controller needs several translations at once, bundling them as a JSON data attribute is cleaner than adding individual value definitions for each string:

<div data-controller="cart"
     data-cart-i18n-value="<%= { add: t('.add'), remove: t('.remove') }.to_json %>">

On the JavaScript side, declare i18n as an Object value and access individual keys directly:

static values = { i18n: Object }

add() {
  console.log(this.i18nValue.add)
}

For apps where JavaScript needs broad translation access across many controllers, the i18n-js gem exports your YAML files to a JavaScript object you can query as I18n.t("key").

It works, but it adds a build step and ships your full translation set to the client. Use it when the first two patterns become repetitive, not as a default.

When using Turbo Frames, if a Turbo Frame's src URL omits the locale prefix, the request will usually fail with a Routing Error (404) because it won't match your scope "/:locale" pattern. Always use locale-aware path helpers for frame sources.

This isn't a Turbo bug. Instead, it's a routing oversight, and the around_action from the locale detection section prevents it as long as every URL in the frame uses a locale-aware path helper.

Weglot's reverse proxy takes a different approach entirely. It translates the rendered DOM after the page loads, so Stimulus-rendered content and dynamically inserted strings are covered without any of this wiring.

What Rails I18n Leaves Out

Three categories sit entirely outside what Rails I18n handles:

  • Multilingual SEO in Rails i18n translates strings but doesn’t generate hreflang tags, language-specific sitemaps, or translated metadata. These tasks require manual work beyond the i18n setup.
  • Database-stored content, like product names and blog posts, can’t be stored in YAML files. The Mobility gem is recommended for this, as it supports multiple storage backends. Traco is a lighter alternative for smaller models with per-column translations.
  • Translation workflow in Rails relies on YAML files. Managing the translation process, review cycles, updates, and keeping translations in sync with the app must be handled outside the framework.

Weglot addresses all these at the output layer rather than the code layer:

  • Instead of integrating with the Rails I18n pipeline, it translates the rendered HTML your app produces. This means database content, JavaScript-rendered strings, and static translations are all handled the same way.
  • Multilingual SEO/GEO comes included: hreflang tags, translated URLs, and sitemaps are generated automatically.
  • The translator workflow runs through Weglot's dashboard rather than YAML export cycles.

That said, there are a few limitations worth knowing before you evaluate it:

  • The JavaScript snippet integration is not SEO-indexed; you need the reverse proxy setup for translated pages to appear in search.
  • Custom Subdirectory routing, useful if you have an existing CDN or Nginx configuration, is Enterprise-only.
  • Unlike translations baked into your codebase, Weglot requires an active subscription to keep translated content visible.

Choosing Your Rails i18n Architecture

Every Rails i18n implementation comes down to the same decisions made in roughly the same order.

First, pick your URL strategy before writing any locale-detection code. Path segments work for most public-facing apps. Subdomains make sense when regional identity matters. DB-stored preferences suit authenticated apps where the locale follows the user rather than the URL.

Second, decide how JavaScript gets translations. The Stimulus values API covers most cases. JSON attributes work when a controller needs several strings at once.

Third, decide what you're building manually. Rails handles static strings well. The SEO layer, the translator workflow, and database content all require separate decisions. You can build each one, or delegate the output layer to a tool like Weglot and focus engineering time on the application itself.

Skip the output layer entirely. Try Weglot free for 14 days and get multilingual SEO/GEO, translated URLs, and automatic content detection without touching your i18n code.

direction icon
Discover Weglot

Join 110,000+ brands already translating their sites with Weglot

Translate your website instantly with AI, refine with human edits, and go live in minutes.

In this article, we're going to look into:
Rocket icon

Ready to get started?

The best way to understand the power of Weglot is to see it for yourself. Test it for free and without any engagement.

A demo website is available in your dashboard if you’re not ready to connect your website yet.

Read articles you may also like

FAQ icon

Common questions

No items found.

Blue arrow

Blue arrow

Blue arrow