Building a Multi-Tenant Ecommerce Platform with Rails: One Codebase, Multiple Brands
The Challenge
Imagine you need to run two (or more) ecommerce stores with different branding, but you don't want to maintain separate codebases. Each store needs its own look, feel, and domain, but the core functionality—products, orders, checkout—remains the same.
This is where a multi-tenant architecture shines. In this post, I'll show you how to build a scalable multi-tenant platform using Rails 8.1, where a single codebase powers multiple branded stores.
The Architecture: Three Pillars
Our approach uses three key Rails features:
- Configuration System - YAML-driven client settings
- Rails Variants - Client-specific views
- CSS Variables - Dynamic theming
Let's dive into each.
1. Configuration-Driven Design
Instead of scattering if client_a? checks throughout your code, use a centralized configuration system.
config/configurations/app.yml
client_a:
variant: "client_a"
name: "Client A Store"
support_email: "support@clienta.com"
theme:
primary_color: "#3B82F6" # Blue
secondary_color: "#8B5CF6" # Purple
font_family: "Inter"
client_b:
variant: "client_b"
name: "Client B Store"
support_email: "support@clientb.com"
theme:
primary_color: "#EF4444" # Red
secondary_color: "#F59E0B" # Amber
font_family: "Poppins"
lib/config/app.rb
module Config
class App
class << self
def variant
ENV.fetch("APP_VARIANT", Rails.env)
end
def config
@config ||= load_config
end
def name
config["name"]
end
def theme
config["theme"]
end
private
def load_config
YAML.load_file(Rails.root.join("config/configurations/app.yml"))[variant]
end
end
end
end
Now you can access configuration anywhere:
Config::App.name # "Client A Store"
Config::App.theme["primary_color"] # "#3B82F6"
2. Rails Variants for Views
Rails variants allow you to create client-specific views without conditionals.
app/controllers/concerns/variants.rb
module Variants
extend ActiveSupport::Concern
included do
before_action :set_variant
end
private
def set_variant
request.variant = Config::App.variant.to_sym
end
end
Include in your ApplicationController:
class ApplicationController < ActionController::Base
include Variants
end
Now create variant-specific views:
app/views/home/
├── index.html+client_a.erb # Professional homepage
└── index.html+client_b.erb # Bold promotional homepage
app/views/home/index.html+client_a.erb
<div class="hero">
<h1>Professional Solutions for Your Business</h1>
<p>Quality products, exceptional service.</p>
</div>
app/views/home/index.html+client_b.erb
<div class="hero">
<h1>🔥 UNBEATABLE DEALS DAILY!</h1>
<p>Save BIG on everything you need!</p>
</div>
Rails automatically renders the correct variant based on APP_VARIANT.
3. CSS Variables for Dynamic Theming
Use CSS variables to make themes swappable without duplicating styles.
app/assets/tailwind/application.css
@layer base {
:root {
--color-primary: 59 130 246; /* Default blue */
--color-secondary: 139 92 246; /* Default purple */
--font-family: Inter, sans-serif;
}
}
@layer utilities {
.bg-primary { background-color: rgb(var(--color-primary)); }
.text-primary { color: rgb(var(--color-primary)); }
.bg-secondary { background-color: rgb(var(--color-secondary)); }
.font-brand { font-family: var(--font-family); }
}
app/assets/stylesheets/themes/client_a.css
:root {
--color-primary: 59 130 246; /* Blue */
--color-secondary: 139 92 246; /* Purple */
--font-family: Inter, -apple-system, sans-serif;
}
app/assets/stylesheets/themes/client_b.css
:root {
--color-primary: 239 68 68; /* Red */
--color-secondary: 245 158 11; /* Amber */
--font-family: Poppins, -apple-system, sans-serif;
}
Load the correct theme in your layout:
<%= stylesheet_link_tag :app %>
<%= stylesheet_link_tag "themes/#{Config::App.variant}" %>
Bonus: Variant-Specific Emails
Extend the variant system to emails for complete brand consistency.
app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
default from: -> { "#{Config::App.name} <#{Config::App.config['support_email']}>" }
layout -> { "mailer_#{Config::App.variant}" }
end
app/views/layouts/mailer_client_a.html.erb
<div class="email-header" style="background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);">
<h1 style="color: #ffffff;"><%= Config::App.name %></h1>
</div>
<div class="email-body">
<%= yield %>
</div>
app/views/layouts/mailer_client_b.html.erb
<div class="email-header" style="background: linear-gradient(135deg, #EF4444 0%, #F59E0B 100%);">
<h1 style="color: #ffffff;"><%= Config::App.name %></h1>
</div>
<div class="email-body">
<%= yield %>
</div>
Deployment: One App, Multiple Instances
Deploy separate instances with different environment variables:
# Client A Production
APP_VARIANT=client_a
DATABASE_URL=postgresql://localhost/ecommerce_clienta_production
MAILER_HOST=clienta.com
# Client B Production
APP_VARIANT=client_b
DATABASE_URL=postgresql://localhost/ecommerce_clientb_production
MAILER_HOST=clientb.com
Each deployment gets:
- Its own database (data isolation)
- Its own domain
- Its own branding automatically applied
Development Workflow
Test different clients locally:
# Test Client A
APP_VARIANT=client_a rails server
# Test Client B on different port
APP_VARIANT=client_b rails server -p 3001
Key Benefits
- ✅ One Codebase - Fix bugs once, deploy everywhere
- ✅ Easy Scaling - Add new clients by adding YAML config
- ✅ Maintainable - No scattered conditionals or duplicated code
- ✅ Flexible - Each client can have unique features via feature flags
- ✅ Cost Effective - Shared infrastructure, separate branding
Best Practices
❌ Avoid:
if Config::App.variant == "client_a"
# client A logic
else
# client B logic
end
✅ Instead:
# Use configuration
Config::App.config["setting"]
# Or feature flags
Config::App.feature_enabled?(:premium_checkout)
❌ Avoid:
<% if Config::App.variant == "client_a" %>
<h1>Client A Title</h1>
<% else %>
<h1>Client B Title</h1>
<% end %>
✅ Instead:
# Create separate view files
index.html+client_a.erb
index.html+client_b.erb
Tech Stack
- Rails 8.1 - Latest Rails with Solid stack
- Hotwire - Turbo + Stimulus for interactivity
- Tailwind CSS v4 - Utility-first CSS with CSS variables
- SolidQueue - Background jobs without Redis
- PostgreSQL - Separate database per client
Conclusion
Multi-tenancy doesn't have to be complicated. With Rails' built-in variant system, YAML configuration, and CSS variables, you can build a scalable platform that serves multiple brands from a single codebase.
The key is separation of concerns:
- Business logic stays shared
- Views differ by client
- Styling adapts via CSS variables
- Configuration drives differences
This architecture has powered our platform serving two distinct ecommerce brands with zero code duplication. Start simple, add complexity only when needed, and let Rails conventions guide you.
Source Code: The complete implementation is available at ecommerce