DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Two products, one Rails codebase

This article was originally published on Rails Designer's Build a SaaS


(apologies for the title, but couldn't resist 💩)

Some time ago I helped a new client launch two (and more in the future) products with one Rails code base. Due to legal and commercial considerations I cannot link directly to any of the two products, but you can think of it as "discussion forums for specific niches". The client is well versed in SEO and once they spot an interesting niche, can spin up another platform in no time. Cool, right?

If you do not have the right marketing chops, I do not recommend you take this approach, but some other ideas that can use a similar code set up that come to mind are:

  • Marketplace
  • Business Directory
  • Dating Site
  • Directory sites

I took on the opportunity because from a technical point of view it was interesting. One code base that could be deployed separately (different apps or servers even!) and be served under its own domain, sporting their own unique branding and sometimes different views.

In this short article I want to show what (Rails-)specific features I used for this and how you can build something like this yourself. As always the code can be found on GitHub. It uses a fictitious example of a Rails Designer and Laravel Designer sites.

The two “product's homepages” will look like this:

(yes, not pretty—but notices how they are similar but quite different?)

Configuration Object

First thing I needed was a way to configure each product. A simple Config module loads YAML files from config/configurations/ and turns them into constants. I wrote about this pattern before, so check it out for all the details. it works with context-specific yaml files stored in config/configurations. Like this one for for the app-specific settings:

# config/configurations/app.yml
shared:
  variant: <%= ENV.fetch("APP_VARIANT", "rails_designer") %>
  name: <%= ENV.fetch("APP_NAME", "Rails Designer") %>
Enter fullscreen mode Exit fullscreen mode

This is the key part. All product-specific configuration is driven by environment variables. When deploying, you set APP_VARIANT=laravel_designer for one product, and APP_VARIANT=rails_designer for the other. This makes it super easy to spin up new products without touching the code. Just set different environment variables for each deployment.

The app.yml file can grow to include more settings like feature flags, API keys, or any other product-specific configuration. For example, you might have different email templates, or different third-party integrations per product. All of this can be configured through environment variables and this YAML file. The beauty is that you can access these values anywhere in the app with Config::App.variant and Config::App.name. Simple and clean.

CSS Setup

For the styling, each product needs its own look and feel. The setup starts with a base application.css file with shared styles:

/* app/assets/stylesheets/application.css */
@layer reset, base, components, utilities;

@import url("./normalize.css") layer(reset);
@import url("./components/nav.css") layer(components);

@layer components {
  .hero {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    width: 100%;
    padding-block: 12rem;

    * {
      margin-block: 0;
    }

    h1 {
      font-size: 3rem;
      letter-spacing: -.025em;
    }

    p {
      margin-block-start: .5rem;
      font-size: 1.875rem;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

CSS layers control the cascade here. The reset layer contains normalize.css, base is for base styles, components for component styles, and utilities for utility classes. This way styles can be overridden in a predictable way.

Product-specific CSS files set CSS custom properties that the shared components use:

/* app/assets/stylesheets/rails_designer.css */
:root {
  --primary-color: #dc2626;
}

@layer base {
  a:any-link {
    text-decoration-line: none;
    color: inherit;

    &:not([class]) {
      text-decoration-line: underline;
      color: var(--primary-color);

      &:hover {
        text-decoration-line: none;
      }
    }
  }
}

@layer components {
  nav a {
    --nav-item-background-color: var(--primary-color);
    --nav-item-color: #fff;
    --nav-item-border-radius: .5rem;
  }
}
Enter fullscreen mode Exit fullscreen mode
/* app/assets/stylesheets/laravel_designer.css */
:root {
  --primary-color: #f97316;
}

@layer components {
  nav a {
    --nav-item-color: var(--primary-color);
  }
}
Enter fullscreen mode Exit fullscreen mode

The navigation component show an example how to use these custom properties:

/* app/assets/stylesheets/components/nav.css */
nav {
  display: flex;
  align-items: center;
  justify-content: center;
  column-gap: 1rem;
  margin-block-start: .5rem;

  a {
    display: inline flex;
    padding-block: .5rem;
    padding-inline: 1rem;
    font-weight; 600;
    color: var(--nav-item-color, --primary-color);
    text-decoration-line: none;
    background-color: var(--nav-item-background-color);
    border-radius: var(--nav-item-border-radius);
  }
}
Enter fullscreen mode Exit fullscreen mode

In the layout, both the shared application.css and the product-specific CSS file get loaded:

# app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
-    <title><%= content_for(:title) || "Rails Designer" %></title>
+    <title><%= content_for(:title) || Config::App.name %></title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <link rel="icon" href="/icon.svg" type="image/svg+xml">
    <link rel="apple-touch-icon" href="/icon.png">

-    <%# Includes all stylesheet files in app/assets/stylesheets %>
-    <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
+    <%= stylesheet_link_tag "application", Config::App.variant, "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
+    <%= render "shared/navigation" %>
+
    <%= yield %>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This loads application.css first, then either rails_designer.css or laravel_designer.css depending on the APP_VARIANT environment variable. The product-specific CSS can override the shared styles using CSS custom properties or by targeting the same selectors in higher layers.

Rails Variants

Now this is all powered by a really cool Rails feature called variants that lets you render different templates based on a variant. This is typically used for mobile vs. desktop views, but works perfectly for different products too.

A concern sets the variant based on the configuration:

# 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
Enter fullscreen mode Exit fullscreen mode

Including this in the ApplicationController makes it available everywhere:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
+  include Variants
end
Enter fullscreen mode Exit fullscreen mode

With this in place, variant-specific views can be created using the +variant syntax. Different navigation partials for each product:

<%# app/views/shared/_navigation.html+rails_designer.erb %>
<nav>
  <%= link_to "About Rails", "#" %>
  <%= link_to "Design for Rails", "#" %>
</nav>
Enter fullscreen mode Exit fullscreen mode
<%# app/views/shared/_navigation.html+laravel_designer.erb %>
<nav>
  <%= link_to "About Laravel", "#" %>
  <%= link_to "Design for Laravel", "#" %>
</nav>
Enter fullscreen mode Exit fullscreen mode

And different homepage views:

<%# app/views/pages/show.html+rails_designer.erb %>
<section class="hero">
  <h1>
    Welcome to Rails Designer
  </h1>

  <p>Check out <%= link_to "railsdesigner.com", "https://railsdesigner.com/" %>
</section>
Enter fullscreen mode Exit fullscreen mode
<%# app/views/pages/show.html+laravel_designer.erb %>
<section class="hero">
  <h1>
    Welcome to Laravel Designer
  </h1>
</section>
Enter fullscreen mode Exit fullscreen mode

When Rails renders a view, it first looks for a variant-specific template. If it finds one, it uses that. Otherwise, it falls back to the default template. This means you only need to create variant-specific views when they actually differ between products. Shared views can stay as regular templates.


And that is the gist of how I helped this client build this business for them. It is now multiple months live and it works great! Each product gets its own branding, its own views where needed, and its own configuration. But you only maintain one codebase, which makes adding features and fixing bugs much easier.

Does this spark some interesting ideas to you for a new business? Let me know! 😊

Top comments (0)