Free

Claude zwei Markenseiten aus einer Rails-Codebasis laufen lassen (how2claude.com + how2claude.dev)

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-Thema

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


Kernfrage: was ist geteilt, was forkt

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.

Architektur: der Host-Constraint-Block in routes.rb

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:

  1. Die Host-Regex muss präzise sein — anker das 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.
  2. Locale-Scope verschachtelt innerhalb des Host-Constraints — beide Routenblöcke umhüllen 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).

Dev-Namespace-Controller: nur wenn Forking erforderlich

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.

Smarte Helper: halte Views host-unwissend

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.

Layout-Fork: Thema + Nav, aber Disziplin bewahren

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

Falle #1: der /: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 /:slugDev::ArticlesController#showCategory.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.

Falle #2: Dev-Nav fehlten Pricing + Sign in (Commit 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."

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

Lokale Entwicklung

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.

Checkliste

Claude zwei Markenseiten aus einer Rails-Codebasis laufen lassen — vollständige Checkliste:

  1. Geteilt vs. geforkt zuerst kartographieren — welche Module matchen, welche nicht. Nicht sofort Namespaces öffnen.
  2. Dev-Block mit constraints(host:) in routes.rb umhüllen, vor den Default-Block platzieren (Rails matcht in Deklarationsreihenfolge).
  3. Dev-Namespace-Controller nur wenn Verhalten divergiert. Teilbare Controller (PagesController, AccountsController) in beide Routenblöcke einhängen mit eindeutigen as-Namen zur Kollisionsvermeidung.
  4. Spezifische Seiten wie /pricing explizit vor /:slug-Catch-alls deklarieren, sonst werden sie verschluckt.
  5. Helper smart_article_path / on_dev_domain? halten Views host-agnostisch. Kein if request.host.include?(...) in Templates.
  6. Layout-Visuals forken, aber funktionale Einstiege ausgerichtet halten. Sag Claude "bewahre alle Funktionalität, ändere nur Visuelles" — sein Default ist vereinfachen, und das liest sich als Feature-Verlust.
  7. Rückwärtspfade separat auditieren. Ausloggen, kündigen, löschen werden übersehen; Claude "jeden User-Zustandsübergang" aufzählen zu lassen deckt mehr ab als ihm zu sagen "mach jede Seite."
  8. Lokal /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.