Automatic Timezones in Rails
This is basically how Basecamp handles timezones, with a small change. Check out basecamp/fizzy for their implementation.
Server-side Rails apps default to UTC. That’s fine until you need to show a user dates in their timezone. “You read this chapter on March 27” shouldn’t say March 28 because they’re in America/New_York.
Here’s a simple pattern that detects the browser’s timezone, syncs it to the server, and makes Time.current just work everywhere.
How it works
- A Stimulus controller sets a cookie with the browser’s IANA timezone on every page load
- A Rails
around_actionwraps every request inTime.use_zoneusing that cookie - A hidden auto-submitting form persists the timezone to the user record when it changes
There is nothing for the user to configure, it just works automatically.
Step 1: Migration
Add a timezone_name column to your users table:
class AddTimezoneNameToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :timezone_name, :string, default: "UTC", null: false
end
end
Step 2: User model
Add a helper that returns an ActiveSupport::TimeZone object, falling back to UTC for invalid values:
class User < ApplicationRecord
def timezone
ActiveSupport::TimeZone[timezone_name] || ActiveSupport::TimeZone["UTC"]
end
end
Step 3: Stimulus controller
Detect the browser’s timezone via the Intl API and set it as a cookie on every page load:
// app/javascript/controllers/timezone_cookie_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
this.#setTimezoneCookie();
}
#setTimezoneCookie() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
document.cookie = `timezone=${encodeURIComponent(timezone)}; path=/`;
}
}
Attach it to the <body> so it runs on every page:
<body data-controller="timezone-cookie">
Intl.DateTimeFormat().resolvedOptions().timeZone returns IANA identifiers like America/New_York or Asia/Tokyo. These map directly to ActiveSupport::TimeZone keys.
Step 4: Controller concern
Wrap every request in the user’s timezone so Time.current and Date.current return the right values for their timezone. Timestamps are still stored in UTC in the database, but this ensures you get the correct date when calling things like Time.current.to_date:
# app/controllers/concerns/current_timezone.rb
module CurrentTimezone
extend ActiveSupport::Concern
included do
around_action :set_current_timezone
helper_method :timezone_from_cookie
end
private
def set_current_timezone(&)
Time.use_zone(timezone_from_cookie, &)
end
def timezone_from_cookie
@timezone_from_cookie ||= begin
timezone = cookies[:timezone]
ActiveSupport::TimeZone[timezone] if timezone.present?
end
end
end
Include it in your ApplicationController:
class ApplicationController < ActionController::Base
include CurrentTimezone
end
Time.use_zone sets the timezone for the duration of the block. If the cookie is missing or invalid, it passes nil, which just falls back to the app default (UTC).
Step 5: Auto-sync to the user record
When the browser timezone differs from what’s saved on the user, auto-submit a hidden form to persist it:
<%# app/views/layouts/shared/_time_zone.html.erb %>
<% if timezone_from_cookie.present? && timezone_from_cookie != Current.user.timezone %>
<%= form_with url: user_timezone_path, method: :put, data: { controller: "auto-submit" } do |form| %>
<%= form.hidden_field :timezone_name, value: timezone_from_cookie.name %>
<% end %>
<% end %>
The auto-submit Stimulus controller just submits the form as soon as it connects. Since the form only renders when the timezone has changed, it only fires when needed:
// app/javascript/controllers/auto_submit_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
this.element.requestSubmit();
}
}
Render it in your layout for authenticated users:
<%# app/views/layouts/application.html.erb %>
<%= render "layouts/shared/time_zone" if Current.user %>
The controller that handles the update:
# app/controllers/user/timezones_controller.rb
class User::TimezonesController < ApplicationController
def update
Current.user.update!(timezone_name: timezone_param)
head :ok
end
private
def timezone_param
params[:timezone_name]
end
end
With the route:
namespace :user do
resource :timezone, only: :update
end
Why this works well
- No UI needed. The browser sets the timezone every time so if a user travels it will just work.
Time.currentjust works.Timestamps are still stored in UTC, butTime.currentandDate.currentreturn the right values for the user’s timezone. No.in_time_zonecalls scattered everywhere.- Persisting to the user record means you have their timezone available outside of requests, like if you need to schedule something at a specific time in their timezone.
- The auto-submit formfires once when the timezone changes, then the condition
timezone_from_cookie != Current.user.timezonestops matching, so it doesn’t fire again.