Dos marcas, una app Rails — host constraints en routes.rb + namespace dev + helpers inteligentes + tres baches reales (slug catch-all, deriva de nav, sign-out ausente).
how2claude tiene dos sitios:
how2claude.com — el sitio general, que cubre todas las categorías de uso de Claude (getting-started / prompting / use-cases / tools / comparisons / claude-code)how2claude.dev — enfocado a desarrolladores, solo muestra la categoría claude-code, marca separada, URLs planas, tema oscuro de terminalEl 95% del contenido es compartido (mismos datos de artículo), el 20% de la presentación necesita bifurcarse (marca, nav, estructura de URL, tema). Dos repos Rails obviamente no vale la pena — duplicas el mantenimiento del sistema de artículos, pagos, cuentas, OAuth, i18n. Pero un código, dos dominios tiene muchos bordes que trazar: cómo se separan las rutas, ¿se reutilizan controllers?, ¿cómo sabe la view en qué dominio corre?, ¿dónde se bifurca el layout?
Dejé que Claude lo construyera. Lo que sigue es el registro completo de dónde caen las líneas de bifurcación — trabajo de feature de hace 5 días, ambos sitios ahora en producción.
Lista de compartido vs. bifurcado de entrada:
Compartido (un solo código, ambos dominios lo usan):
- Modelo de artículo, Category, Series
- Login OAuth, suscripción Stripe, pagos x402
- Página de cuenta /accounts (misma feature, misma UI)
- Página de precios /pricing (mismos datos, mismos controles Stimulus)
- Mecanismo de locale i18n, 19 archivos de idioma
Bifurcado (diferente entre dominios):
- Home: .com navegación multi-categoría vs. .dev diseño específico para devs (hero $ claude --master)
- Estructura URL: .com /:category/:slug vs. .dev /:slug (plano)
- Contenido visible: .dev solo muestra artículos de categoría claude-code
- Tema: .com fondo blanco con acentos orange vs. .dev oscuro monospace emerald
- Navegación: .com tiene enlaces de categoría vs. .dev simplificado a Pricing + Sign in
Principio núcleo: no tocar la capa de datos, bifurcar controllers donde haga falta, hacer views / helpers conscientes del host.
Todo el forking fluye desde 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
Dos notas:
how2claude\.dev con \A, matchea puertos opcionales con (:\d+)?. how2claude.test es el dominio de desarrollo local (vía dnsmasq o /etc/hosts apuntando a 127.0.0.1); agregarlo al patrón hace que las rutas dev funcionen localmente también.scope "(:locale)", los prefijos URL como /zh, /ja funcionan en ambos sitios sin reescribir.El mismo PagesController#pricing se engancha en ambos bloques — un método de controller, distintos route helpers: .com usa pricing_path, .dev usa dev_pricing_path. Este truco de alias evita una "colisión de nombre de helper" (dos dominios nombrando su helper de /pricing como pricing harían que la última definición sobrescribiera la anterior).
Dile a Claude que haga esto y su primer instinto es crear Dev::XxxController para cada página. Mal — la mayoría de controllers son compartidos entre ambos dominios. Solo ponles namespace cuando el comportamiento realmente diverge.
En la práctica solo dos:
# 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 está fijo en Category.find_by!(slug: "claude-code") — esa es la restricción de capa de datos para el dominio .dev. /:slug busca por slug de artículo directamente; al contrario de .com no necesita /:category_slug/:slug.
ContentGate es el concern del paywall, compartido entre ambos sitios — reglas de pago idénticas, solo los paths de entrada difieren.
Las views no deberían contener if request.host.include?("how2claude.dev") — feo, no DRY. Extrae 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
Luego cada view compartida (articles/_row.html.erb, articles/show.html.erb, breadcrumbs, sidebar) usa smart_article_path(article, category) en vez de article_path(...).
smart_category_path devuelve dev_root_path en .dev — porque .dev no tiene página de categoría separada (todo el sitio es una categoría), los enlaces de categoría deben apuntar a home.
Estos helpers permiten que una sola view template se comporte bien en ambos dominios sin bifurcar la view.
app/views/layouts/dev.html.erb y application.html.erb están bifurcados — oscuro vs. claro, monospace vs. sans, emerald vs. orange. Pero cada enlace funcional se mantiene alineado con el 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>
Lo visual se bifurca, pero Pricing / Sign in / Account / switcher de locale — ninguno de esos enlaces funcionales desaparece. Pisé esta trampa (abajo).
/:slug se comió /pricing (commit 23163cc)Las rutas dev empezaron viéndose así:
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
Visitar how2claude.dev/pricing → matchea /:slug → Dev::ArticlesController#show → Category.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing") → ActiveRecord::RecordNotFound → 404.
Fix: declarar /pricing explícitamente 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
Las rutas de Rails matchean en orden de declaración — /pricing tiene que ir antes de /:slug para reclamar el match primero.
Trampa bonus: el alias tiene que ser dev_pricing, no pricing — porque el bloque .com ya tiene as: :pricing. Rails no lanzará error; la última definición silenciosamente sobrescribe la primera, así que pricing_path en .com empezaría a generar un URL atado al bloque .dev. Cuando Claude escribió esto inicialmente, puso as: :pricing; solo lo cacé cuando fui a /pricing en .com y noté algo raro — el string URL /pricing se veía bien, pero el target de ruta había cambiado (y como ambas rutas caían en PagesController#pricing, el bug era silencioso).
Regla: cuando el mismo action se engancha a múltiples bloques de ruta, cada bloque necesita un as único.
eac4b2f)La primera vez que le dije a Claude que construyera dev.html.erb escribió una versión stripped — logo de marca + switcher de locale solamente. Razonamiento: "sitio dev es un sitio de contenido, nav limpia."
Lo mandé. Registré una cuenta nueva en .dev — sin enlace de Sign in. Tuve que tipear /session/new a mano. Luego usuarios pagos — sin enlace de Pricing. A tipear /pricing a mano.
Eso no es diseño limpio — es features faltantes. eac4b2f copió la nav core de .com, restilizada a tema oscuro.
Regla: lo visual puede bifurcar (oscuro/claro, monospace/sans) pero la funcionalidad debe mantenerse alineada. Cada entry point core que un sitio tiene (Pricing, Sign in, Account, locale) el otro lo necesita, a menos que haya razón explícita de producto para no.
Cuando le pidas a Claude un layout bifurcado, dile explícitamente: "mantén todas las entradas funcionales, solo cambia lo visual." Su default es "simplificar," y del lado del usuario "simplificar" se lee como "feature faltante."
/accounts no tenía botón Sign-out (commit 92b34a8)No directamente ligado a multi-dominio, pero surgió en el mismo tramo.
La template de session de Rails 8 genera una ruta DELETE /session pero nunca expone el botón. La única forma de que los usuarios hagan logout:
curl -X DELETE https://how2claude.com/session -H "Cookie: ..."
Claramente inaceptable. 92b34a8 agregó un button_to muted de "Sign out" al pie de /accounts con turbo_confirm para evitar clicks accidentales.
Regla: al auditar completitud de feature, no solo rastrees el path directo. Signup, login, pagar, usar — eso Claude lo cubre comprensivamente. Paths reversos (sign out, cancelar suscripción, eliminar cuenta) se pierden. Hacer que Claude enumere "toda transición de estado de usuario" da cobertura más amplia que pedirle "haz la página de cuenta."
Los hosts de producción son how2claude.com y how2claude.dev; localmente necesitamos simular ambos. /etc/hosts:
127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test
Luego el constraint de ruta agrega .test como alias local de .dev:
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
(En lo anterior, how2claude.test es efectivamente el alias local de .dev. Tráfico .com local pega en el default localhost o 127.0.0.1 — ningún host constraint matchea, cae al bloque de ruta default.)
En la práctica: bin/rails s -b 127.0.0.1 -p 3000 corriendo, luego entrar a how2claude.test:3000 en el navegador para testear rutas .dev, localhost:3000 para .com. Dos dominios, un proceso Rails, cambia código y ambos recargan.
Hacer que Claude corra dos sitios de marca desde un código Rails — checklist completa:
constraints(host:) en routes.rb, ubicándolo antes del bloque default (Rails matchea en orden de declaración).as únicos para evitar colisión./pricing antes de catch-alls /:slug, o se los traga.smart_article_path / on_dev_domain? mantienen views agnósticas del host. Nada de if request.host.include?(...) dentro de templates./etc/hosts + dominio .test localmente para simular el dominio dev. Agrega .test como alias dev en el constraint de ruta.La dificultad técnica de correr dos sitios de marca desde un código Rails no es alta — constraints(host:) más abstracciones con helpers te llevan ahí. Lo que realmente necesita tu atención es la disciplina de la bifurcación: qué se comparte, qué se bifurca, cuándo bifurcar, cuándo fusionar. Claude puede escribir el código correcto, pero no decide por ti si los dos sitios deberían verse iguales. Eso es juicio de producto.