Free

Claude に 1 つの Rails コードベースから 2 つのブランドサイトを動かさせる(how2claude.com + how2claude.dev)

2 ブランド、1 Rails アプリ——routes.rb の host constraint、dev namespace、スマートヘルパー、そして 3 つの実戦的落とし穴(catch-all スラッグ、ナビゲーションのずれ、サインアウト漏れ)。


how2claude には 2 つのサイトがある:

  • how2claude.com — 汎用サイト、Claude 活用の全カテゴリをカバー(getting-started / prompting / use-cases / tools / comparisons / claude-code)
  • how2claude.dev — 開発者専用、claude-code カテゴリのみ、独立ブランド、フラット URL、ダークなターミナル調テーマ

コンテンツの 95% は共有(同じ記事データ)、表示の 20% は分岐が必要(ブランド、ナビ、URL 構造、テーマ)。2 つの Rails リポジトリは割に合わない——記事システム、決済、アカウント、OAuth、i18n、全部メンテを 2 倍にすることになる。でも1 コードベースで 2 ドメインをやるには境界を多数引く必要がある:ルートをどう分けるか、コントローラーを再利用するか、view は自分がどのドメインにいるかをどう認識するか、layout の分岐はどこで。

これを Claude にやらせた。本稿は分岐境界の完全実録——5 日前の feature 作業、現在両サイトが本番稼働中。


核心問題:何を共有し、何を分岐するか

共有と分岐をまず列挙:

共有(1 つのコード、両ドメインが使用):
- 記事データモデル、Category、Series
- OAuth ログイン、Stripe サブスク、x402 決済
- アカウントページ /accounts(同じ機能、同じ UI)
- 料金ページ /pricing(同じデータ、同じ Stimulus コントロール)
- i18n locale 機構、19 言語ファイル

分岐(2 ドメインで異なる):
- ホーム:.com マルチカテゴリ閲覧 vs .dev 開発者特化デザイン($ claude --master hero)
- URL 構造:.com /:category/:slug vs .dev /:slug(フラット)
- 表示コンテンツ:.dev は claude-code カテゴリの記事のみ
- テーマ:.com 白背景+orange アクセント vs .dev ダーク monospace emerald
- ナビ:.com カテゴリリンク付き vs .dev 簡略化して Pricing + Sign in のみ

核心原則:データ層は触らない、コントローラー層は必要に応じて分岐、view / helper 層でホスト認識。

アーキテクチャ:routes.rb の host constraint ブロック

全分岐の入口は 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

注意点 2 つ:

  1. host 正規表現は厳密に——how2claude\.dev プレフィックスは \A でアンカー、ポートは (:\d+)? で任意マッチ。how2claude.test はローカル開発ドメイン(dnsmasq か /etc/hosts で 127.0.0.1 に向ける)、パターンに加えれば dev ルートがローカルでも動く。
  2. locale scope は host constraint の中にネスト——両ルートブロック共に scope "(:locale)" でラップ、URL プレフィックスの /zh/ja 機構は両サイト共有、書き直し不要。

同じ PagesController#pricing が両ブロックに紐付けられている、同じコントローラーメソッドが別々の route helper で呼ばれる.compricing_path.devdev_pricing_path。この alias テクニックで「helper 名衝突」を回避(両ドメインの /pricing が同名 helper だと Rails は後定義で前定義を上書き)。

Dev 名前空間コントローラー:分岐が必要なときだけ作る

これを最初に Claude に任せると、各ページに Dev::XxxController を作りたがる。違う——ほとんどのコントローラーは両ドメイン共有。動作が分岐するときだけ namespace を切る。

実際作ったのは 2 つだけ:

# 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 は Category.find_by!(slug: "claude-code") で固定——これが .dev ドメインのデータ層制約。/:slug は記事 slug で直接引く、.com のように /:category_slug/:slug は不要。

ContentGate は有料ゲートの concern、両サイト共有——有料ルールは同一、入口パスだけ違う。

スマート helper:view にホストを意識させない

view テンプレートには if request.host.include?("how2claude.dev") のような判定を入れてはダメ——醜く、DRY でない。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

そして全共有 view(articles/_row.html.erbarticles/show.html.erb、パンくず、サイドバー)は smart_article_path(article, category)article_path(...) を置き換える。

smart_category_path は .dev ドメイン下では dev_root_path を返す——.dev は個別のカテゴリページがない(サイト全体が 1 カテゴリ)ので、カテゴリリンクはホームへ。

この helper 群で 1 つの view テンプレートが両ドメインで正しく振る舞う、view を分岐させる必要なし

Layout の分岐:テーマ + ナビ、ただし規律が要る

app/views/layouts/dev.html.erbapplication.html.erb は分岐している——ダーク vs 明色、monospace vs sans、emerald vs orange。ただし機能的リンクは全て主 layout と整合

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

ビジュアルは分岐、でも Pricing / Sign in / Account / locale 切替は 1 つも欠けない。ここで 1 度ハマった(後述)。

落とし穴 #1:/:slug catch-all が /pricing を飲んだ(commit 23163cc

dev ルートは最初こうなっていた:

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

how2claude.dev/pricing アクセス → /:slug にマッチ → Dev::ArticlesController#showCategory.find_by!(slug: "claude-code").articles.find_by!(slug: "pricing")ActiveRecord::RecordNotFound → 404。

修正:/:slug の前に /pricing を明示宣言

 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 ルートは宣言順にマッチ/pricing/:slug の前にないと先取りできない。

おまけの罠:alias は dev_pricing でなければならず、pricing はダメ——.com ブロック側で既に as: :pricing があるため。Rails はエラーにしない、後定義が前定義を黙って上書きするため、.compricing_path を使うと .dev 版の URL が生成される。Claude がこのスニペットを最初に書いたとき as: :pricing と書いていた、私が .com に戻って /pricing を踏んで初めて気づいた——pricing_path が生成する URL 文字列は /pricing で正しいが、ルートターゲットが変わっていた(同じ PagesController#pricing なので見た目に差がなかった)。

法則:同一アクションを複数のルートブロックに紐付けるとき、各ブロックで unique な as 名を使う

落とし穴 #2:dev ナビから Pricing + Sign in が漏れた(commit eac4b2f

Claude に dev.html.erb をやらせたときは簡略版——ブランドロゴ + locale 切替だけ。理由:「dev サイトはコンテンツサイト、ナビはシンプルに」。

ローンチ後 .dev で新規登録——Sign in リンクがない。/session/new 手打ちしかない。課金ユーザーが来た——Pricing リンクがない。/pricing 手打ちしかない。

これは簡潔なデザインじゃない、機能欠如だ。commit eac4b2f で .com のコアナビをコピーし、ダークテーマにリスタイル。

法則:ビジュアルは分岐できる(ダーク/明色、monospace/sans)が機能は整合させる。片方のサイトにある中核エントリ(Pricing、Sign in、Account、locale)は、もう片方にも必要、明示的な製品理由で外すのでない限り。

Claude に分岐 layout をやらせるときは積極的に言う:「全機能エントリを保持、ビジュアルだけ変更」。デフォルトで「簡略化」に走る、でもユーザーから見ると「簡略化」は機能欠如。

落とし穴 #3:/accounts に Sign out ボタンがなかった(commit 92b34a8

これはマルチドメインと直接関係ないが、同時期に発覚した。

Rails 8 の session テンプレートは DELETE /session ルートを生成するが、ボタンをどこにも出さない。ユーザーのログアウト唯一の方法:

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

明らかにダメ。commit 92b34a8/accounts の下部に muted な "Sign out" button_to を追加、turbo_confirm で誤操作防止。

法則:feature 完全性 audit では正方向パスだけ見ない。登録、ログイン、決済、利用——これらは Claude が網羅する。逆方向パス(ログアウト、解約、アカウント削除)は漏れやすい。Claude に「ユーザーの全状態遷移」を列挙させる方が「アカウントページを作る」より広くカバーする。

ローカル開発

本番ドメインは how2claude.comhow2claude.dev、ローカルでは両方シミュレートが必要。/etc/hosts

127.0.0.1 how2claude.test
127.0.0.1 dev.how2claude.test

そしてルート constraint に .test を dev ドメインのローカルエイリアスとして追加:

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

(上の例で how2claude.test は実質 .dev のローカルエイリアス。.com はローカルではデフォルトの localhost127.0.0.1——host 制約がないのでデフォルトルートブロックに落ちる。)

実際の開発:bin/rails s -b 127.0.0.1 -p 3000 を起動、ブラウザで how2claude.test:3000 で .dev ルート、localhost:3000 で .com ルートをテスト。2 ドメイン、1 Rails プロセス、コード変更で両方 reload。

チェックリスト

Claude に 1 Rails コードベースから 2 ブランドサイトを動かさせる完全チェックリスト:

  1. 共有 vs 分岐を先に明確化——どのモジュールが両方同じ、どのモジュールが違うか。いきなり namespace を開かない。
  2. routes.rbconstraints(host:) で dev ブロックをラップ、デフォルトブロックの前に配置(Rails は宣言順にマッチ)。
  3. dev namespace controller は動作が分岐するときだけ作る。再利用可能なコントローラー(PagesController、AccountsController)は両ルートブロックに紐付け、unique な as 名で衝突回避。
  4. /pricing のような具体ページは /:slug catch-all の前に明示宣言、さもなくば飲まれる。
  5. smart_article_path / on_dev_domain? helper で view にホストを意識させない。view に if request.host.include?(...) を書かない。
  6. layout はビジュアル分岐、機能エントリは整合必須。Claude に「全機能保持、ビジュアルだけ変更」と伝える——デフォルトでナビを簡略化するので、それは機能欠如。
  7. 逆方向パスを個別 audit。ログアウト、解約、削除は漏れやすい、Claude に「ユーザーの全状態遷移」を列挙させる方が「各ページを作る」より広くカバー。
  8. ローカルは /etc/hosts + .test ドメイン で dev ドメインをシミュレート。ルート constraint に .test を dev エイリアスとして追加。

1 Rails コードベースから 2 ブランドサイトを動かす技術的難度は高くない——Rails の constraints(host:) + helper 抽象で十分。本当に要注目なのは分岐の規律:何を共有、何を分岐、いつ分岐、いつ統合。Claude はコードを正しく書ける、でも「2 サイトは見た目を同じにすべきかどうか」は代わりに考えてくれない。それはあなたの製品判断。