← Back to Blogs

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:

  1. Configuration System - YAML-driven client settings
  2. Rails Variants - Client-specific views
  3. 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