Free

Hacer que Claude corra dos sitios de marca desde un solo código Rails (how2claude.com + how2claude.dev)

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 terminal

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


Pregunta clave: qué se comparte, qué se bifurca

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.

Arquitectura: el bloque host-constraint en routes.rb

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:

  1. La regex del host debe ser precisa — ancla el prefijo 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.
  2. El scope de locale anida dentro de host constraint — ambos bloques de ruta envuelven 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).

Controllers de namespace dev: solo cuando bifurcar es necesario

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.

Helpers inteligentes: mantén las views sin saber del host

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.

Bifurcación del layout: tema + nav, pero con disciplina

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

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

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

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

Desarrollo local

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.

Checklist

Hacer que Claude corra dos sitios de marca desde un código Rails — checklist completa:

  1. Mapea compartido vs. bifurcado primero — qué módulos coinciden, cuáles no. No abras namespaces de entrada.
  2. Envuelve el bloque dev con constraints(host:) en routes.rb, ubicándolo antes del bloque default (Rails matchea en orden de declaración).
  3. Controllers de namespace dev solo cuando el comportamiento diverge. Controllers compartibles (PagesController, AccountsController) se enganchan a ambos bloques de ruta con nombres as únicos para evitar colisión.
  4. Declara explícitamente páginas específicas como /pricing antes de catch-alls /:slug, o se los traga.
  5. Helpers smart_article_path / on_dev_domain? mantienen views agnósticas del host. Nada de if request.host.include?(...) dentro de templates.
  6. Bifurca visuales en el layout pero mantén entradas funcionales alineadas. Dile a Claude "preserva toda la funcionalidad, solo cambia lo visual" — su default es simplificar, y eso se lee como pérdida de feature.
  7. Audita paths reversos por separado. Sign out, cancelar, eliminar se pierden; pedirle a Claude que enumere "toda transición de estado de usuario" cubre más que pedirle "haz cada página."
  8. Usa /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.