Duas marcas, um app Rails — host constraints no routes.rb + namespace dev + helpers inteligentes + três armadilhas reais (slug catch-all, deriva de nav, sign-out faltando).
how2claude tem dois sites:
how2claude.com — o site geral, cobrindo todas as categorias de uso do Claude (getting-started / prompting / use-cases / tools / comparisons / claude-code)how2claude.dev — voltado a devs, mostra só a categoria claude-code, marca separada, URLs planas, tema escuro de terminal95% do conteúdo é compartilhado (mesmos dados de artigo), 20% da apresentação precisa bifurcar (marca, nav, estrutura de URL, tema). Dois repos Rails obviamente não vale — dobra a manutenção do sistema de artigos, pagamentos, contas, OAuth, i18n. Mas um código, dois domínios tem uma pilha de bordas pra traçar: como as rotas se separam, reusa os controllers?, como a view sabe em qual domínio roda?, onde o layout bifurca?
Deixei o Claude construir. O que segue é o registro completo de onde ficam as linhas de bifurcação — trabalho de feature de 5 dias atrás, os dois sites agora em produção.
Mapeia compartilhado vs. bifurcado de cara:
Compartilhado (um código, ambos os domínios usam):
- Modelo de artigo, Category, Series
- Login OAuth, assinatura Stripe, pagamentos x402
- Página de conta /accounts (mesma feature, mesma UI)
- Página de pricing /pricing (mesmos dados, mesmos controles Stimulus)
- Mecanismo de locale i18n, 19 arquivos de idioma
Bifurcado (diferente entre os domínios):
- Home: .com navegação multi-categoria vs. .dev design específico pra devs (hero $ claude --master)
- Estrutura de URL: .com /:category/:slug vs. .dev /:slug (plano)
- Conteúdo visível: .dev só mostra artigos da categoria claude-code
- Tema: .com fundo branco com acentos orange vs. .dev escuro monospace emerald
- Nav: .com tem links de categoria vs. .dev simplificado pra Pricing + Sign in
Princípio central: não mexe na camada de dados, bifurca controllers onde necessário, torna views / helpers cientes do host.
Todo o forking flui do 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
Duas notas:
how2claude\.dev com \A, case portas opcionais com (:\d+)?. how2claude.test é o domínio de dev local (via dnsmasq ou /etc/hosts apontando pra 127.0.0.1); adicionando no pattern faz as rotas dev funcionarem localmente também.scope "(:locale)", os prefixos URL tipo /zh, /ja funcionam em ambos sites sem reescrever.O mesmo PagesController#pricing é plugado em ambos os blocos — um método de controller, route helpers diferentes: .com usa pricing_path, .dev usa dev_pricing_path. Esse truque de alias evita "colisão de nome de helper" (dois domínios nomeando o helper de /pricing como pricing faria a última definição sobrescrever a primeira).
Diz pro Claude fazer isso e o primeiro instinto dele é criar Dev::XxxController pra cada página. Errado — a maioria dos controllers é compartilhada entre ambos os domínios. Só cria namespace quando o comportamento realmente diverge.
Na prática só dois:
# 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 tá travado em Category.find_by!(slug: "claude-code") — essa é a restrição de camada de dados pro domínio .dev. /:slug busca por slug de artigo direto; ao contrário de .com não precisa de /:category_slug/:slug.
ContentGate é o concern do paywall, compartilhado entre ambos os sites — regras de pagamento idênticas, só os paths de entrada diferem.
Templates de view não deveriam conter if request.host.include?("how2claude.dev") — feio, não é DRY. Extrai 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
Aí cada view compartilhada (articles/_row.html.erb, articles/show.html.erb, breadcrumbs, sidebar) usa smart_article_path(article, category) em vez de article_path(...).
smart_category_path retorna dev_root_path em .dev — porque .dev não tem página de categoria separada (o site todo é uma categoria), links de categoria devem apontar pra home.
Esses helpers deixam uma única view template se comportar correto em ambos os domínios sem bifurcar a view.
app/views/layouts/dev.html.erb e application.html.erb estão bifurcados — escuro vs. claro, monospace vs. sans, emerald vs. orange. Mas cada link funcional fica alinhado com o layout principal:
<!-- 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>
O visual bifurca, mas Pricing / Sign in / Account / seletor de locale — nenhum desses links funcionais some. Pisei nessa (abaixo).
/:slug engoliu /pricing (commit 23163cc)As rotas dev começaram assim:
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
Acessar how2claude.dev/pricing → casa /:slug → Dev::ArticlesController#show → Category.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing") → ActiveRecord::RecordNotFound → 404.
Fix: declarar /pricing explicitamente antes de /: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
Rotas do Rails casam na ordem de declaração — /pricing tem que vir antes de /:slug pra pegar primeiro.
Armadilha bônus: o alias tem que ser dev_pricing, não pricing — porque o bloco .com já tem as: :pricing. Rails não dá erro; a última definição silenciosamente sobrescreve a anterior, então pricing_path no .com começaria a gerar uma URL amarrada ao bloco .dev. Quando o Claude escreveu isso primeiro, usou as: :pricing; só peguei quando fui em /pricing no .com e notei algo estranho — o string URL /pricing parecia certo, mas o alvo de rota tinha mudado (e como as duas rotas batiam em PagesController#pricing, o bug era silencioso).
Regra: quando o mesmo action é plugado em múltiplos blocos de rota, cada bloco precisa de um as único.
eac4b2f)Primeira vez que mandei o Claude construir dev.html.erb ele escreveu uma versão stripped — logo da marca + switcher de locale só. Raciocínio: "site dev é site de conteúdo, nav limpa."
Subi. Registrei conta nova no .dev — sem link de Sign in. Teve que digitar /session/new na mão. Aí usuários pagos vieram — sem link de Pricing. Digitar /pricing na mão.
Isso não é design limpo — é feature faltando. eac4b2f copiou o core da nav do .com, restilizado pra tema escuro.
Regra: o visual pode bifurcar (escuro/claro, monospace/sans) mas a funcionalidade tem que ficar alinhada. Cada entry point core que um site tem (Pricing, Sign in, Account, locale) o outro precisa, a não ser que tenha razão explícita de produto pra não.
Quando pedir pro Claude construir um layout bifurcado, fala explícito: "mantém todas as entradas funcionais, só muda o visual." O default dele é "simplificar," e do lado do usuário "simplificar" se lê como "feature faltando."
/accounts não tinha botão de Sign-out (commit 92b34a8)Não diretamente ligado a multi-domínio, mas surgiu no mesmo período.
O template de session do Rails 8 gera uma rota DELETE /session mas nunca expõe o botão em lugar nenhum. Única forma dos usuários fazerem logout:
curl -X DELETE https://how2claude.com/session -H "Cookie: ..."
Claramente inaceitável. 92b34a8 adicionou um button_to muted de "Sign out" no pé de /accounts com turbo_confirm pra evitar cliques acidentais.
Regra: ao auditar completude de feature, não rastreia só o path direto. Signup, login, pagar, usar — isso o Claude cobre de forma abrangente. Paths reversos (sign out, cancelar assinatura, deletar conta) são perdidos. Fazer o Claude enumerar "toda transição de estado do usuário" dá cobertura mais ampla do que pedir "faz a página de conta."
Hosts de produção são how2claude.com e how2claude.dev; localmente precisamos simular os dois. /etc/hosts:
127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test
Aí o constraint de rota adiciona .test como alias local de .dev:
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
(No acima, how2claude.test efetivamente é o alias local de .dev. Tráfego .com local bate no default localhost ou 127.0.0.1 — nenhum host constraint casa, cai no bloco de rota default.)
Na prática: bin/rails s -b 127.0.0.1 -p 3000 rodando, aí bate how2claude.test:3000 no navegador pra testar rotas .dev, localhost:3000 pra .com. Dois domínios, um processo Rails, muda código e os dois recarregam.
Fazer o Claude rodar dois sites de marca a partir de um código Rails — checklist completa:
constraints(host:) no routes.rb, colocando antes do bloco default (Rails casa em ordem de declaração).as únicos pra evitar colisão./pricing antes de catch-alls /:slug, ou elas são engolidas.smart_article_path / on_dev_domain? mantêm views agnósticas ao host. Nada de if request.host.include?(...) dentro de templates./etc/hosts + domínio .test localmente pra simular o domínio dev. Adiciona .test como alias dev no constraint de rota.A dificuldade técnica de rodar dois sites de marca a partir de um código Rails não é alta — constraints(host:) mais abstrações com helpers te levam lá. O que realmente precisa da sua atenção é a disciplina da bifurcação: o que é compartilhado, o que bifurca, quando bifurcar, quando fundir. O Claude consegue escrever o código certo, mas não decide por você se os dois sites deveriam parecer iguais. Isso é julgamento de produto.