Free

Fazendo o Claude rodar dois sites de marca a partir de um código Rails (how2claude.com + how2claude.dev)

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 terminal

95% 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.


Questão central: o que é compartilhado, o que é bifurcado

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.

Arquitetura: o bloco host-constraint no routes.rb

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:

  1. A regex do host precisa ser precisa — ancore o prefixo 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.
  2. Scope de locale aninha dentro do host constraint — ambos os blocos de rota embrulham 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).

Controllers de namespace dev: só quando bifurcação é necessária

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.

Helpers inteligentes: mantém as views sem saber do host

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.

Bifurcação de layout: tema + nav, mas com disciplina

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).

Armadilha #1: o catch-all /: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 /:slugDev::ArticlesController#showCategory.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.

Armadilha #2: nav dev perdeu Pricing + Sign in (commit 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."

Armadilha #3: /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."

Desenvolvimento local

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.

Checklist

Fazer o Claude rodar dois sites de marca a partir de um código Rails — checklist completa:

  1. Mapeia compartilhado vs. bifurcado primeiro — quais módulos batem, quais não. Não abre namespaces de cara.
  2. Embrulha bloco dev com constraints(host:) no routes.rb, colocando antes do bloco default (Rails casa em ordem de declaração).
  3. Controllers de namespace dev só quando comportamento diverge. Controllers compartilháveis (PagesController, AccountsController) plugam em ambos os blocos de rota com nomes as únicos pra evitar colisão.
  4. Declara explicitamente páginas específicas tipo /pricing antes de catch-alls /:slug, ou elas são engolidas.
  5. Helpers smart_article_path / on_dev_domain? mantêm views agnósticas ao host. Nada de if request.host.include?(...) dentro de templates.
  6. Bifurca visuais no layout mas mantém entradas funcionais alinhadas. Diz pro Claude "preserva toda funcionalidade, só muda visual" — o default dele é simplificar, e isso se lê como perda de feature.
  7. Audita paths reversos separadamente. Sign out, cancelar, deletar são perdidos; pedir pro Claude enumerar "toda transição de estado de usuário" cobre mais do que pedir "faz cada página."
  8. Usa /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.