Multitenancy
A Rails engine-based multitenancy gem that provides theme isolation through dynamically created Rails engines. Each theme lives in its own directory under themes/ and gets its own namespace, routes, controllers, views, assets, JavaScript, and locales — all automatically discovered and wired up at boot time.
Documentation
Full guides, theme generator reference, integrations, and rake tasks are published at gems.marcosz.com.br/multitenancy-rails — part of the marcosgz Ruby gem catalogue.
Installation
Add the gem to your Gemfile:
gem "multitenancy-rails"Quick start
Generate a new theme:
bin/rails generate multitenancy my_storeMount the themes in your routes (config/routes.rb):
Rails.application.routes.draw do
draw(:multitenancy)
root "home#index"
endCreate the route drawing file at config/routes/multitenancy.rb:
Multitenancy.themes.each do |theme|
mount theme.engine, at: "/#{theme.name}"
endStart the server and visit /my-store to see the theme in action.
Or using subdomain
Multitenancy.themes.each do |theme|
constraints subdomain: theme.name do
mount theme.engine, at: "/"
end
endStart the server and visit http://my-store.localhost:3000 to see the theme. (You may need to add to /etc/hosts)
Theme directory structure
Each theme is a self-contained directory under themes/ that mirrors a Rails application structure:
themes/my_store/
├── app/
│ ├── controllers/
│ │ ├── application_controller.rb
│ │ └── home_controller.rb
│ ├── views/
│ │ ├── layouts/
│ │ │ └── application.html.erb
│ │ └── home/
│ │ └── index.html.erb
│ ├── assets/
│ │ └── stylesheets/my-store/
│ │ └── application.css
│ └── javascript/my-store/ # When using importmap
│ ├── application.js
│ └── controllers/
│ └── hello_controller.js
├── config/
│ ├── routes.rb
│ ├── importmap.rb # When using importmap
│ └── locales/
│ └── en.yml
├── spec/
│ └── factories/ # When using FactoryBot (RSpec)
└── test/ # When using Minitest
├── controllers/
└── models/How it works
Boot sequence
The gem hooks into Rails through a Railtie and initializes in two phases:
Phase 1 — before_configuration:
-
Rails integration discovers all directories under
themes/and bootstraps each one:- Creates a Ruby module namespace (e.g.,
Themes::MyStore) - Creates a
Rails::Enginesubclass atThemes::MyStore::Engine - Registers autoload paths with Zeitwerk (controllers, models, services, etc.)
- Registers asset paths with Propshaft
- Creates a Ruby module namespace (e.g.,
-
FactoryBot integration adds each theme's
spec/factoriesto the factory lookup paths - RSpec integration configures theme spec directories for automatic discovery
-
Minitest integration configures theme test directories for automatic discovery via
rails test
Phase 2 — after_initialize:
- Importmap integration creates per-theme importmaps with development file watchers
- TailwindCSS integration excludes raw Tailwind input files from Propshaft and prepares build output directories
Theme engine
Each theme gets a dynamically created Rails::Engine configured via the Stim module. The engine provides:
-
Isolated namespace — theme classes live under
Themes::<Name>and never collide with each other or the main app - Own root path — the engine's root is the theme directory itself
-
View paths —
app/viewswithin the theme -
Asset paths —
app/assetsregistered with Propshaft -
Locale paths —
config/localesfor per-theme I18n -
JavaScript paths —
app/javascriptfor importmap/Stimulus support
Controller concern
Theme controllers include Multitenancy::Controller, which:
- Prepends the theme's
app/viewsdirectory to the view lookup path so theme views take priority - Sets
layout "application"to use the theme's own layout - Strips the namespace prefix from controller prefixes so views resolve naturally (e.g.,
home/indexinstead ofthemes/my_store/home/index)
module Themes::MyStore
class ApplicationController < ActionController::Base
include Multitenancy::Controller
end
endRouting
Themes define their own routes inside their engine:
# themes/my_store/config/routes.rb
Themes::MyStore::Engine.routes.draw do
root "home#index"
endAll theme engines are mounted automatically by the config/routes/multitenancy.rb draw file. Each theme is mounted at /<theme-name>:
| Theme directory | Mount path | Root URL |
|---|---|---|
themes/my_store |
/my-store |
http://localhost:3000/my-store |
themes/community |
/community |
http://localhost:3000/community |
Autoload paths
The following directories inside a theme are automatically registered with Zeitwerk under the theme's namespace:
app/controllersapp/channelsapp/helpersapp/servicesapp/structsapp/modelsapp/mailerslib
This means you can add models, services, or any other classes to a theme and they will be autoloaded under the Themes::<Name> namespace.
Generator
bin/rails generate multitenancy NAME [options]
Arguments
| Argument | Description |
|---|---|
NAME |
Name of the theme (will be parameterized) |
Options
| Option | Type | Default | Description |
|---|---|---|---|
--importmap |
boolean | true |
Generate importmap configuration and Stimulus setup |
--tailwindcss |
boolean | false |
Generate Tailwind CSS configuration instead of plain CSS |
Examples
Generate a theme with importmap (default):
bin/rails generate multitenancy blogGenerate a theme with Tailwind CSS and importmap:
bin/rails generate multitenancy blog --tailwindcssGenerate a theme without importmap:
bin/rails generate multitenancy blog --no-importmapGenerate a theme with Tailwind CSS and no importmap:
bin/rails generate multitenancy blog --tailwindcss --no-importmapIntegrations
Importmap
Requires: importmap-rails gem
Each theme gets its own Importmap::Map instance stored on Themes::<Name>::Engine.importmap. The integration:
- Draws the main app's importmap first (Turbo, Stimulus, shared libraries)
- Draws the theme's
config/importmap.rbon top — theme pins can override app pins - In development, watches theme JavaScript directories and reloads the importmap on changes
- Removes theme importmap paths from the main app's config to prevent cross-contamination
Theme importmap example (themes/blog/config/importmap.rb):
pin "blog/application", to: "blog/application.js"
pin_all_from "themes/blog/app/javascript/blog/controllers",
under: "blog/controllers", to: "blog/controllers"Theme layout usage:
<%= javascript_importmap_tags "blog/application", importmap: Themes::Blog::Engine.importmap %>Theme application.js — loads shared controllers from the main app plus theme-specific ones:
import { Application } from "@hotwired/stimulus"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
const application = Application.start()
application.debug = false
window.Stimulus = application
// Shared controllers from the main app (data-controller="hello")
eagerLoadControllersFrom("controllers", application)
// Theme-specific controllers (overrides shared if same name)
eagerLoadControllersFrom("blog/controllers", application)This setup allows themes to use all shared Stimulus controllers from the main app while also defining or overriding controllers specific to the theme.
Tailwind CSS
Requires: tailwindcss-rails gem (~> 4.4)
The Tailwind CSS integration compiles per-theme stylesheets using Tailwind CSS v4's CSS-first configuration. No tailwind.config.js is needed.
File layout with Tailwind:
themes/blog/
└── app/assets/
├── tailwind/blog/
│ └── application.css # Tailwind input (raw @import directives)
└── builds/blog/
└── application.css # Compiled output (served by Propshaft)
Tailwind input file (app/assets/tailwind/blog/application.css):
@import "tailwindcss";
@source "../../../views/**/*.html.erb";
@source "../../../controllers/**/*.rb";
@source "../../../javascript/blog/**/*.js";The @source directives use relative paths from the input file's location to scan only the theme's own files for utility classes. Each theme is independently scoped.
The integration automatically:
- Excludes the
tailwind/directory from Propshaft (raw@importfiles cannot be served directly) - Creates the
builds/<name>/output directory - Skips entirely if
tailwindcss-railsis not in the bundle (guard clause)
Layout usage — no changes needed, the existing stylesheet_link_tag resolves to the compiled output:
<%= stylesheet_link_tag "blog/application", "data-turbo-track": "reload" %>Rake tasks
multitenancy:tailwindcss:build — compiles Tailwind CSS for all themes:
bin/rails multitenancy:tailwindcss:buildThis task is automatically hooked into assets:precompile, so production deploys (Kamal, Heroku, etc.) compile theme CSS without any extra configuration.
multitenancy:tailwindcss:watch — watches and recompiles on changes (for development):
bin/rails multitenancy:tailwindcss:watchSpawns a separate watcher process per theme. Ctrl-C cleanly terminates all watchers.
Development setup
To run the Tailwind watcher alongside the Rails server during development, use a Procfile.dev:
web: bin/rails server
tailwind: bin/rails multitenancy:tailwindcss:watchThen start everything with:
bin/devOr run the watcher in a separate terminal:
bin/rails multitenancy:tailwindcss:watchRSpec
Requires: rspec-rails gem
The RSpec integration automatically discovers and includes theme specs in test runs:
-
rspec(no arguments) — runsspec/from the main app plusspec/from every theme -
rspec themes/blog— expands tothemes/blog/specautomatically -
Nested themes — if theme names share a prefix (e.g.,
blogandblog-admin), runningrspec themes/blogalso includesthemes/blog-admin/spec
No configuration needed. Theme specs are discovered as long as a spec/ directory exists inside the theme.
Minitest
Requires: Rails test runner (included with railties)
The Minitest integration automatically discovers and includes theme tests in rails test runs:
-
bin/rails test(no arguments) — runstest/from the main app plustest/from every theme -
bin/rails test themes/blog— expands tothemes/blog/testautomatically -
Nested themes — if theme names share a prefix (e.g.,
blogandblog-admin), runningbin/rails test themes/blogalso includesthemes/blog-admin/test
No configuration needed. Theme tests are discovered as long as a test/ directory exists inside the theme and files follow the *_test.rb naming convention.
Theme test directory structure:
themes/blog/
└── test/
├── controllers/
│ └── home_controller_test.rb
└── models/
└── post_test.rb
Theme test example:
# themes/blog/test/controllers/home_controller_test.rb
require "test_helper"
module Themes::Blog
class HomeControllerTest < ActionDispatch::IntegrationTest
test "gets root" do
get blog.root_path
assert_response :success
end
end
endFactoryBot
Requires: factory_bot_rails gem
Automatically adds each theme's spec/factories directory to FactoryBot's definition file paths. Theme factories are available in all tests without any extra configuration.
themes/blog/
└── spec/
└── factories/
└── posts.rb # Automatically loaded by FactoryBot
Localization
Each theme has its own locale files under config/locales/. Theme translations are namespaced under themes.<name>:
# themes/blog/config/locales/en.yml
en:
themes:
blog:
hello: "Hello world from the blog theme"Usage in theme views:
<%= t("themes.blog.hello") %>Configuration
Autoload paths
The list of directories that get registered with Zeitwerk for each theme can be customized:
# In an initializer or before configuration
Multitenancy.config.paths = %w[
app/controllers
app/channels
app/helpers
app/services
app/structs
app/models
app/mailers
lib
]Only directories that actually exist within a theme are registered.
ActiveSupport hook
The gem fires an ActiveSupport load hook during initialization that other gems or application code can use to hook into:
ActiveSupport.on_load(:multitenancy) do |multitenancy|
# Custom initialization after all themes are bootstrapped
endFull example
Create a new theme with all integrations:
bin/rails generate multitenancy storefront --tailwindcssThis generates:
themes/storefront/
├── app/
│ ├── controllers/
│ │ ├── application_controller.rb
│ │ └── home_controller.rb
│ ├── views/
│ │ ├── layouts/
│ │ │ └── application.html.erb
│ │ └── home/
│ │ └── index.html.erb
│ ├── assets/
│ │ ├── tailwind/storefront/
│ │ │ └── application.css
│ │ └── builds/storefront/
│ │ └── .keep
│ └── javascript/storefront/
│ ├── application.js
│ └── controllers/
│ └── hello_controller.js
├── config/
│ ├── routes.rb
│ ├── importmap.rb
│ └── locales/
│ └── en.yml
Build and serve:
# Compile Tailwind CSS
bin/rails multitenancy:tailwindcss:build
# Start the server
bin/rails server
# Visit http://localhost:3000/storefrontAdd a new controller to the theme:
# themes/storefront/app/controllers/products_controller.rb
module Themes::Storefront
class ProductsController < ApplicationController
def index
@products = Product.all # Access main app models
end
end
endAdd a route:
# themes/storefront/config/routes.rb
Themes::Storefront::Engine.routes.draw do
root "home#index"
resources :products, only: [:index, :show]
endAdd a Stimulus controller specific to this theme:
// themes/storefront/app/javascript/storefront/controllers/cart_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["count"]
add() {
this.countTarget.textContent = parseInt(this.countTarget.textContent) + 1
}
}Use it in a theme view — the controller is scoped to this theme only:
<!-- themes/storefront/app/views/products/index.html.erb -->
<div data-controller="storefront--cart">
<span data-storefront--cart-target="count">0</span>
<button data-action="storefront--cart#add">Add to cart</button>
</div>Requirements
- Ruby >= 3.1
- Rails (railties, activesupport)
- Zeitwerk (for autoloading)
Optional dependencies
| Gem | Integration | Purpose |
|---|---|---|
importmap-rails |
Importmap | Per-theme JavaScript with ESM |
tailwindcss-rails |
TailwindCSS | Per-theme Tailwind CSS builds |
rspec-rails |
RSpec | Auto-discover theme specs |
railties (test runner) |
Minitest | Auto-discover theme tests |
factory_bot_rails |
FactoryBot | Auto-discover theme factories |
All integrations use guard clauses and are silently skipped when their respective gems are not installed.
License
MIT