A theme is a directory under themes/ that gets bootstrapped into a Rails::Engine at boot.
Anatomy
themes/storefront/├── app/│ ├── controllers/ # Themes::Storefront::XxxController│ ├── models/ # Themes::Storefront::Xxx (optional; main app models are also reachable)│ ├── views/ # Resolved first, ahead of main app views│ ├── helpers/│ ├── services/│ ├── jobs/│ ├── policies/│ ├── assets/│ ├── javascript/ # If importmap is enabled│ └── ...├── config/│ ├── routes.rb # Engine-scoped routes│ └── locales/├── spec/ # Auto-discovered by RSpec (if present)└── test/ # Auto-discovered by Minitest (if present)Only directories that actually exist are registered. You don’t need to create empty app/mailers/ just for the gem to work.
The autoloaded paths by default are:
app/controllers app/channels app/helpers app/services app/structsapp/models app/mailers app/presenters app/decorators app/queriesapp/resources app/serializers app/transformers app/validatorsapp/workers app/jobs app/notifications app/policies libCustomize in an initializer:
Multitenancy.config.paths = %w[app/controllers app/models app/views lib]Namespace rules
All Ruby code in a theme lives under Themes::<Name>:
module Themes::Storefront class HomeController < ApplicationController def index @products = Product.all # main-app model — no namespacing needed end endendThe namespace module is created dynamically at boot. You can rely on it existing:
Themes::Storefront # => Themes::StorefrontThemes::Storefront::Engine # => Themes::Storefront::EngineThemes::Storefront::HomeController # => Themes::Storefront::HomeControllerEngine isolation
Each theme’s engine runs through isolate_namespace(Themes::<Name>). That means:
- Routes are scoped (
home_pathin the engine is NOTRails.application.routes.url_helpers.home_path). - Helpers don’t leak between themes.
- Generators inside the theme generate into its namespace.
Controllers
Theme ApplicationControllers typically include the gem’s Controller concern:
module Themes::Storefront class ApplicationController < ::ApplicationController include Multitenancy::Controller endendThe generator does this for you. The concern sets:
prepend_view_path— theme views win over main-app views with the same name.layout "application"— uses the theme’slayouts/application.html.erb.- A
before_actionthat re-executes the theme’s importmap reloader in development when JS files change.
View resolution quirk
Without the concern, Themes::Storefront::HomeController would look up views under themes/storefront/home/ — a path that doesn’t exist, because views are at home/. The concern strips the theme namespace prefix from _prefixes so view lookup works naturally:
theme module = Themes::Storefrontcontroller = Themes::Storefront::HomeControllerlookup path = home/ (not themes/storefront/home/)That means app/views/home/index.html.erb inside the theme renders for the index action.
Models
Shared across themes by default. You can add theme-specific models in themes/<name>/app/models/:
module Themes::Storefront class Product < ApplicationRecord self.table_name = 'storefront_products' endendInside the theme, Product means Themes::Storefront::Product. To reach the main app’s model, use ::Product.
Database
All themes share the main app’s database connection. If you want per-tenant data, handle it at the model level — a tenant_id column plus a default scope, or multiple DB configs in database.yml with connects_to. The gem doesn’t touch this.
Routes
Each theme’s config/routes.rb is a standard engine routes file:
Themes::Storefront::Engine.routes.draw do root to: 'home#index' resources :productsendThe main app’s config/routes/multitenancy.rb decides how each engine gets mounted — see getting-started.md.
Assets & JavaScript
- CSS: each theme has its own
app/assets/paths registered with Propshaft. - JS (importmap): each theme has its own
Importmap::Mapwith its own pins, separate from the main app. Thejavascript_importmap_tagshelper in the theme’s layout draws the theme’s importmap. - Tailwind: each theme has its own input file and gets its own build output under
app/assets/builds/<theme>/.
See integrations.md for the asset and JS story in detail.
Locales
themes/<name>/config/locales/*.yml is added to I18n.load_path. Keys aren’t automatically namespaced — if two themes define the same key, the last loaded wins. Scope keys manually if you want them isolated:
en: themes: storefront: home: title: Welcome to Storefront