.NET 9 の「MAUI Blazor Hybrid & Web」構成は、同じ Razor コンポーネントをネイティブと Web で共有できる一方、wwwroot や index.html の“担当”がプロジェクトごとに分かれていて迷子になりがちです。JavaScript と LLM/音声などの大容量ファイルを、1か所で管理しつつ各ターゲットへ確実に届ける方法を整理します。
.NET 9 の「MAUI Blazor Hybrid & Web」で“置き場所迷子”が起きる理由
まず前提として、この構成は「1つのアプリ」ではなく「複数のホスト(実行環境)を持つ1つの UI」を作るのが本質です。具体的には、.NET MAUI(ネイティブ)と Blazor Web App(Web)のそれぞれがホストになり、UI は Razor class library(RCL)で共有します。Microsoft Learn のチュートリアルでも、MAUI と Web を同時に作るソリューションテンプレートが用意され、共有 UI を RCL に集約する方針が示されています。
ここで混乱の元になるのが、ホストごとに「起点となる HTML(HostPage)」と「静的ファイルのルート(wwwroot)」の意味が違うことです。
- MAUI(BlazorWebView)は、ローカルの
wwwroot/index.htmlを HostPage として読み込み、そこから Blazor を起動します。 - Web(Blazor Web App)は、Web サーバー側のパイプラインで静的ファイルや RCL の静的アセットを配布します(プロジェクトによっては Web 側に wwwroot が空、あるいは無いように見える構成もあり得ます)。
- Shared(RCL)は「UI と共通ロジックと共通アセットの入れ物」なので、index.html を持ちません。index.html は “ホストの責務” だからです。
つまり、「wwwroot がどこにあるか」ではなく「誰が HostPage を持つホストなのか」で整理すると一気に分かりやすくなります。
プロジェクト構成の整理:誰がホストで、どこに何があるべきか
Microsoft Learn の移行手順では、例として MauiBlazorWeb.Maui(MAUI)、MauiBlazorWeb.Web(Web)、MauiBlazorWeb.Shared(RCL)という命名で説明されています。
| プロジェクト | 役割 | index.html の有無 | wwwroot の意味 | ここに置くと整理しやすいもの |
|---|---|---|---|---|
| (例)App.Maui | ネイティブホスト(Android/iOS/Windows/Mac) | あり(HostPage) | BlazorWebView が読むローカル web ルート | MAUI 固有の HostPage 調整、MAUI 固有の静的ファイル |
| (例)App.Web | Web ホスト(Blazor Web App) | 構成による(App.razor 等で head を組む場合も) | Web で公開する静的ファイル(必要なら) | Web 固有の静的ファイル、サーバー側ファイル置き場、API |
| (例)App.Shared(RCL) | 共有 UI(Razor)と共有アセット | なし | 共有静的アセット(Static Web Assets) | 共通 JS/CSS/画像、共通 Razor コンポーネント、共通サービス |
この表の通り、Shared(RCL)に index.html が無いのは正常です。代わりに、Shared の wwwroot に置いた静的アセットは、ホスト側から特別なパスで参照します(後述)。
結論:JavaScript は「共有したいなら RCL(.Shared)の wwwroot」に置く
JavaScript の置き場所で一番ハマりやすいのが、「.Web に wwwroot が無いから置けない」「.Shared に wwwroot はあるけど index.html が無いから読み込めない」という状態です。ここは発想を変えるのがポイントです。
Shared(RCL)に JS を置く=共有アセットとして配布する、という考え方が最もブレません。RCL の wwwroot に置いた静的アセットは、アプリ側から _content/{PACKAGE ID}/{PATH} 形式で参照します。
さらに Blazor Hybrid でも、RCL の静的アセットは _content/... パスで扱える前提が整理されています。HostPage(通常 wwwroot/index.html)を web ルート直下に置くのが推奨で、こうするとアプリ自身の wwwroot と RCL の静的アセットを一番素直に組み合わせられます。
推奨のファイル配置例(JS を共有する)
Solution
├─ App.Maui
│ └─ wwwroot
│ └─ index.html (MAUI の HostPage)
├─ App.Web
│ └─ (Web ホスト。必要なら wwwroot を作っても OK)
└─ App.Shared (Razor class library / RCL)
└─ wwwroot
└─ js
├─ app.js
└─ speech.js
MAUI 側(App.Maui)の index.html から参照する
MAUI の HostPage(App.Maui/wwwroot/index.html)で、RCL の JS を読み込みます。CSS を RCL 参照に切り替える手順は Microsoft Learn の移行手順でも紹介されており、JS でも同じ発想で整理できます。
<!-- App.Maui/wwwroot/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 共有 CSS 例(RCL) -->
<link rel="stylesheet" href="_content/App.Shared/css/app.css" />
<!-- 共有 JS 例(RCL) -->
<script src="_content/App.Shared/js/app.js"></script>
</head>
<body>
<div id="app">Loading...</div>
<script src="_framework/blazor.webview.js"></script>
</body>
</html>
Web 側(App.Web)で参照する位置
Blazor Web App(.NET 8/9 系の “Razor Components” ベース)では、従来の _Host.cshtml や index.html ではなく、Components/App.razor などで head のリンクを組む構成が一般的です。Microsoft Learn の手順でも、Web 側の App.razor でスタイル参照を RCL の _content/... に差し替える例が載っています。
<!-- App.Web/Components/App.razor(例) -->
<head>
<link rel="stylesheet" href="_content/App.Shared/css/app.css" />
<script src="_content/App.Shared/js/app.js"></script>
</head>
この形にしておくと、.Web 側に wwwroot が無い/空に見える構成でも「JS は RCL の静的アセットとして共有」という一本化ができ、迷いが減ります。
“どの JS を RCL に置くべきか”の判断基準
| JS の種類 | おすすめの置き場 | 理由 |
|---|---|---|
| UI と一緒に使う共通 JS(JS interop、共通ユーティリティ) | App.Shared(RCL)の wwwroot | Web と MAUI で同じパス(_content)で参照でき、重複が消える |
| ホスト固有の初期化(MAUI のみ、Web のみ) | 各ホスト(App.Maui / App.Web) | ホスト依存の挙動は共有すると逆に管理が難しくなる |
| ページ単位の小さな JS(JS isolation 的に扱いたい) | 基本は RCL、必要ならコンポーネント近くで管理 | 部品化しやすく、将来のターゲット追加でも破綻しにくい |
大きな参照ファイル(LLM・音声・辞書など)は「共有」と「配布」の問題を分けて考える
JS と違って、LLM モデルや長尺音声などの大きなファイルは “置き場所” だけでなく “配布の仕方” がトラブルの原因になります。特に Web でクライアント配布してしまうと、次のような問題が起きやすいです。
- 配布サイズが膨らみ、初回ロードが重くなる
- キャッシュや更新戦略が難しくなる(ユーザーが古いモデルを握り続ける等)
- ライセンス上、配布してよいのか判断が必要
- 流出リスク(クライアントに配れば基本的に回収できない)
そのため、実務では次のどれかに分岐させるのが堅実です。
パターンA:ネイティブに同梱、Web はサーバー側に置いて API 経由で使う
多くのケースで一番安全です。ネイティブ(MAUI)には同梱してオフライン対応し、Web はサーバー側ストレージ(サーバーのディスク、Blob、S3 など)に置き、認証付き API で推論や音声生成だけ提供します。
Blazor Hybrid 側の静的ファイルは「アプリ資産」として扱われ、.NET MAUI では raw assets を MauiAsset として扱い、ファイルシステム系のヘルパーでアクセスする考え方が整理されています。
パターンB:どうしても Web クライアントに配布する(公開ディレクトリは慎重に)
Web でクライアントにモデルや音声を配布する場合、単純に wwwroot に置くと “誰でも直接ダウンロードできる公開資産” になります。社内向けツールならまだしも、一般公開のサービスでここを許容するのは慎重に判断した方がよいです。
パターンC:ソースは1か所、ビルド時にターゲット別の正しい場所へコピーする
今回の「できれば同じ場所に置いたまま使い回したい」という要件に一番フィットするのがこれです。ポイントは次の2つです。
- ソース(元ファイル)を“共通プロジェクト”または共通フォルダに集約する
- ビルド時(または publish 時)に、各ターゲットが期待する場所へ自動配置する
こうしておくと、ターゲットが増えても「コピー先のルール」を調整するだけで済み、運用が安定します。
実践:共通フォルダを1か所に集約し、MSBuild で自動配置する
ここからは “確実に動く” ことを重視して、共有フォルダ+MSBuild で解決する具体例を紹介します。Visual Studio の Pre-build イベントでも可能ですが、チーム開発や CI を考えると MSBuild の Target と Copy タスクで完結させたほうが再現性が高いです。
おすすめの共有フォルダ設計
Solution
├─ SharedAssets
│ ├─ wwwroot
│ │ ├─ js
│ │ └─ media
│ └─ References
│ └─ raw
│ ├─ models
│ └─ audio
├─ App.Maui
├─ App.Web
└─ App.Shared
この設計のメリットは、「JS などの Web 系アセット」と「大容量バイナリ」を明確に分離できる点です。Web 系は RCL の wwwroot に寄せ、巨大ファイルは “共有フォルダに置いて必要なターゲットへだけ配る” にしておくと事故が減ります。
方法1:Link+CopyToOutputDirectory で「プロジェクト外のファイル」を取り込む
まずはカスタム Target を書かずに済む、手堅い方法です。ソースは共通フォルダに置いたまま、ビルド成果物にコピーします。
Web(サーバー側)に “公開せずに” 置きたい場合は、wwwroot ではなく Web プロジェクト配下の専用ディレクトリ(例:App_Data や Data)にコピーするのが安全です。
<!-- App.Web/App.Web.csproj(例) -->
<ItemGroup>
<Content Include="..\SharedAssets\References\raw\**\*.*"
Link="App_Data\References\raw\%(RecursiveDir)%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
この形なら、サーバーのディスク上にファイルは載りますが、静的ファイルとして公開しない限り URL で直接取られることはありません(公開するかどうかは Web 側のミドルウェア設定次第です)。
MAUI に同梱したい場合は、MauiAsset として取り込むのが分かりやすいです。Android などでサイズが厳しい場合、.NET for Android では asset packs を使って大容量アセットを分離する仕組みもあり、MauiAsset に AssetPack メタデータを付与できることが説明されています。
<!-- App.Maui/App.Maui.csproj(例) -->
<ItemGroup>
<MauiAsset Include="..\SharedAssets\References\raw\**\*.*"
LogicalName="References\raw\%(RecursiveDir)%(Filename)%(Extension)" />
<!-- Android でさらに大きい場合の例(必要なときだけ) -->
<MauiAsset Update="..\SharedAssets\References\raw\models\**\*.*"
AssetPack="mlmodels" />
</ItemGroup>
MAUI 側のコードは、基本的に “パッケージに同梱したファイルをストリームとして開く” 発想になります。Blazor Hybrid の静的ファイルはアプリ資産として扱う整理になっているため、MauiAsset+ファイルシステムヘルパーで読む、という方向性がぶれにくいです。
方法2:MSBuild Target(Copy タスク)で「各ターゲットが期待する場所」へ同期する
次に、あなたの要件により直結する「ビルド時に正しい場所へコピーする」方法です。これをやると、ソースは1か所(SharedAssets)に置いたまま、
- MAUI には
App.Maui/wwwrootやResources/Rawへ - Web には
App.Web/wwwrootまたはApp_Dataへ
のように、ターゲットごとの “正しい置き場” を自動で作れます。
例として、Solution 直下に Directory.Build.targets を置き、各プロジェクトで共通 Target を使えるようにします(これができると、後からターゲットが増えてもルール追加が簡単です)。
<!-- Solution/Directory.Build.targets(例) -->
<Project>
<PropertyGroup>
<SharedAssetsRoot>$(MSBuildThisFileDirectory)SharedAssets\</SharedAssetsRoot>
</PropertyGroup>
<Target Name="SyncSharedWwwrootToMaui"
BeforeTargets="Build"
Condition="'$(MSBuildProjectName)' == 'App.Maui'">
<ItemGroup>
<_SharedJs Include="$(SharedAssetsRoot)wwwroot\js\**\*.js" />
</ItemGroup>
<Copy SourceFiles="@(_SharedJs)"
DestinationFiles="@(_SharedJs->'$(MSBuildProjectDirectory)\wwwroot\js\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
<Target Name="SyncSharedReferencesToWeb"
BeforeTargets="Build"
Condition="'$(MSBuildProjectName)' == 'App.Web'">
<ItemGroup>
<_SharedRefs Include="$(SharedAssetsRoot)References\raw\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(_SharedRefs)"
DestinationFiles="@(_SharedRefs->'$(MSBuildProjectDirectory)\App_Data\References\raw\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
</Project>
この形の強みは、ソースを1か所に固定したまま、コピー先だけをターゲットごとに変えられる点です。構成変更やターゲット追加(例:WebAssembly クライアント、別のデスクトップホスト)があっても、ルールを増やすだけで追従できます。
コピー戦略で失敗しがちなポイント
- コピー先を “公開フォルダ(wwwroot)” にしない:巨大ファイルは原則として Web 公開しない(必要なら API で制御)。
- SkipUnchangedFiles を使う:毎回コピーするとビルドが遅くなり、開発体験が悪化します。
- Publish 時も意識する:ビルドだけでなく publish 出力に含めたい場合は
BeforeTargets="Publish"を追加するか、CopyToPublishDirectoryを併用します。 - Git 管理の方針を決める:LLM などは Git に入れず、Git LFS や別ストレージに置く運用のほうが現実的なことも多いです。
ターゲット別:ファイル種類ごとの “正しい置き場” 早見表
| ファイル種類 | 共通保管(ソース) | MAUI 側の配置先 | Web 側の配置先 | 備考 |
|---|---|---|---|---|
| 共有 JS/CSS/画像 | App.Shared/wwwroot | そのまま _content 参照(HostPage から読み込む) | そのまま _content 参照(App.razor 等から読み込む) | RCL の静的アセットは _content/{PackageId}/… で参照 |
| MAUI だけの JS(ネイティブ固有) | (必要なら)SharedAssets/wwwroot | App.Maui/wwwroot | 不要 | 共有したいなら Copy タスクで同期 |
| LLM モデル/大容量音声 | SharedAssets/References/raw | MauiAsset として同梱、必要なら Android asset pack | App_Data 等の非公開フォルダ(または外部ストレージ) | Android の大容量は asset packs も検討 |
| Web 公開してよいメディア | SharedAssets/wwwroot/media | 必要なら App.Maui/wwwroot にコピー | App.Web/wwwroot | 公開範囲を明確化(キャッシュ/更新戦略も) |
“index.html が無いから参照できない”を卒業する考え方
RCL(.Shared)に index.html が無いのは、設計として正しいです。RCL は「共有する部品」なので、HTML の起点は常に “ホスト(MAUI/Web)側” にあります。
Microsoft Learn の手順でも、MAUI の wwwroot/index.html から RCL の CSS を _content/... 参照に切り替える具体例が示されています。つまり、“参照を書く場所” はホストにあり、“参照される資産” は RCL に置く、という役割分担が最初から想定されています。
ここが腑に落ちると、JavaScript も同じで、
- 置き場:Shared(RCL)の
wwwroot - 読み込み:MAUI の
index.html/Web のApp.razor等
という “一本道” が作れます。
最終チェックリスト(移行・拡張でハマらないために)
- 共有したい JS/CSS/画像は RCL の
wwwrootに集約したか - ホスト(MAUI/Web)の参照が
_content/{PackageId}/...になっているか - HostPage(MAUI の index.html)は
wwwroot直下に置いているか(サブフォルダにすると参照が破綻しやすい) - 巨大ファイルを Web の
wwwrootに置いて “公開” していないか - 巨大ファイルは「サーバー側に置く」か「ビルドでターゲット別に配る」かを決めたか
- MSBuild の Copy/Link で “ソース1か所” を維持できているか
この運用にしておけば、構成が変わっても「ソースは1か所、配布ルールだけ直す」で済むため、.NET 9 の MAUI Blazor Hybrid & Web を長期運用しやすくなります。

コメント