Free

Để Claude chạy hai site thương hiệu từ một codebase Rails (how2claude.com + how2claude.dev)

Hai thương hiệu, một app Rails — host constraints trong routes.rb + namespace dev + helper thông minh + ba cạm bẫy thực tế (slug catch-all, nav lệch, thiếu sign-out).


how2claude có hai site:

  • how2claude.com — site tổng, phủ toàn bộ hạng mục dùng Claude (getting-started / prompting / use-cases / tools / comparisons / claude-code)
  • how2claude.dev — chuyên cho developer, chỉ hiện hạng mục claude-code, thương hiệu riêng, URL phẳng, theme terminal tối

95% nội dung dùng chung (cùng dữ liệu bài), 20% phần trình bày cần tách nhánh (thương hiệu, điều hướng, cấu trúc URL, theme). Hai repo Rails rõ ràng không đáng — hệ thống bài, thanh toán, tài khoản, OAuth, i18n phải bảo trì gấp đôi. Nhưng một codebase, hai tên miền có cả đống biên cần vẽ: route tách thế nào, có tái dùng controller không, view làm sao biết nó đang chạy trên domain nào, layout tách ở đâu.

Tôi để Claude xây cái này. Dưới đây là biên bản đầy đủ vị trí các đường tách — công việc feature cách đây 5 ngày, hai site giờ đều chạy trên production.


Câu hỏi cốt lõi: cái gì chia sẻ, cái gì tách

Liệt kê chia sẻ vs. tách ngay từ đầu:

Chia sẻ (một đoạn code, cả hai domain dùng):
- Model bài, Category, Series
- Đăng nhập OAuth, đăng ký Stripe, thanh toán x402
- Trang tài khoản /accounts (cùng feature, cùng UI)
- Trang pricing /pricing (cùng dữ liệu, cùng control Stimulus)
- Cơ chế locale i18n, 19 file ngôn ngữ

Tách (khác nhau giữa các domain):
- Trang chủ: .com duyệt đa hạng mục vs. .dev thiết kế chuyên dev (hero $ claude --master)
- Cấu trúc URL: .com /:category/:slug vs. .dev /:slug (phẳng)
- Nội dung hiện: .dev chỉ hiện bài hạng mục claude-code
- Theme: .com nền trắng với điểm nhấn orange vs. .dev tối monospace emerald
- Nav: .com có link hạng mục vs. .dev rút gọn còn Pricing + Sign in

Nguyên tắc cốt lõi: không động tầng dữ liệu, tách controller khi cần, làm view / helper ý thức về host.

Kiến trúc: khối host-constraint trong routes.rb

Mọi fork đều xuất phát từ 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

Hai điều cần chú ý:

  1. Regex host phải chính xác — neo prefix how2claude\.dev với \A, match port tùy chọn với (:\d+)?. how2claude.test là domain dev local (qua dnsmasq hoặc /etc/hosts trỏ về 127.0.0.1); thêm vào pattern để route dev cũng chạy được trên local.
  2. Scope locale lồng bên trong host constraint — cả hai khối route đều bọc scope "(:locale)", tiền tố URL như /zh, /ja hoạt động trên cả hai site mà không cần viết lại.

Cùng một PagesController#pricing được móc vào cả hai khối — một phương thức controller, route helper khác nhau: .com dùng pricing_path, .dev dùng dev_pricing_path. Thủ thuật alias này tránh "xung đột tên helper" (hai domain đặt tên helper /pricingpricing sẽ khiến định nghĩa sau đè định nghĩa trước).

Controller namespace dev: chỉ khi cần tách

Bảo Claude làm việc này và bản năng đầu tiên của nó là tạo Dev::XxxController cho mỗi trang. Sai — phần lớn controller được chia sẻ giữa hai domain. Chỉ namespace khi hành vi thực sự phân nhánh.

Thực tế chỉ có hai cái:

# 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 khóa vào Category.find_by!(slug: "claude-code") — đó là ràng buộc tầng dữ liệu cho domain .dev. /:slug tìm theo slug bài trực tiếp; khác .com không cần /:category_slug/:slug.

ContentGate là concern của paywall, dùng chung hai site — luật trả phí giống nhau, chỉ khác đường vào.

Helper thông minh: giữ view không biết về host

Template view không nên chứa if request.host.include?("how2claude.dev") — xấu, không DRY. Tách 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

Rồi mọi view chia sẻ (articles/_row.html.erb, articles/show.html.erb, breadcrumb, sidebar) dùng smart_article_path(article, category) thay cho article_path(...).

smart_category_path trả dev_root_path trên .dev — vì .dev không có trang hạng mục riêng (cả site là một hạng mục), link hạng mục nên trỏ về home.

Bộ helper này cho một template view hoạt động đúng trên cả hai domain mà không tách view.

Tách layout: theme + nav, nhưng phải có kỷ luật

app/views/layouts/dev.html.erbapplication.html.erb đã tách — tối vs. sáng, monospace vs. sans, emerald vs. orange. Nhưng mọi link chức năng vẫn giữ khớp với layout chính:

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

Visual tách, nhưng Pricing / Sign in / Account / switcher locale — không link chức năng nào biến mất. Đã dính bẫy này (bên dưới).

Cạm bẫy #1: /:slug catch-all nuốt /pricing (commit 23163cc)

Route dev ban đầu trông như thế này:

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

Truy cập how2claude.dev/pricing → khớp /:slugDev::ArticlesController#showCategory.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing")ActiveRecord::RecordNotFound → 404.

Fix: khai báo /pricing tường minh trước /: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

Route Rails khớp theo thứ tự khai báo/pricing phải đi trước /:slug để giành khớp.

Bẫy bonus: alias phải là dev_pricing, không được là pricing — vì khối .com đã có as: :pricing. Rails sẽ không báo lỗi; định nghĩa sau âm thầm đè định nghĩa trước, nên pricing_path trên .com sẽ bắt đầu sinh URL gắn với khối .dev. Khi Claude viết snippet này lần đầu, nó dùng as: :pricing; tôi chỉ bắt được khi nhấn /pricing trên .com và nhận ra có gì sai — chuỗi URL /pricing trông đúng, nhưng target route đã đổi (và vì cả hai route đều dẫn về PagesController#pricing, bug âm thầm).

Luật: khi cùng một action được móc vào nhiều khối route, mỗi khối cần một as duy nhất.

Cạm bẫy #2: nav dev thiếu Pricing + Sign in (commit eac4b2f)

Lần đầu bảo Claude xây dev.html.erb nó viết phiên bản rút gọn — logo thương hiệu + switcher locale thôi. Lý do: "site dev là site nội dung, nav phải sạch."

Đẩy lên. Đăng ký tài khoản mới trên .dev — không có link Sign in. Phải gõ /session/new bằng tay. Rồi user trả phí đến — không có link Pricing. Gõ /pricing bằng tay.

Đó không phải thiết kế sạch — đó là thiếu feature. eac4b2f copy nav core của .com sang, restyle theo theme tối.

Luật: visual có thể tách (tối/sáng, monospace/sans) nhưng chức năng phải giữ khớp. Mỗi entry point cốt lõi một site có (Pricing, Sign in, Account, locale) site kia cũng cần, trừ khi có lý do sản phẩm rõ ràng không.

Khi bảo Claude xây layout tách, nói rõ: "giữ mọi entry chức năng, chỉ đổi visual." Mặc định nó là "simplify," và phía user "simplify" đọc thành "thiếu feature."

Cạm bẫy #3: /accounts không có nút Sign-out (commit 92b34a8)

Không liên quan trực tiếp đến multi-domain, nhưng xuất hiện cùng giai đoạn.

Template session của Rails 8 sinh route DELETE /session nhưng không bao giờ expose nút ở đâu cả. Cách duy nhất để user logout:

curl -X DELETE https://how2claude.com/session -H "Cookie: ..."

Rõ ràng không chấp nhận được. 92b34a8 thêm button_to "Sign out" muted ở đáy /accounts với turbo_confirm để tránh click nhầm.

Luật: khi audit độ đầy đủ feature, đừng chỉ trace path xuôi. Signup, login, trả phí, dùng — Claude phủ toàn diện. Path ngược (sign out, hủy đăng ký, xóa tài khoản) bị bỏ qua. Bảo Claude liệt kê "mọi chuyển trạng thái của user" cho phủ rộng hơn là bảo "làm trang tài khoản."

Phát triển local

Host production là how2claude.comhow2claude.dev; trên local cần mô phỏng cả hai. /etc/hosts:

127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test

Rồi constraint route thêm .test làm alias local cho .dev:

constraints(host: /\A(how2claude\.dev|how2claude\.test)(:\d+)?\z/) do

(Ở trên, how2claude.test thực tế là alias local cho .dev. Traffic .com local đập vào localhost hoặc 127.0.0.1 mặc định — không host constraint khớp, rơi vào khối route mặc định.)

Thực tế: bin/rails s -b 127.0.0.1 -p 3000 chạy, rồi đập how2claude.test:3000 trong trình duyệt để test route .dev, localhost:3000 cho .com. Hai domain, một process Rails, đổi code cả hai reload.

Checklist

Để Claude chạy hai site thương hiệu từ một codebase Rails — checklist đầy đủ:

  1. Liệt kê chia sẻ vs. tách trước — module nào khớp, module nào không. Đừng mở namespace ngay từ đầu.
  2. Bọc khối dev bằng constraints(host:) trong routes.rb, đặt trước khối mặc định (Rails khớp theo thứ tự khai báo).
  3. Controller namespace dev chỉ khi hành vi phân nhánh. Controller có thể chia sẻ (PagesController, AccountsController) móc vào cả hai khối route với tên as duy nhất để tránh xung đột.
  4. Khai báo tường minh các trang cụ thể như /pricing trước catch-all /:slug, không thì bị nuốt.
  5. Helper smart_article_path / on_dev_domain? giữ view không biết host. Không if request.host.include?(...) trong template.
  6. Tách visual trong layout nhưng giữ entry chức năng khớp. Bảo Claude "giữ mọi chức năng, chỉ đổi visual" — mặc định nó simplify, và đó đọc thành mất feature.
  7. Audit path ngược riêng. Sign out, hủy, xóa bị bỏ qua; bảo Claude liệt kê "mọi chuyển trạng thái user" phủ rộng hơn là bảo "làm từng trang."
  8. Dùng /etc/hosts + domain .test trên local để mô phỏng domain dev. Thêm .test làm alias dev trong constraint route.

Độ khó kỹ thuật chạy hai site thương hiệu từ một codebase Rails không cao — constraints(host:) cộng trừu tượng helper là đủ. Thứ thực sự cần bạn chú ý là kỷ luật của việc tách: cái gì chia sẻ, cái gì tách, khi nào tách, khi nào hợp. Claude có thể viết code đúng, nhưng nó không quyết định cho bạn liệu hai site có nên trông giống nhau không. Đó là phán đoán sản phẩm.