Dua brand, satu app Rails — host constraints di routes.rb + namespace dev + helper pintar + tiga jebakan nyata (slug catch-all, nav drift, sign-out hilang).
how2claude punya dua situs:
how2claude.com — situs umum, cover semua kategori pemakaian Claude (getting-started / prompting / use-cases / tools / comparisons / claude-code)how2claude.dev — fokus developer, cuma nampilin kategori claude-code, brand terpisah, URL datar, tema dark terminal95% konten di-share (data artikel sama), 20% presentasi perlu bercabang (brand, nav, struktur URL, tema). Dua repo Rails jelas gak sepadan — maintenance sistem artikel, pembayaran, akun, OAuth, i18n jadi dobel. Tapi satu basis kode, dua domain punya banyak batas yang harus digambar: rute dipisah gimana, controller di-reuse?, gimana view tau domain mana dia jalan?, di mana layout bercabang?
Saya biarin Claude bangun ini. Yang berikut adalah catatan lengkap di mana garis bercabang berada — kerjaan feature 5 hari lalu, sekarang kedua situs jalan di produksi.
Peta share vs. bercabang dari awal:
Share (satu kode, kedua domain pakai):
- Model artikel, Category, Series
- Login OAuth, langganan Stripe, pembayaran x402
- Halaman akun /accounts (fitur sama, UI sama)
- Halaman pricing /pricing (data sama, kontrol Stimulus sama)
- Mekanisme locale i18n, 19 file bahasa
Bercabang (beda antar domain):
- Home: .com browsing multi-kategori vs. .dev desain spesifik dev (hero $ claude --master)
- Struktur URL: .com /:category/:slug vs. .dev /:slug (datar)
- Konten terlihat: .dev cuma nampilin artikel kategori claude-code
- Tema: .com background putih dengan aksen orange vs. .dev dark monospace emerald
- Navigasi: .com ada link kategori vs. .dev disederhanakan jadi Pricing + Sign in
Prinsip inti: jangan sentuh lapisan data, bercabangin controller seperlunya, bikin view / helper sadar host.
Semua forking ngalir dari 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
Dua catatan:
how2claude\.dev dengan \A, match port opsional dengan (:\d+)?. how2claude.test adalah domain dev lokal (via dnsmasq atau /etc/hosts ngarah ke 127.0.0.1); nambahin ke pattern bikin rute dev jalan di lokal juga.scope "(:locale)", prefiks URL kayak /zh, /ja jalan di kedua situs tanpa nulis ulang.PagesController#pricing yang sama dicantol di kedua blok — satu method controller, route helper beda: .com pake pricing_path, .dev pake dev_pricing_path. Trik alias ini hindarin "kolisi nama helper" (dua domain namain helper /pricing jadi pricing bakal bikin definisi terakhir nutup definisi pertama).
Suruh Claude lakuin ini, insting pertamanya bikin Dev::XxxController tiap halaman. Salah — mayoritas controller di-share dua domain. Cuma kasih namespace pas perilaku beneran divergen.
Praktisnya cuma dua:
# 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 dikunci ke Category.find_by!(slug: "claude-code") — itu constraint lapisan data buat domain .dev. /:slug cari pake slug artikel langsung; beda sama .com gak butuh /:category_slug/:slug.
ContentGate itu concern paywall, di-share di kedua situs — aturan bayar identik, cuma path masuknya beda.
Template view harusnya gak isi if request.host.include?("how2claude.dev") — jelek, gak DRY. Ekstrak helper:
# 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
Terus semua view share (articles/_row.html.erb, articles/show.html.erb, breadcrumb, sidebar) pake smart_article_path(article, category) gantiin article_path(...).
smart_category_path balikin dev_root_path di .dev — karena .dev gak punya halaman kategori terpisah (seluruh situs satu kategori), link kategori harus nunjuk ke home.
Helper ini bikin satu template view berperilaku bener di kedua domain tanpa bercabangin view.
app/views/layouts/dev.html.erb dan application.html.erb bercabang — dark vs. terang, monospace vs. sans, emerald vs. orange. Tapi setiap link fungsional tetap align sama layout utama:
<!-- 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>
Visualnya bercabang, tapi Pricing / Sign in / Account / switcher locale — gak ada satu pun link fungsional yang ilang. Kena jebakan ini (di bawah).
/:slug nelen /pricing (commit 23163cc)Rute dev awalnya kayak gini:
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
Akses how2claude.dev/pricing → match /:slug → Dev::ArticlesController#show → Category.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing") → ActiveRecord::RecordNotFound → 404.
Fix: deklarasiin /pricing eksplisit sebelum /: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
Rute Rails match dalam urutan deklarasi — /pricing harus datang sebelum /:slug biar ambil match duluan.
Jebakan bonus: alias harus dev_pricing, bukan pricing — karena blok .com udah punya as: :pricing. Rails gak bakal error; definisi terakhir diam-diam nutup definisi pertama, jadi pricing_path di .com bakal mulai generate URL yang terikat ke blok .dev. Pas Claude nulis ini pertama kali, dia pake as: :pricing; baru ketangkep pas saya balik ke /pricing di .com dan ngerasa ada yang gak beres — string URL /pricing keliatan bener, tapi target rute udah berubah (dan karena dua rute hit PagesController#pricing, bug-nya silent).
Aturan: pas action yang sama dicantol ke beberapa blok rute, tiap blok butuh nama as unik.
eac4b2f)Pertama kali saya suruh Claude bangun dev.html.erb dia nulis versi stripped — logo brand + switcher locale aja. Alasan: "situs dev itu situs konten, nav bersih."
Saya rilis. Daftar akun baru di .dev — gak ada link Sign in. Harus ngetik /session/new manual. Terus user bayar datang — gak ada link Pricing. Ngetik /pricing manual.
Itu bukan desain bersih — itu fitur hilang. eac4b2f nyalin core nav .com, di-restyle ke dark theme.
Aturan: visual boleh bercabang (dark/terang, monospace/sans) tapi fungsionalitas harus tetap align. Setiap entry point inti yang satu situs punya (Pricing, Sign in, Account, locale) yang lain butuh, kecuali ada alasan produk eksplisit buat gak.
Pas minta Claude bangun layout bercabang, bilang eksplisit: "jaga semua entry fungsional, cuma ubah visual." Default-nya "simplify," dan sisi user "simplify" kebaca sebagai "fitur hilang."
/accounts gak ada tombol Sign-out (commit 92b34a8)Gak langsung terkait multi-domain, tapi muncul di periode sama.
Template session Rails 8 generate rute DELETE /session tapi gak pernah expose tombolnya di mana pun. Satu-satunya cara user logout:
curl -X DELETE https://how2claude.com/session -H "Cookie: ..."
Jelas gak bisa diterima. 92b34a8 nambahin button_to muted "Sign out" di bagian bawah /accounts dengan turbo_confirm biar cegah klik gak sengaja.
Aturan: pas audit kelengkapan fitur, jangan cuma trace path maju. Signup, login, bayar, pakai — ini Claude cover komprehensif. Path mundur (sign out, cancel subscription, delete akun) kemakan. Suruh Claude enumerate "setiap transisi state user" kasih coverage lebih luas dari minta "bikin halaman akun."
Host produksi itu how2claude.com dan how2claude.dev; di lokal perlu simulasi dua-duanya. /etc/hosts:
127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test
Terus constraint rute nambahin .test sebagai alias lokal buat .dev:
constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do
(Di atas, how2claude.test efektifnya alias lokal buat .dev. Trafik .com lokal nimpa default localhost atau 127.0.0.1 — gak ada host constraint match, jatuh ke blok rute default.)
Praktisnya: bin/rails s -b 127.0.0.1 -p 3000 jalan, terus hit how2claude.test:3000 di browser buat test rute .dev, localhost:3000 buat .com. Dua domain, satu proses Rails, ubah kode dan dua-duanya reload.
Membiarkan Claude jalankan dua situs brand dari satu basis kode Rails — checklist lengkap:
constraints(host:) di routes.rb, taruh sebelum blok default (Rails match urutan deklarasi).as unik biar ngehindar kolisi./pricing sebelum catch-all /:slug, atau ketelen.smart_article_path / on_dev_domain? jaga view agnostik host. Gak ada if request.host.include?(...) di dalem template./etc/hosts + domain .test di lokal buat simulasi domain dev. Tambah .test sebagai alias dev di constraint rute.Kesulitan teknis jalanin dua situs brand dari satu basis kode Rails gak tinggi — constraints(host:) plus abstraksi helper udah cukup. Yang beneran perlu perhatian kamu adalah disiplin bercabangnya: apa yang di-share, apa yang bercabang, kapan bercabang, kapan gabung. Claude bisa nulis kode bener, tapi dia gak mutusin buat kamu apakah dua situs harus keliatan sama. Itu judgment produk.