Zwei Marken, eine Rails-App — Host-Constraints in routes.rb + Dev-Namespace + smarte Helper + drei echte Fallen (Catch-all-Slug, Nav-Drift, fehlender Sign-out).
how2claude hat zwei Seiten:
how2claude.com — die allgemeine Seite, deckt alle Kategorien der Claude-Nutzung ab (getting-started / prompting / use-cases / tools / comparisons / claude-code)how2claude.dev — entwicklerorientiert, zeigt nur die Kategorie claude-code, eigene Marke, flache URLs, dunkles Terminal-Thema95% des Inhalts ist geteilt (gleiche Artikeldaten), 20% der Darstellung muss forken (Marke, Nav, URL-Struktur, Thema). Zwei Rails-Repos sind offensichtlich nicht wert — Artikelsystem, Zahlungen, Accounts, OAuth, i18n, alle im Wartungsaufwand verdoppelt. Aber eine Codebasis, zwei Domains hat einen Haufen Grenzen zu ziehen: wie teilen sich die Routen, werden Controller wiederverwendet, wie weiß die View, auf welcher Domain sie läuft, wo forkt das Layout.
Ich habe Claude das bauen lassen. Was folgt, ist das vollständige Protokoll, wo die Fork-Linien liegen — Feature-Arbeit von vor 5 Tagen, beide Seiten jetzt in Produktion.
Geteilt vs. geforkt von vornherein kartographieren:
Geteilt (ein Code, beide Domains nutzen es):
- Artikel-Modell, Category, Series
- OAuth-Login, Stripe-Abo, x402-Zahlungen
- Account-Seite /accounts (gleiches Feature, gleiche UI)
- Pricing-Seite /pricing (gleiche Daten, gleiche Stimulus-Controls)
- i18n-Locale-Mechanismus, 19 Sprachdateien
Geforkt (unterschiedlich zwischen Domains):
- Startseite: .com Mehrfach-Kategorie-Browsing vs. .dev entwicklerspezifisches Design (Hero $ claude --master)
- URL-Struktur: .com /:category/:slug vs. .dev /:slug (flach)
- Sichtbarer Inhalt: .dev zeigt nur Artikel der Kategorie claude-code
- Thema: .com weißer Hintergrund mit Orange-Akzenten vs. .dev dunkel monospace emerald
- Navigation: .com hat Kategorie-Links vs. .dev vereinfacht auf Pricing + Sign in
Kernprinzip: Datenschicht nicht anfassen, Controller bei Bedarf forken, Views / Helper host-bewusst machen.
Alles Forking fließt aus 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
Zwei Anmerkungen:
how2claude\.dev-Präfix mit \A, matche optionale Ports mit (:\d+)?. how2claude.test ist die lokale Entwicklungsdomain (via dnsmasq oder /etc/hosts auf 127.0.0.1 zeigend); sie ins Pattern aufzunehmen lässt Dev-Routen auch lokal funktionieren.scope "(:locale)", URL-Präfixe wie /zh, /ja funktionieren auf beiden Seiten ohne Neuschreiben.Derselbe PagesController#pricing ist in beiden Blöcken eingehängt — eine Controller-Methode, verschiedene Route-Helper: .com nutzt pricing_path, .dev nutzt dev_pricing_path. Dieser Alias-Trick vermeidet eine "Helper-Namenskollision" (zwei Domains, die ihren /pricing-Helper pricing nennen, würden dazu führen, dass die spätere Definition die frühere überschreibt).
Sag Claude, das zu tun, und sein erster Instinkt ist, Dev::XxxController für jede Seite zu erstellen. Falsch — die meisten Controller sind zwischen beiden Domains geteilt. Nur namespacen, wenn das Verhalten tatsächlich divergiert.
In der Praxis nur zwei:
# 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 ist auf Category.find_by!(slug: "claude-code") festgenagelt — das ist die Datenschicht-Einschränkung für die .dev-Domain. /:slug sucht per Artikel-Slug direkt; anders als .com braucht es kein /:category_slug/:slug.
ContentGate ist das Paywall-Concern, zwischen beiden Seiten geteilt — bezahlte Regeln identisch, nur Eingangspfade unterscheiden sich.
View-Templates sollten if request.host.include?("how2claude.dev") nicht enthalten — hässlich, nicht DRY. Helper extrahieren:
# 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
Dann verwendet jede geteilte View (articles/_row.html.erb, articles/show.html.erb, Breadcrumbs, Sidebar) smart_article_path(article, category) statt article_path(...).
smart_category_path gibt auf .dev dev_root_path zurück — weil .dev keine separate Kategorieseite hat (die ganze Seite ist eine Kategorie), sollten Kategorie-Links nach Home zeigen.
Diese Helper lassen ein einziges View-Template auf beiden Domains korrekt verhalten, ohne die View zu forken.
app/views/layouts/dev.html.erb und application.html.erb sind geforkt — dunkel vs. hell, monospace vs. sans, emerald vs. orange. Aber jeder funktionale Link bleibt mit dem Haupt-Layout ausgerichtet:
<!-- 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>
Das Visuelle forkt, aber Pricing / Sign in / Account / Locale-Switcher — keiner dieser funktionalen Links verschwindet. Auf diesen reingefallen (unten).
/:slug-Catch-all hat /pricing verschluckt (Commit 23163cc)Dev-Routen sahen zunächst so aus:
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
Besuch von how2claude.dev/pricing → matcht /:slug → Dev::ArticlesController#show → Category.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing") → ActiveRecord::RecordNotFound → 404.
Fix: /pricing explizit vor /:slug deklarieren:
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-Routen matchen in Deklarationsreihenfolge — /pricing muss vor /:slug kommen, um den Match zuerst zu beanspruchen.
Bonus-Falle: der Alias muss dev_pricing sein, nicht pricing — weil der .com-Block bereits as: :pricing hat. Rails wird keinen Fehler werfen; die spätere Definition überschreibt stillschweigend die frühere, sodass pricing_path auf .com anfangen würde, eine an den .dev-Block gebundene URL zu generieren. Als Claude das zuerst geschrieben hat, nutzte er as: :pricing; ich habe es nur erwischt, als ich auf .com /pricing aufgerufen habe und bemerkte, dass etwas nicht stimmte — der URL-String /pricing sah richtig aus, aber das Route-Ziel hatte sich geändert (und weil beide Routen auf PagesController#pricing trafen, war der Bug leise).
Regel: Wenn dieselbe Action an mehrere Routenblöcke gebunden ist, braucht jeder Block einen eindeutigen as-Namen.
eac4b2f)Das erste Mal, als ich Claude dev.html.erb bauen ließ, schrieb er eine abgespeckte Version — Markenlogo + Locale-Switcher nur. Begründung: "Dev-Seite ist eine Content-Seite, Nav sauber halten."
Live geschickt. Neuen Account auf .dev registriert — kein Sign-in-Link. Musste /session/new per Hand tippen. Dann kamen zahlende Nutzer — kein Pricing-Link. /pricing per Hand tippen.
Das ist kein sauberes Design — das sind fehlende Features. eac4b2f kopierte den Core der .com-Nav, neu gestylt für Dunkeltheme.
Regel: Visuelles kann forken (dunkel/hell, monospace/sans), aber Funktionalität muss ausgerichtet bleiben. Jeder Core-Einstiegspunkt, den eine Seite hat (Pricing, Sign in, Account, Locale), braucht die andere auch, außer es gibt einen expliziten Produktgrund, es nicht zu tun.
Wenn du Claude bittest, ein geforktes Layout zu bauen, sag's explizit: "behalte alle funktionalen Einstiege, ändere nur das Visuelle." Sein Default ist "vereinfachen," und nutzerseitig liest sich "vereinfachen" als "Feature fehlt."
/accounts hatte keinen Sign-out-Button (Commit 92b34a8)Nicht direkt mit Multi-Domain verknüpft, aber in derselben Phase aufgetaucht.
Rails 8's Session-Template generiert eine DELETE /session-Route, aber legt den Button nirgendwo offen. Einziger Weg für Nutzer zum Ausloggen:
curl -X DELETE https://how2claude.com/session -H "Cookie: ..."
Eindeutig inakzeptabel. 92b34a8 fügte einen gedämpften "Sign out" button_to unten an /accounts hinzu, mit turbo_confirm gegen versehentliche Klicks.
Regel: Wenn du Feature-Vollständigkeit auditierst, verfolge nicht nur den Vorwärtspfad. Registrierung, Login, Zahlung, Nutzung — das deckt Claude umfassend ab. Rückwärtspfade (Ausloggen, Abo kündigen, Account löschen) werden übersehen. Claude "jeden User-Zustandsübergang" aufzählen zu lassen gibt breitere Abdeckung als ihn zu bitten "mach die Account-Seite."
Produktions-Hosts sind how2claude.com und how2claude.dev; lokal müssen wir beide simulieren. /etc/hosts:
127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test
Dann fügt die Route-Constraint .test als lokalen Alias für .dev hinzu:
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
(Oben ist how2claude.test effektiv der lokale Alias für .dev. Lokaler .com-Traffic trifft localhost oder 127.0.0.1 default — keine Host-Constraint matcht, es fällt in den Default-Routenblock.)
In der Praxis: bin/rails s -b 127.0.0.1 -p 3000 läuft, dann how2claude.test:3000 im Browser für .dev-Routen treffen, localhost:3000 für .com. Zwei Domains, ein Rails-Prozess, Code ändern und beide laden neu.
Claude zwei Markenseiten aus einer Rails-Codebasis laufen lassen — vollständige Checkliste:
constraints(host:) in routes.rb umhüllen, vor den Default-Block platzieren (Rails matcht in Deklarationsreihenfolge).as-Namen zur Kollisionsvermeidung./pricing explizit vor /:slug-Catch-alls deklarieren, sonst werden sie verschluckt.smart_article_path / on_dev_domain? halten Views host-agnostisch. Kein if request.host.include?(...) in Templates./etc/hosts + .test-Domain nutzen zur Simulation der Dev-Domain. .test als Dev-Alias in der Route-Constraint hinzufügen.Die technische Schwierigkeit, zwei Markenseiten aus einer Rails-Codebasis zu betreiben, ist nicht hoch — constraints(host:) plus Helper-Abstraktionen bringen dich dorthin. Was wirklich deine Aufmerksamkeit braucht, ist die Disziplin des Forks: was ist geteilt, was forkt, wann forken, wann zusammenführen. Claude kann den Code richtig schreiben, aber er entscheidet nicht für dich, ob die beiden Seiten gleich aussehen sollten. Das ist ein Produkt-Urteil.