Vinicius Aguiar
アーキテクチャ

ページは514行。枠組みは2,560行。

2026年9月25日 · 6分で読めます

前回の記事では、このサイトのページの中にAngularアプリを組み込みました。ページ内で合成するマイクロフロントエンドです。それは一つのモデルです。もう一つは、もっと説明しやすいモデル、つまりルートによる分割です。サイトのあるパスが、独自のビルドとデプロイを持つ別のアプリに属し、ドメインは同じままです。

そこで、ここでそれを実践しました。ツールページは今、二つ目のNext.jsアプリから配信されています。そのページでDevToolsを開くと、このサイトの他のどこでも返さないヘッダーがレスポンスに含まれています:x-zone: uses。

ページは安い部分だった

ページ自体は514行です。五つの言語で書かれたツールの一覧です。別のアプリに移すのはコピー&ペーストで済むはずでした。

そうはいきません。ページは単独では存在しないからです。サイドバー、モバイルヘッダー、フッター、検索のショートカット、言語とテーマの切り替え、デザイントークン、翻訳の中にあります。数えると、この枠組みは約2,560行、ページの5倍です。ルート分割のマイクロフロントエンドに必要なのはページではなく、その周りの枠組み全体を、二つのアプリで同一に保つことです。

本当の判断はそこにありました。枠組みをコピーすれば、二つのサイドバーは時間とともにずれていきます。新しいアプリに最小限の枠組みを与えれば、別のサイトのように見えるページになります。スケールする答えは、枠組みを共有パッケージにしたモノレポです。

ステップ1:何も変えないモノレポ

新しいアプリを作る前に、リポジトリをpnpmとTurborepoのモノレポに変えました。サイトはapps/portfolioに移り、枠組み、デザインシステム、翻訳はパッケージになりました。ルールは、訪問者が何も気づかないことでした。

「同一」は推測ではなく計測する必要がありました。ファイルを一つも動かす前に、122ページすべてのHTMLとCSSセレクターの完全な集合を保存し、各ステップの後に比較しました。この比較は二度役に立ちました。

  • 58個のCSSクラスが消えた。 Tailwindはドキュメント内のコード例を含むリポジトリ全体をスキャンし、それらのCSSまで生成していました。アプリだけをスキャンするようになると、その死んだCSSはなくなりました。受け入れる前に一つずつ確認しました。
  • 一行消すと枠組みのスタイルが消えた。 Tailwindは見つけたクラスしか生成せず、パッケージはアプリの外にあります。それらを指す明示的な@sourceがなければ、サイドバーはスタイルなしで表示され、すべてのチェックは緑のままです。

その後、独立したレビューがもう一つ見つけました。Turborepoはアプリがパッケージに依存していることを知らなかったため、枠組みだけの変更ではアプリのテスト結果のキャッシュが再利用されていました。CIは翻訳の欠落を通していたでしょう。依存関係のルール一つで直りました。

ステップ2:ゾーン

枠組みがパッケージになれば、新しいアプリは小さくなります。一つのルートを配信し、同じ枠組みを使い、同じドメイン上でポートフォリオのアセットと衝突しないようにアセットにプレフィックスを付けます。

const nextConfig: NextConfig = {
  // The zone's assets must not collide with the portfolio's on the same domain.
  assetPrefix: "/uses-static",
  transpilePackages: ["@repo/i18n", "@repo/ui", "@repo/shell"],
  async headers() {
    return [{ source: "/(.*)", headers: [{ key: "x-zone", value: "uses" }] }]
  },
}

ポートフォリオがそのルートを転送します。本番ビルドでゾーンのURLがなければ、どこにも向かわないrewriteを公開する代わりに、ビルドが失敗します。

export function usesRewrites(env: { USES_ZONE_URL?: string; NODE_ENV?: string }) {
  const raw = env.USES_ZONE_URL ?? (env.NODE_ENV === "production" ? undefined : "http://localhost:3001")
  if (!raw) throw new Error("USES_ZONE_URL is required in production builds")
  const zone = raw.replace(/\/$/, "")
  return [
    { source: `/${USES_LOCALE_PARAM}/uses`, destination: `${zone}/:locale/uses` },
    { source: `/${USES_LOCALE_PARAM}/uses/:path*`, destination: `${zone}/:locale/uses/:path*` },
    { source: "/uses", destination: `${zone}/uses` },
    { source: "/uses-static/:path*", destination: `${zone}/uses-static/:path*` },
  ]
}

ゾーン自身のドメインは、すべてをブロックするrobots.txtを返します。検索エンジンはメインドメインからページにたどり着き、canonical URLはすでにそちらを指しています。

行き先を知っているリンク

微妙だったのはナビゲーションです。クライアントサイドのナビゲーションは同じアプリの中でしか機能しません。ツールページからクライアントサイドのルートでホームに行こうとすると、そのルートを持たないアプリに要求することになります。そこで枠組みは、各パスがどのアプリに属するかを学びました。

export type Zone = "portfolio" | "uses"

const LOCALE_GROUP = `(?:${LOCALES.join("|")})`
const USES = new RegExp(`^(?:/${LOCALE_GROUP})?/uses(?:/.*)?$`)

export function zoneFor(pathname: string): Zone {
  return USES.test(pathname) ? "uses" : "portfolio"
}

export function linkMode(current: Zone, href: string): "client" | "document" | "external" {
  if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) return "external"
  const pathname = href.split(/[?#]/)[0] || "/"
  return zoneFor(pathname) === current ? "client" : "document"
}

枠組みのすべてのリンクとプログラムによるナビゲーションは、この判定を通ります。ツールページ内での言語切り替えはクライアントサイドのままです。ツールページからホームや検索に移るときは、意図的に新しいドキュメントを読み込みます。

かかったコスト

  • 余分なホップ。 直接配信した場合、ゾーンは224 msで応答し、ポートフォリオのrewrite経由では267 msでした。
  • クライアントサイドが自動的に速いわけではない。 ゾーン内の言語切り替え(クライアントサイド)は534 msかかり、ゾーンからホームへの移動(完全なドキュメント)は318 msでした。クライアントサイドの遷移はページのデータをrewrite経由で取得するため、切り替えのたびに往復が一回増えます。
  • デプロイは本当に独立している。 ゾーンだけのコミットはゾーンだけを、ポートフォリオだけのコミットはポートフォリオだけを再ビルドします。枠組みの変更は両方を再ビルドします。両方がそれに依存しているからです。
  • 障害の形が違う。 ゾーンが落ちれば/usesは失敗し、サイトの残りは動き続けるはずです。rewriteにはフォールバックがないからです。Angularの実験では、障害のあるマイクロフロントエンドはブロックを一つ落とし、ページは残りました。

本番でしか見えなかったこと

ローカルではすべて通りました。二つのことは、両方のアプリを公開して初めて現れました。

  • rewrite経由のプリフェッチが失敗していた。 Next.js 16は特別なヘッダーを付けて、ルートをセグメントごとにプリフェッチします。直接呼び出すと、ゾーンはそのリクエストに応答しました。ポートフォリオ経由では、同じリクエストがrewriteを通り、ゾーンが処理できない形で届いて404になりました。ページへの通常のリクエストは問題なく通り、壊れていたのはプリフェッチだけでした。ナビゲーションは動き続けましたが、プリフェッチはありませんでした。最初の修正として、rewriteをプラットフォームの層(vercel.json)に移しましたが、何も変わりませんでした。効いたのは、ゾーン内のリンクのプリフェッチを無効にすることでした。失敗していたリクエストそのものが発生しなくなったのです。
  • 再検証のレスポンスにヘッダーがなかった。 Angularの実験では、記事に戻ってきた訪問者に対して、パネルがモジュールのコストを0.3 KBと表示していました。プラットフォームが再検証で返す304にはCORSとタイミングのヘッダーが一つもなく、ブラウザはヘッダーのサイズしか報告しません。パネルは今それをキャッシュとして扱います。

マネージド製品なら何をしていたか

VercelにはMicrofrontendsという製品があり、一つの設定ファイルから、このルーティングをエッジで行い、ゾーン間のプリフェッチとローカルプロキシも提供します。実験を、費用がかかるかもしれない製品に依存させたくなかったので、私は普通のNext.jsのrewriteで実装しました。違いは配管を誰が運用するかであって、モデルが機能するかどうかではありません。

一つのリポジトリ、二人のオーナー

新しいアプリは独自のリポジトリに置くべきではないか、というのはもっともな疑問です。マイクロフロントエンドの本質は、独立したデプロイとオーナーシップであって、リポジトリではありません。ここではゾーンが独自のアプリ、独自のVercelプロジェクト、独自のデプロイを持ち、CODEOWNERSファイルが責任を明示しています。別のリポジトリにすれば、枠組みをバージョン付きのパッケージとして公開し、二つのアプリをそれに同期させ続けなければなりません。それこそ避けたかったずれです。Angularの実験は、サイトと何も共有していなかったので独自のリポジトリにしました。

割に合わないとき

一人で一ページなら、これはやりすぎで、それこそが狙いでした。コストはページではありません。枠組み、モノレポ、rewrite、そして新しい障害モードです。複数のチームが同じサイトの一部をそれぞれのペースで公開する必要が出てきたとき、割に合い始めます。今は、その最初の一歩がどんなものか分かっています。