Skip to content

Rack Integration Guide

Cyril Kato edited this page Jan 20, 2026 · 1 revision

Table of Contents

Rack Integration Guide

This guide shows you how to integrate AcceptLanguage with Rack-based applications, including Sinatra, Hanami, Roda, and pure Rack apps.

Understanding Rack

Rack is the interface between Ruby web servers and frameworks. The Accept-Language header is available in the Rack environment as HTTP_ACCEPT_LANGUAGE.

# In any Rack app
env["HTTP_ACCEPT_LANGUAGE"]
# => "fr-CH, fr;q=0.9, en;q=0.8"

Middleware Approach

The cleanest way to handle locale detection is with middleware. This works with any Rack-compatible framework.

Basic Middleware

# lib/locale_middleware.rb
require "accept_language"

class LocaleMiddleware
  def initialize(app, available_locales:, default_locale:)
    @app = app
    @available_locales = available_locales
    @default_locale = default_locale
  end

  def call(env)
    locale = detect_locale(env) || @default_locale
    env["rack.locale"] = locale
    @app.call(env)
  end

  private

  def detect_locale(env)
    header = env["HTTP_ACCEPT_LANGUAGE"]
    return if header.nil?

    AcceptLanguage.parse(header).match(*@available_locales)
  end
end

Using the Middleware

# config.ru
require_relative "lib/locale_middleware"
require_relative "app"

use LocaleMiddleware,
    available_locales: [:en, :fr, :de, :ja],
    default_locale: :en

run App

Access the detected locale in your app:

# In any Rack app
locale = env["rack.locale"]
# => :fr

Sinatra Integration

With Middleware

# app.rb
require "sinatra"
require "accept_language"

class LocaleMiddleware
  def initialize(app, available_locales:, default_locale:)
    @app = app
    @available_locales = available_locales
    @default_locale = default_locale
  end

  def call(env)
    locale = detect_locale(env) || @default_locale
    env["rack.locale"] = locale
    @app.call(env)
  end

  private

  def detect_locale(env)
    header = env["HTTP_ACCEPT_LANGUAGE"]
    return if header.nil?

    AcceptLanguage.parse(header).match(*@available_locales)
  end
end

class App < Sinatra::Base
  use LocaleMiddleware,
      available_locales: [:en, :fr, :de],
      default_locale: :en

  get "/" do
    locale = env["rack.locale"]
    "Your locale is: #{locale}"
  end
end

With Before Filter

For simpler apps, use a before filter:

# app.rb
require "sinatra"
require "accept_language"

AVAILABLE_LOCALES = [:en, :fr, :de, :ja]
DEFAULT_LOCALE = :en

helpers do
  def current_locale
    @locale ||= detect_locale
  end

  def detect_locale
    header = request.env["HTTP_ACCEPT_LANGUAGE"]
    return DEFAULT_LOCALE if header.nil?

    AcceptLanguage.parse(header).match(*AVAILABLE_LOCALES) || DEFAULT_LOCALE
  end
end

before do
  @locale = current_locale
end

get "/" do
  "Your locale is: #{current_locale}"
end

With I18n

Combine with the i18n gem for translations:

# app.rb
require "sinatra"
require "sinatra/i18n"
require "accept_language"

class App < Sinatra::Base
  register Sinatra::I18n

  set :locales, ["config/locales/en.yml", "config/locales/fr.yml"]
  set :available_locales, [:en, :fr]
  set :default_locale, :en

  before do
    I18n.locale = detect_locale
  end

  helpers do
    def detect_locale
      header = request.env["HTTP_ACCEPT_LANGUAGE"]
      return settings.default_locale if header.nil?

      AcceptLanguage.parse(header).match(*settings.available_locales) ||
        settings.default_locale
    end
  end

  get "/" do
    t(:welcome_message)
  end
end

Hanami Integration

Hanami 2.x

Create an action mixin:

# app/actions/locale_detection.rb
require "accept_language"

module LocaleDetection
  AVAILABLE_LOCALES = [:en, :fr, :de].freeze
  DEFAULT_LOCALE = :en

  def detect_locale(request)
    header = request.env["HTTP_ACCEPT_LANGUAGE"]
    return DEFAULT_LOCALE if header.nil?

    AcceptLanguage.parse(header).match(*AVAILABLE_LOCALES) || DEFAULT_LOCALE
  end
end

Use it in actions:

# app/actions/home/index.rb
module MyApp
  module Actions
    module Home
      class Index < MyApp::Action
        include LocaleDetection

        def handle(request, response)
          locale = detect_locale(request)
          response.body = "Your locale is: #{locale}"
        end
      end
    end
  end
end

Hanami 2.x with Middleware

# config/app.rb
module MyApp
  class App < Hanami::App
    config.middleware.use LocaleMiddleware,
                          available_locales: [:en, :fr, :de],
                          default_locale: :en
  end
end

Roda Integration

# app.rb
require "roda"
require "accept_language"

class App < Roda
  AVAILABLE_LOCALES = [:en, :fr, :de, :ja]
  DEFAULT_LOCALE = :en

  def detect_locale
    header = env["HTTP_ACCEPT_LANGUAGE"]
    return DEFAULT_LOCALE if header.nil?

    AcceptLanguage.parse(header).match(*AVAILABLE_LOCALES) || DEFAULT_LOCALE
  end

  route do |r|
    @locale = detect_locale

    r.root do
      "Your locale is: #{@locale}"
    end
  end
end

Or use the middleware:

# config.ru
require_relative "lib/locale_middleware"
require_relative "app"

use LocaleMiddleware,
    available_locales: [:en, :fr, :de, :ja],
    default_locale: :en

run App.freeze.app

Advanced Middleware

With Priority Chain

Check multiple sources for locale:

# lib/locale_middleware.rb
require "accept_language"

class LocaleMiddleware
  def initialize(app, available_locales:, default_locale:)
    @app = app
    @available_locales = available_locales
    @default_locale = default_locale
  end

  def call(env)
    request = Rack::Request.new(env)
    locale = detect_locale(request) || @default_locale
    env["rack.locale"] = locale
    @app.call(env)
  end

  private

  def detect_locale(request)
    locale_from_params(request) ||
      locale_from_cookie(request) ||
      locale_from_header(request)
  end

  def locale_from_params(request)
    locale = request.params["locale"]
    return unless locale

    validate_locale(locale)
  end

  def locale_from_cookie(request)
    locale = request.cookies["locale"]
    return unless locale

    validate_locale(locale)
  end

  def locale_from_header(request)
    header = request.env["HTTP_ACCEPT_LANGUAGE"]
    return if header.nil?

    AcceptLanguage.parse(header).match(*@available_locales)
  end

  def validate_locale(locale)
    sym = locale.to_sym
    return sym if @available_locales.include?(sym)

    nil
  end
end

Thread-Safe with Request Store

For thread-safe access across your app:

# lib/locale_middleware.rb
require "accept_language"

class LocaleMiddleware
  def initialize(app, available_locales:, default_locale:)
    @app = app
    @available_locales = available_locales
    @default_locale = default_locale
  end

  def call(env)
    locale = detect_locale(env) || @default_locale
    env["rack.locale"] = locale

    # Store in thread-local for easy access
    Thread.current[:locale] = locale

    begin
      @app.call(env)
    ensure
      Thread.current[:locale] = nil
    end
  end

  private

  def detect_locale(env)
    header = env["HTTP_ACCEPT_LANGUAGE"]
    return if header.nil?

    AcceptLanguage.parse(header).match(*@available_locales)
  end
end

# Helper module
module Locale
  def self.current
    Thread.current[:locale]
  end
end

Usage anywhere:

Locale.current
# => :fr

Pure Rack Application

For a minimal Rack app:

# config.ru
require "accept_language"

AVAILABLE_LOCALES = [:en, :fr, :de]
DEFAULT_LOCALE = :en

app = lambda do |env|
  header = env["HTTP_ACCEPT_LANGUAGE"]

  locale = if header
             AcceptLanguage.parse(header).match(*AVAILABLE_LOCALES) || DEFAULT_LOCALE
           else
             DEFAULT_LOCALE
           end

  [
    200,
    { "Content-Type" => "text/plain" },
    ["Your locale is: #{locale}"]
  ]
end

run app

Setting Response Headers

Include Vary header for proper caching:

# lib/locale_middleware.rb
class LocaleMiddleware
  def initialize(app, available_locales:, default_locale:)
    @app = app
    @available_locales = available_locales
    @default_locale = default_locale
  end

  def call(env)
    locale = detect_locale(env) || @default_locale
    env["rack.locale"] = locale

    status, headers, body = @app.call(env)

    # Add Vary header for caching
    vary = headers["Vary"]
    headers["Vary"] = vary ? "#{vary}, Accept-Language" : "Accept-Language"

    [status, headers, body]
  end

  private

  def detect_locale(env)
    header = env["HTTP_ACCEPT_LANGUAGE"]
    return if header.nil?

    AcceptLanguage.parse(header).match(*@available_locales)
  end
end

Setting a Locale Cookie

Persist user choice:

# lib/locale_middleware.rb
require "accept_language"
require "rack"

class LocaleMiddleware
  COOKIE_NAME = "locale"
  COOKIE_MAX_AGE = 365 * 24 * 60 * 60  # 1 year

  def initialize(app, available_locales:, default_locale:)
    @app = app
    @available_locales = available_locales
    @default_locale = default_locale
  end

  def call(env)
    request = Rack::Request.new(env)
    locale = detect_locale(request) || @default_locale
    env["rack.locale"] = locale

    status, headers, body = @app.call(env)

    # Set cookie if from header detection (not already in cookie)
    unless request.cookies[COOKIE_NAME]
      Rack::Utils.set_cookie_header!(
        headers,
        COOKIE_NAME,
        { value: locale.to_s, max_age: COOKIE_MAX_AGE, path: "/" }
      )
    end

    [status, headers, body]
  end

  private

  def detect_locale(request)
    locale_from_cookie(request) || locale_from_header(request)
  end

  def locale_from_cookie(request)
    locale = request.cookies[COOKIE_NAME]
    return unless locale

    sym = locale.to_sym
    return sym if @available_locales.include?(sym)

    nil
  end

  def locale_from_header(request)
    header = request.env["HTTP_ACCEPT_LANGUAGE"]
    return if header.nil?

    AcceptLanguage.parse(header).match(*@available_locales)
  end
end

Testing

Test your middleware with Rack::Test:

# spec/locale_middleware_spec.rb
require "rack/test"
require_relative "../lib/locale_middleware"

RSpec.describe LocaleMiddleware do
  include Rack::Test::Methods

  let(:inner_app) do
    lambda { |env| [200, {}, [env["rack.locale"].to_s]] }
  end

  let(:app) do
    LocaleMiddleware.new(
      inner_app,
      available_locales: [:en, :fr, :de],
      default_locale: :en
    )
  end

  it "detects French" do
    header "Accept-Language", "fr, en;q=0.8"
    get "/"
    expect(last_response.body).to eq("fr")
  end

  it "falls back to default" do
    header "Accept-Language", "ja"
    get "/"
    expect(last_response.body).to eq("en")
  end

  it "handles missing header" do
    get "/"
    expect(last_response.body).to eq("en")
  end

  it "respects quality values" do
    header "Accept-Language", "de;q=0.7, fr;q=0.9"
    get "/"
    expect(last_response.body).to eq("fr")
  end
end

What's Next?