Free

Letting Claude Run Two Brand Sites from One Rails Codebase (how2claude.com + how2claude.dev)

Two brands, one Rails app — routes.rb host constraints + dev namespace + smart helpers + three real pitfalls around catch-all slugs, nav drift, and missing sign-out.


how2claude has two sites:

  • how2claude.com — the general site, covering all Claude-use categories (getting-started / prompting / use-cases / tools / comparisons / claude-code)
  • how2claude.dev — developer-focused, only surfaces the claude-code category, separate brand, flat URLs, dark terminal theme

95% of the content is shared (same article data), 20% of the presentation needs to fork (branding, nav, URL structure, theme). Two Rails repos is obviously not worth it — the article system, payments, accounts, OAuth, i18n all doubled in maintenance. But one codebase, two domains has a pile of edges to draw: how do routes split, do controllers get reused, how does the view know which domain it's running on, where does the layout fork.

I let Claude build this out. What follows is the complete record of where the fork lines sit — feature work done 5 days ago, both sites running in production now.


Core question: what's shared, what's forked

Lay out shared vs. forked up front:

Shared (one piece of code, both domains use it):
- Article model, Category, Series
- OAuth login, Stripe subscription, x402 payments
- Account page /accounts (same feature, same UI)
- Pricing page /pricing (same data, same Stimulus controls)
- i18n locale mechanism, 19 language files

Forked (different across domains):
- Homepage: .com multi-category browse vs. .dev developer-specific design ($ claude --master hero)
- URL structure: .com /:category/:slug vs. .dev /:slug (flat)
- Visible content: .dev only shows claude-code-category articles
- Theme: .com white background with orange accents vs. .dev dark monospace emerald
- Navigation: .com has category links vs. .dev simplified down to Pricing + Sign in

Core principle: don't touch the data layer, fork controllers where needed, make views / helpers host-aware.

Architecture: the host-constraint block in routes.rb

All forking flows from config/routes.rb:

# how2claude.dev — developer site (Claude Code only, flat URLs)
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
  scope "(:locale)", locale: /zh|zh-TW|ja|ko|.../ do
    root "dev/home#index", as: :dev_root
    get "/pricing", to: "pages#pricing", as: :dev_pricing
    get "/:slug", to: "dev/articles#show", as: :dev_article
  end
end

# Public routes — default locale (en) has no prefix, others use /:locale
scope "(:locale)", locale: /zh|zh-TW|ja|ko|.../ do
  root "pages#home"
  get "/pricing", to: "pages#pricing", as: :pricing
  get "/:category_slug",       to: "categories#show", as: :category
  get "/:category_slug/:slug", to: "articles#show",   as: :article
end

Two notes:

  1. The host regex must be precise — anchor the how2claude\.dev prefix with \A, match optional ports with (:\d+)?. how2claude.test is the local-development domain (via dnsmasq or /etc/hosts pointing to 127.0.0.1); adding it to the pattern makes dev routes work locally too.
  2. Locale scope nests inside the host constraint — both route blocks wrap scope "(:locale)", so URL prefixes like /zh, /ja work on both sites without rewriting.

The same PagesController#pricing is hooked into both blocks — one controller method, different route helpers: .com uses pricing_path, .dev uses dev_pricing_path. This aliasing trick avoids a "helper name collision" (two domains both naming their /pricing helper pricing would get the later definition overwriting the earlier one).

Dev namespace controllers: only when forking is required

Tell Claude to do this and its first instinct is to make Dev::XxxController for every page. Wrong — most controllers are shared across both domains. Only namespace when behavior actually diverges.

In practice only two:

# app/controllers/dev/home_controller.rb
class Dev::HomeController < ApplicationController
  allow_unauthenticated_access
  layout "dev"

  def index
    @category = Category.find_by!(slug: "claude-code")
    @series = @category.series.order(:position)
    @standalone_articles = @category.articles
                                    .where(series: nil)
                                    .published
                                    .order(:position)

    first_free = @category.articles.published.where(free: true).order(:position).first
    @cta_article = first_free || @category.articles.published.order(:position).first
  end
end
# app/controllers/dev/articles_controller.rb
class Dev::ArticlesController < ApplicationController
  include ContentGate
  allow_unauthenticated_access
  layout "dev"

  def show
    @category = Category.find_by!(slug: "claude-code")
    @article = @category.articles.published.find_by!(slug: params[:slug])
    @series = @article.series
    @series_articles = @series.articles.published.order(:position) if @series
    gate_content!(@article)
  end
end

Dev articles is locked to Category.find_by!(slug: "claude-code") — that's the data-layer constraint for the .dev domain. /:slug looks up by article slug directly; unlike .com it doesn't need /:category_slug/:slug.

ContentGate is the paywall concern, shared across both sites — paid rules are identical, only the entry paths differ.

Smart helpers: keep views from knowing the host

View templates shouldn't contain if request.host.include?("how2claude.dev") — ugly, not DRY. Extract helpers:

# app/helpers/application_helper.rb
def on_dev_domain?
  request.host.include?("how2claude.dev")
end

def smart_article_path(article, category)
  if on_dev_domain?
    dev_article_path(slug: article.slug)
  else
    article_path(category_slug: category.slug, slug: article.slug)
  end
end

def smart_category_path(category)
  if on_dev_domain?
    dev_root_path
  else
    category_path(category_slug: category.slug)
  end
end

Then every shared view (articles/_row.html.erb, articles/show.html.erb, breadcrumbs, sidebar) uses smart_article_path(article, category) instead of article_path(...).

smart_category_path returns dev_root_path on .dev — because .dev has no separate category page (the whole site is one category), category links should point home.

These helpers let a single view template behave correctly across both domains without forking the view.

Layout fork: theme + nav, but stay disciplined

app/views/layouts/dev.html.erb and application.html.erb are forked — dark vs. light, monospace vs. sans, emerald vs. orange. But every functional link stays aligned with the main layout:

<!-- layouts/dev.html.erb -->
<nav class="...">
  <%= link_to "how2claude.dev", dev_root_path,
        class: "font-mono font-bold text-emerald-400..." %>

  <div class="flex items-center gap-4 text-sm">
    <%= link_to t("pricing.page_title"), dev_pricing_path, class: "..." %>

    <% if authenticated? %>
      <%= link_to t("nav.account"), accounts_path, class: "..." %>
    <% else %>
      <%= link_to t("nav.sign_in"), new_session_path, class: "..." %>
    <% end %>

    <!-- locale dropdown -->
  </div>
</nav>

The visuals fork, but Pricing / Sign in / Account / locale switcher — none of those functional links go missing. Stepped on this one (below).

Pitfall #1: the /:slug catch-all ate /pricing (commit 23163cc)

Dev routes started out looking like this:

constraints(host: /how2claude\.dev/) do
  scope "(:locale)" do
    root "dev/home#index", as: :dev_root
    get "/:slug", to: "dev/articles#show", as: :dev_article
  end
end

Visiting how2claude.dev/pricing → matches /:slugDev::ArticlesController#showCategory.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing")ActiveRecord::RecordNotFound → 404.

Fix: declare /pricing explicitly before /:slug:

 constraints(host: /how2claude\.dev/) do
   scope "(:locale)" do
     root "dev/home#index", as: :dev_root
+    get "/pricing", to: "pages#pricing", as: :dev_pricing
     get "/:slug", to: "dev/articles#show", as: :dev_article
   end
 end

Rails routes match in declaration order/pricing has to come before /:slug to claim the match first.

Bonus trap: the alias has to be dev_pricing, not pricing — because the .com block already has as: :pricing. Rails won't error; the later definition silently overwrites the earlier one, so pricing_path on .com would start generating a URL tied to the .dev block. When Claude first wrote this snippet, it used as: :pricing; I only caught it when I hit /pricing on .com and noticed something was off — the URL string /pricing looked right, but the underlying route target had changed (and since both routes hit PagesController#pricing, the bug was silent).

Rule: when the same action is hooked into multiple route blocks, each block needs a unique as.

Pitfall #2: dev nav missed Pricing + Sign in (commit eac4b2f)

First time I had Claude build dev.html.erb it wrote a stripped version — brand logo + locale switcher only. Reasoning: "dev site is a content site, keep nav clean."

Shipped it. Registered a new account on .dev — no Sign in link. Had to hand-type /session/new. Then paid users came through — no Pricing link. Had to hand-type /pricing.

That's not clean design — that's missing features. eac4b2f copied the .com nav core over, restyled for dark theme.

Rule: visuals can fork (dark/light, monospace/sans) but functionality must stay aligned. Every core entry point one site has (Pricing, Sign in, Account, locale) the other needs, unless there's an explicit product reason not to.

When asking Claude to build a forked layout, tell it explicitly: "keep all functional entries, only change the visuals." Its default is to "simplify," and on the user side "simplify" reads as "feature missing."

Pitfall #3: /accounts had no Sign-out button (commit 92b34a8)

Not directly tied to multi-domain, but surfaced in the same stretch.

Rails 8's session template generates a DELETE /session route but never exposes the button anywhere. The only way for users to log out:

curl -X DELETE https://how2claude.com/session -H "Cookie: ..."

Clearly not acceptable. 92b34a8 added a muted "Sign out" button_to at the bottom of /accounts with turbo_confirm to avoid accidental clicks.

Rule: when auditing feature completeness, don't just trace the forward path. Signup, login, paying, using — these Claude will cover comprehensively. Reverse paths (sign out, cancel subscription, delete account) get missed. Having Claude enumerate "every user state transition" gives broader coverage than asking it to "do the account page."

Local development

Production hosts are how2claude.com and how2claude.dev; locally we need to simulate both. /etc/hosts:

127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test

Then the route constraint adds .test as a local alias for .dev:

constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do

(In the above, how2claude.test is effectively the local alias for .dev. Local .com traffic hits default localhost or 127.0.0.1 — no host constraint matches, so it falls into the default route block.)

In practice: bin/rails s -b 127.0.0.1 -p 3000 running, then hit how2claude.test:3000 in the browser to test .dev routes, localhost:3000 to test .com routes. Two domains, one Rails process, change code and both reload.

Checklist

Letting Claude run two brand sites from one Rails codebase — complete checklist:

  1. Lay out shared vs. forked first — which modules match, which don't. Don't open namespaces up front.
  2. Wrap dev block with constraints(host:) in routes.rb, placing it before the default block (Rails matches in declaration order).
  3. Dev namespace controllers only when behavior diverges. Shareable controllers (PagesController, AccountsController) hook into both route blocks with unique as names to avoid collision.
  4. Explicitly declare specific pages like /pricing before /:slug catch-alls, or they get swallowed.
  5. smart_article_path / on_dev_domain? helpers keep views host-agnostic. No if request.host.include?(...) inside templates.
  6. Fork visuals in the layout but keep functional entries aligned. Tell Claude "preserve all functionality, only change visuals" — its default is to simplify, and that reads as feature loss.
  7. Audit reverse paths separately. Sign out, cancel, delete get missed; asking Claude to enumerate "every user state transition" covers more than asking it to "do every page."
  8. Use /etc/hosts + .test domain locally to simulate the dev domain. Add .test as a dev alias in the route constraint.

The technical difficulty of running two brand sites off one Rails codebase isn't high — constraints(host:) plus helper abstractions get you there. What actually needs your attention is the discipline of the fork: what's shared, what's forked, when to fork, when to merge. Claude can write the code right, but it won't decide for you whether the two sites should look the same. That's a product judgment call.