Free

Membiarkan Claude Menjalankan Dua Situs Brand dari Satu Basis Kode Rails (how2claude.com + how2claude.dev)

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 terminal

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


Pertanyaan inti: apa yang di-share, apa yang bercabang

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.

Arsitektur: blok host-constraint di routes.rb

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:

  1. Regex host harus presisi — jangkar prefiks 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.
  2. Scope locale nested di dalam host constraint — kedua blok rute bungkus 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).

Controller namespace dev: cuma pas bercabang diperlukan

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.

Helper pintar: jagain view gak tau host

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.

Bercabangnya layout: tema + nav, tapi perlu disiplin

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

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

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

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

Development lokal

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.

Checklist

Membiarkan Claude jalankan dua situs brand dari satu basis kode Rails — checklist lengkap:

  1. Peta share vs. bercabang duluan — modul mana yang match, mana yang gak. Jangan buka namespace duluan.
  2. Bungkus blok dev dengan constraints(host:) di routes.rb, taruh sebelum blok default (Rails match urutan deklarasi).
  3. Controller namespace dev cuma pas behavior divergen. Controller yang bisa di-share (PagesController, AccountsController) cantol ke kedua blok rute dengan nama as unik biar ngehindar kolisi.
  4. Deklarasiin eksplisit halaman spesifik kayak /pricing sebelum catch-all /:slug, atau ketelen.
  5. Helper smart_article_path / on_dev_domain? jaga view agnostik host. Gak ada if request.host.include?(...) di dalem template.
  6. Bercabangin visual di layout tapi jaga entry fungsional align. Bilang Claude "pertahanin semua fungsi, cuma ubah visual" — default-nya simplify, dan itu kebaca sebagai kehilangan fitur.
  7. Audit path mundur terpisah. Sign out, cancel, delete kemakan; minta Claude enumerate "setiap transisi state user" cover lebih dari minta "bikin tiap halaman."
  8. Pake /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.