こんにちは。Inflabのフロントエンド開発者、GOHSUEです。
昨年8月、Inflearnのヘッダー(GNB)がリニューアルされました! 🎉
Inflearnのヘッダーは複数のフロントエンド環境で動作しなければならない複雑な構造になっており、以前からさまざまな問題を抱えていました。
本記事では、Inflearnのヘッダーがどのように動作しているのか、そしてどのような過程を経て問題を解決していったのかをまとめました。
1. ヘッダーをリニューアルした理由
Inflearnのヘッダーは、ローンチ以来ほとんど変更されたことがありませんでした。
サービスが追加されるたびに既存の構造に押し込むだけだったため、コアサービスがはっきりと見えない状態になっていました。
現在、Inflearnは講座、チャレンジ、メンタリング、クリップ、コミュニティをコアサービスとして位置づけています。
今回のプロジェクトの目標は、これらのコアサービスをヘッダーで明確に見せ、ユーザーが直感的に探索・検索・購入・学習できるように改善することでした。
デザインのリニューアル自体はすでに完了していましたが、その道のりは決して単純ではありませんでした。
ここからは、このヘッダーが動作しているMFE環境の構造と、その中で直面した技術的な課題を一つずつ紐解いていきます。
2. MFEアーキテクチャとModule Federation
2-1. Inflabチームのフロントエンドアーキテクチャ
モノリシックからドメイン分離へ
初期のInflearnは、社内でAntmanと呼んでいる自社製JavaScriptフレームワークをベースにしたモノリシックなプロジェクトでサービスを運営していました。
サービスの規模が大きくなるにつれて開発者の数も増え、ビルド速度も遅くなり始めました。
最大の問題は、1台のサーバーにフロントエンドとバックエンドが同居していたため、どちらか一方で障害が起きるとサービス全体が止まってしまうことでした。
これを解決するため、Reactへの移行にあわせてドメインごとにプロジェクトを分離しました。
フロントエンドに限って見ると、モノレポもあればポリレポ(シングルレポ)もあり、形態はさまざまです。いずれも、各ドメインが独立して運用されるマイクロフロントエンド(MFE)構成になっています。
このように分離されたアプリケーションは、リバースプロキシを通じて1つのサービスとして動作しています。
ユーザーがアクセスしたURLのパスに応じて、プロキシが適切なアプリケーションへリクエストをルーティングする仕組みです。
リバースプロキシとは、クライアントのリクエストを受け取り、内部の複数のサーバーのうち適切なところへ転送する中間サーバーです。ユーザーは1つのドメインにアクセスしますが、実際には背後にある複数のアプリケーションのうちの1つがレスポンスを返します。
バックエンドもサーバーが分離されたことで、一方の障害が全体に広がる問題は解消されました。フロントエンドの観点でも、各アプリケーションが独立してビルド・デプロイされるため、ランタイムエラーやデプロイの失敗がほかのサービスに影響を与えなくなりました。
パッケージごとにさまざまな環境を構成でき、コードのコンフリクトも減りました。
分離したら、かえって忙しくなった
しかし、共通で使うコンポーネントがある場合は、変更するたびにすべてのパッケージに反映してデプロイしなければならないという手間がありました。
その代表例がヘッダーでした。
ヘッダーは、さまざまなサービスの追加や実験が行われる中で、頻繁に変更されていました。
ヘッダーを一度修正すると、次のような手順を踏みます。
- レガシーなAntman側のヘッダーのコードを修正してデプロイ
- Reactのヘッダー共通モジュールのコードを修正してデプロイ
- 共通モジュールを使っている各ドメインのパッケージを更新してデプロイ
つまり、共通モジュールを使っているパッケージが10個あれば、10個すべてをデプロイしなければならないということです。
この問題を解決するために、いくつかの方法を検討しました。
方法1: サーバーサイドコンポジション
フロントエンド専用のサーバーを置き、そこでヘッダーと各アプリケーションの成果物を組み合わせて、1つのHTMLとしてレンダリングして返す方式です。
しかし、すべてのトラフィックが1台のサーバーに集中し、そのサーバーに障害が起きるとすべてのページが麻痺してしまうという欠点がありました。
方法2: CI/CDのディスパッチによる自動デプロイ
共通モジュールに変更があれば、ワークフローで各パッケージにディスパッチして自動デプロイする方式です。
既存のフローに自動化を載せただけの形です。しかし、ほかのメンバーがそのパッケージで作業中だったり、ホットフィックスを出していたりすると衝突するおそれがありました。developブランチが常にデプロイ可能な状態だという保証もありませんでした。
すべてのパッケージのデプロイ結果をいちいち確認する手間まで考えると、DXは良くありませんでした。
方法3: ランタイムローディング(Module Federation)
バンドルしたJSをS3に置いておき、各アプリケーションがランタイムにそれを取得して実行する構成です。
webpackのModule Federationを活用した方式です。
ヘッダーモジュールをデプロイするだけで全アプリケーションに即座に反映され、ロールバックもS3のファイルパスを変更するだけで済むので、保守が容易です。
もちろんトレードオフもありました。
ランタイムにJSを読み込む分ネットワークの状態に左右されますし、CSRベースなのでSEOにも不利です。共有依存関係の管理など、学習コストも増えます。
それでも私たちは独立した環境でのDXを優先していたため、3つ目の方式を採用しました。
ここまでが、Module Federationによるヘッダーを導入した背景です。
3. 以前のヘッダーの限界
3-1. 二重管理
Module Federationのおかげで、Reactアプリケーション間の共通モジュールは一か所で管理できるようになりました。
私たちは、ヘッダー、フッター、認証などの共通領域を担うこのホストアプリケーションを App Shell(アプリシェル) と呼んでいます。
しかし、アプリシェルだけでは解決できない問題がまだありました。
課題
Antmanには、まだReactに移行されていないページが多く残っています。
これらのページは素のJavaScriptとHTML、CSSで作られているため、アプリシェルのReactヘッダーをそのまま使うことができませんでした。
結果として、Antman用のヘッダー(素のJS)とアプリシェル用のヘッダー(React)という、同じデザインのヘッダーを2つ管理していました。
例えば、ヘッダーに新しいメインメニュー「ロードマップ」を追加する場合、アプリシェルとAntmanの両方を修正しなければなりませんでした。
これまではヘッダーの修正を最小限に抑え、二重作業を受け入れてきました。しかし今回のリニューアルではデザインが完全に変わるため、これ以上2つを管理し続けることはできませんでした。
解決策
解決の糸口は、チーム内ですでに使っていたアダプターモジュールでした。
このモジュールは、ReactコンポーネントをJSにバンドルしてS3に置いておき、Antman側でmanifestからそのファイルを探して、ランタイムに読み込む仕組みです。
モーダルのように隔離された領域の中でレンダリングされるため、依存関係とスタイルがすべてバンドルに含まれ、既存のAntmanのコードに影響を与えない構造でした。
この方式をヘッダーにも使えるのではないか、と考えたのがきっかけです。
ただし、既存のアダプターモジュールの使い方とは異なる点がありました。
- バンドルの互換性: アプリシェルはModule Federationに合わせてビルドされていたため、Antmanから単独で読み込める別のエントリーを構成する必要がありました。また、Module Federationのsharedとして切り出していた共有依存関係も、Antmanにはそれを提供するホストが存在しないため、すべてバンドルに含める必要がありました。
- スタイルの衝突: モーダルは隔離されたレイヤーの上に表示されますが、ヘッダーはAntmanのレイアウトに直接組み込まれるため、既存のスタイルと衝突する可能性がありました。
- Layout Shift: ランタイムにヘッダーを読み込む分、読み込みの前後でレイアウトがずれる問題を解決する必要がありました。
本格的なヘッダーのリニューアルに入る前に、まずこの3つを解決してデプロイすることにしました。
デザインの変更と統合作業を同時にデプロイすると、問題が起きたときに原因を特定しにくくなるからです。
バンドルの互換性は、Viteを使ってAntman専用のエントリーポイントを別途構成することで解決しました。
アプリシェルのwebpackビルドは既存の方式を維持し、Antmanに渡すモジュールはViteのエントリーに追加して、単独で実行できるバンドルを生成しました。
必要な依存関係は前述のアダプターモジュール側に追加し、アプリシェル本体の依存関係の構造には手を加えずに対応しました。
スタイルの衝突は、AntmanのグローバルCSSと重ならないようにクラス名を分けることで解決しました。
ヘッダーコンポーネントのスタイルには emotion を使い、ランタイムにランダムなクラス名を生成するようにしました。
どうしても残るクラス名については、Antmanのコードベースに同じ名前がないかを検索しました。さらに、そのクラスにイベントリスナーがバインドされていないかまで確認し、衝突を避けました。
Layout Shiftは、MutationObserver を活用して解決しました。
ヘッダーが読み込まれる前は同じ高さのplaceholderを配置しておき、MutationObserverでヘッダーのDOMが追加されたタイミングを検知して、placeholderを削除する方式です。
統合作業を先にデプロイし、ヘッダーのリニューアル作業の間も並行してモニタリングを続けることで、安定性を確保しました。
3-2. ランタイムローディングによるちらつき問題
課題
次は、スケルトンが頻繁に表示される問題です。
アプリシェルのヘッダーはランタイムにJSを読み込んでレンダリングする構造なので、読み込みが完了するまではスケルトンUIを表示します。
CSRベースのアプリケーションでは最初に一度読み込めばその後は保持されますが、SSRベースのアプリケーションではページを遷移するたびにHTMLが新たに返され、ヘッダーのJSも毎回読み込み直されます。
その結果、ページを切り替えるたびにスケルトンが表示され、ヘッダーがちらついていました。
特にネットワークが遅い環境ではこのちらつきがより長く続き、ページ全体の読み込みが遅いような印象を与えていました。
また、ヘッダーが頻繁に変更されるのに対してスケルトンは別途管理されていたため、実際のヘッダーとスケルトンの見た目が一致しないという問題もありました。
解決策
一般的な解決方法としては、ランタイムローディング(CSR)の代わりにSSRでヘッダーをレンダリングする方法があります。
しかしこの方式は、前述のとおり、すべてのパッケージにヘッダーを反映してデプロイしなければならないというDX低下の問題がありました。
UXのためにSSRに切り替えるべきか、DXを維持すべきか悩みました。
ユーザー体験を優先するなら、SSRに変更する理由は十分にありました。
しかし別の観点から考えてみると、サーバーから初期HTMLとして返すスケルトンが実際のヘッダーにもっと近い形であればどうだろう、というアイデアが浮かびました。
以前にレンダリングされたヘッダーの状態を記憶しておき、その値をOptimistic UIのように先に表示してから、最新の状態に置き換える方式です。
これがスナップショットというアプローチです。ヘッダーの最後の状態をあらかじめ撮っておき、単純なスケルトンの代わりに、以前の状態を反映した精巧なスケルトンを表示することです。
初回訪問者はCookieが存在しないため、主要なアイコンだけを表示するようにしました。
スナップショットのデータをどこに保存するか
クライアントで状態を保存する場合、セッションストレージやローカルストレージを使うのが一般的です。
しかし今回は、サーバーからHTMLを返す際に保存された値を一緒に活用する必要があったため、Cookieに保存する方式を選びました。
CookieはHTTPリクエストヘッダーに自動的に含まれるので、サーバーサイドでも値を読み取れるからです。
Cookieの値をスケルトンにそのまま反映できない理由
最初は、SSRの段階でCookieを読み取ってスケルトンにそのまま反映する方法を検討しました。
しかし、2つの制約がありました。
1つ目は、スケルトンコンポーネントの中でCookieの値に応じて条件付きレンダリングをすると、ハイドレーションエラーが起きうることです。
CDNキャッシュなどによってサーバーとクライアントでCookieの値が異なる場合、コンポーネントツリーの構造そのものが変わってしまうからです。
つまり、サーバーから返すコンポーネントにCookieベースの分岐を作ることはできませんでした。
2つ目は、ヘッダーのスケルトンがModule Federationのfallbackとして静的に宣言されていることです。そのため req オブジェクトにアクセスできず、Cookieの値そのものを知ることができませんでした。
data attribute + CSSで解決する
この制約を回避するため、<html> タグのdata attributeにスナップショットの値を注入し、CSSの属性セレクターとCSS変数でスケルトンの表示・非表示とテキストを制御することにしました。
この方式のポイントは、コンポーネントツリーに手を加えないことです。
スケルトンのメニュー項目は常にすべて同じようにレンダリングし、表示・非表示はCSSだけで制御します。そのため、サーバーとクライアントでコンポーネントツリーが食い違うことはなく、ハイドレーションエラーも起きません。
Cookieに次のような値が保存されていると仮定します。
{
"menuList": {
"courses": "講座",
"challenges": "チャレンジ"
}
}
この値を活用して、スナップショットスケルトンをどのように構成するのか見ていきます。
(分かりやすくするため、具体的なUIデザインは省略します。)
メインメニューを表すコンポーネントが、次のように構成されていると仮定します。
<ul class="class-menu-list">
<li class="class-menu-courses" />
<li class="class-menu-challenges" />
<li class="class-menu-community" />
...
</ul>
ここで、Cookieに保存されたメニューだけを選択的に表示するには、<html> タグにdata attributeとCSS変数をあらかじめ注入しておけば済みます。
<html
data-show-menu-courses="1"
data-show-menu-challenges="1"
style="--label-courses:'講座'; --label-challenges:'チャレンジ';"
>
<ul class="class-menu-list">
<li class="class-menu-courses" />
<li class="class-menu-challenges" />
<li class="class-menu-community" />
...
</ul>
</html>
これを制御するグローバルCSSは次のとおりです。
html .class-menu-list {
display: flex;
}
/* メニュー項目はデフォルトで非表示 */
html .class-menu-list > li {
display: none;
}
/* Cookieに存在するメニューのみ表示 */
html[data-show-menu-courses='1'] .class-menu-courses {
display: block;
}
html[data-show-menu-challenges='1'] .class-menu-challenges {
display: block;
}
.class-menu-courses::after {
content: var(--label-courses);
}
.class-menu-challenges::after {
content: var(--label-challenges);
}
スケルトンのメニュー項目はデフォルトですべて非表示になっており、Cookieに存在するメニューだけdata attributeの値が '1' に設定され、CSSによって表示されます。
同時に、CSS変数によってメニューのラベルテキストまで、サーバーのレスポンス時点で表現できます。
パッケージに適用する
このdata attributeを自動で構成するヘルパー関数を作り、アプリシェルを使っているすべてのパッケージの _document に適用しました。
_document に追加するコード自体は一度きりですが、メニューが追加されたりデザインが変更されたりした場合は、getState を含む共有モジュールのバージョンを上げて、各パッケージを再デプロイする必要があります。
ただし、これはスケルトンの構造自体が変わる場合に限られるので、頻度はかなり低いです。SSR方式とは違って各パッケージのコードを直接修正する必要もないため、変更に伴う作業量を最小限に抑えられました。
export default class _Document extends Document<MyDocumentProps> {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx);
const cookies = ctx.req?.headers.cookie ?? '';
const state = getState(cookies, ctx.pathname);
return {
...initialProps,
state,
};
}
render() {
const { state } = this.props;
return (
<Html {...state.attrs} style={state.cssVars}>
<Head />
<Main />
<NextScript />
</Html>
);
}
}
この方式によって、SSRに切り替えなくても、ユーザーには以前の状態を反映した精巧なスケルトンを表示できるようになりました。
UXとDXの間で、スナップショットという落としどころを見つけたと言えます。
4. UX要件の解決: 自然なレイアウトとモーション
構造的な制約を解決したら、次はその上でUXを磨き上げる番でした。
今回のリニューアルでは、2つの要件がありました。
パスによってヘッダーのレイアウトが変わり、検索バーにはスクロールに反応するモーションが必要でした。
それぞれを実装する中で直面した課題と、それをどう解決したかをまとめます。
4-1. パスごとの検索バー領域の確保
課題
パスによって検索バーを表示するかどうかが変わるので、ヘッダーコンポーネントの中でパスごとに分岐すればよいように見えます。
しかし、3-2章で扱ったスケルトンも合わせて考慮する必要がありました。
検索バーが表示されるパスでは、スケルトンの段階から検索バーの高さ分のスペースを確保しておかないと、ヘッダーが読み込まれた後にLayout Shiftが発生してしまうからです。
解決策
先ほどスナップショットで使った方式を、そのまま拡張しました。
スケルトンにCSSの属性を注入する際に、パスの情報も一緒に受け取り、検索バー領域を表示するかどうかを決めるようにしました。
export function getState(
cookieHeader?: string | null,
pathname?: string
): State {
const state: State = { attrs: {}, cssVars: {} };
const snapShot = getSnapShot(cookieHeader); // Cookieの情報からスナップショットのjsonをパース
if (!snapShot) {
return state;
}
...
// 検索バーを表示するかどうかを決定
const isSearchBarVisible = isVisibleSearchBarWithParams(pathname);
// 検索バー関連の属性を追加
state.attrs[ATTRIBUTE_PREFIXES.SHOW_SEARCH_BAR] = createBooleanValue(isSearchBarVisible);
...
return state;
}
こうすると、サーバーからHTMLを返す際に、<html> タグに検索バーを表示するかどうかがdata attributeとして含まれます。
<html data-show-search-bar="1" ...>
...
</html>
CSSの属性セレクターを活用すれば、コンポーネントを変更しなくても検索バーのスペースを確保できます。
html[data-show-search-bar='1'] .class-search-bar {
height: calc(DEFAULT_HEIGHT);
@media (max-width: 1024px) {
height: calc(COMPACT_HEIGHT);
}
}
4-2. スクロールモーションのパフォーマンス改善
課題
検索バーが表示されるページでは、スクロール時に検索バーがヘッダーの中へ上がっていくアニメーションが必要でした。
最初の実装では、このモーションを height と scale プロパティで処理していました。
height の値が変わると、ブラウザはレンダーツリーのreflowを行うため、CPUでの計算が発生します。
一覧ページのようにレンダリング要素が多い画面では、スクロールするたびにreflowが繰り返され、画面全体がカクついていました。
解決策
以前の構造ではヘッダー領域と検索バー領域が分かれており、スクロール時に検索バー領域の height を縮めながら検索バーを上に移動させる方式でした。
この場合、height が変わるたびにreflowが発生します。
ポイントは、reflowを引き起こすプロパティの代わりに、Composite段階で処理できる transform を活用することでした。
この問題を解決するために、Airbnbのヘッダー構造を参考にしました。
Airbnbのヘッダーにもスクロール時に検索バーが縮小されるインタラクションがありますが、height を変更せずに transform だけでモーションを処理していました。
変更後の構造では、検索バーをヘッダーの内部に配置します。
検索バーが占めるスペースを確保するため、コンテンツ領域も translateY で下方向に移動させます。
スクロールが発生すると、検索バーの translateY の値を小さくして、ヘッダーの中へ再び上がってくるようにします。
transform: translateY() はComposite段階でGPUによって処理できるため、レンダーツリーのreflowなしでモーションを実装できます。
CPUの代わりにGPUを活用したおかげで、レンダリング要素の多い一覧ページでも、スクロールモーションがスムーズに動作するようになりました。
5. 完璧な構造ではなく、現実的な構造を選ぶまで
ここまでの内容を振り返ると、ヘッダーのちらつきを解決するための最もきれいな方向性ははっきりしていました。
各アプリケーションでヘッダーを直接SSRでレンダリングすることです。
そうすれば、ランタイムローディングによるスケルトンの表示もなく、スナップショットのような回避策も必要ありません。
しかし、私たちはその方向性を選びませんでした。
SSRに切り替えると、ヘッダーを変更するたびにすべてのパッケージを再ビルドしてデプロイしなければなりません。
2章で説明したように、この構成は、ヘッダーの変更が頻繁な私たちの環境においてDXを大きく下げる原因でした。
Module FederationベースのランタイムローディングにはUXの面で明らかなデメリットがありますが、ヘッダーだけを独立してデプロイできるというメリットのほうが、私たちのチームの運用方法に合っていました。
スナップショットは根本的な解決策ではありませんでした。それでも、目の前のUXの問題を許容できるレベルまで引き上げられたことで、その後は依存関係のバンドルサイズや読み込み遅延の原因を分析しながら、構造改善を続ける余裕が生まれました。
短期的にはユーザー体験を改善し、長期的には構造改善のための土台を整えたと言えます。
今回のヘッダーのリニューアルは、技術的な観点では、DXを向上させつつヘッダーのUXの問題を最小限に抑える落としどころを見つけることでした。
完璧な構造ではありませんが、現在の組織規模とデプロイ環境において、最も現実的なバランスだと判断しました。
| リニューアル前 | リニューアル後 | |
|---|---|---|
| ヘッダーのコード管理 | アプリシェル + Antmanの2つ | アプリシェル1つに統合 |
| ヘッダー変更時のデプロイ | 全パッケージを個別にデプロイ | アプリシェルのみデプロイ |
| スケルトン | 実際のヘッダーと不一致 | スナップショットベースの精巧なスケルトン |
| スクロールモーション | reflowベース(CPU) | Compositeベース(GPU) |
6. まだ残っている課題
リニューアルによって多くの点が改善されましたが、まだ解決できていない課題も残っています。
1つ目は、アプリシェルのAPIの読み込み速度です。
ヘッダーはランタイムにJSを読み込んだ後、ユーザー情報などをAPIで取得してレンダリングを完了します。
スナップショットによって見た目のちらつきは減らせましたが、APIのレスポンスが遅い場合は、依然として最終的なレンダリングまでに時間がかかります。
この区間の遅延を減らすことが次の課題です。
2つ目は、Antmanの移行完了です。
現在は、Antmanに残っているレガシーページのために、アダプターモジュールを経由してヘッダーを読み込む構造を維持しています。
AntmanのすべてのページがReactに移行されれば、このアダプターレイヤーを取り除くことができ、全体の構造がさらにシンプルになると期待しています。
7. おわりに
今回のヘッダーのリニューアルは、単なるデザイン変更ではなく、MFE環境で共通コンポーネントをどう管理するかという悩みの延長線上にあるものでした。
理想的な構造と現実の間で毎回妥協点を見つける必要があり、その過程でスナップショットのような回避策が生まれることもありました。
正解とは言いにくいものの、私たちの環境で見つけられる最善の答えだったと考えています。
複数の環境で動作しなければならない共通コンポーネントを管理しながら、理想的な構造と現実の間で悩んでいる方にとって、この記事が少しでも参考になれば幸いです。
長文を読んでいただき、ありがとうございました! 😄
※この記事は2026年3月5日に執筆されたものです。














