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 theme95% 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.
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.
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:
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.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).
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.
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.
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).
/: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 /:slug → Dev::ArticlesController#show → Category.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.
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."
/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."
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.
Letting Claude run two brand sites from one Rails codebase — complete checklist:
constraints(host:) in routes.rb, placing it before the default block (Rails matches in declaration order).as names to avoid collision./pricing before /:slug catch-alls, or they get swallowed.smart_article_path / on_dev_domain? helpers keep views host-agnostic. No if request.host.include?(...) inside templates./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.