Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Static Files

app.UseStaticFiles("./wwwroot");

The middleware resolves a path and hands over to the same file-serving code downloads use, so byte ranges, ETag, If-None-Match and 304 come free. When no file matches it calls next and the request carries on to routing — which is what lets an app serve a SPA and an API from one pipeline.

IStaticFileSource rather than a directory path, because the interesting case on a phone is not a directory.

app.UseStaticFiles("./wwwroot", o => o.CacheFor(TimeSpan.FromHours(1)));

A MAUI or single-file build has no content directory to point at, so the web assets travel inside the assembly:

<ItemGroup>
<EmbeddedResource Include="wwwroot\**" />
</ItemGroup>
app.UseEmbeddedFiles(typeof(App).Assembly, "MyApp.wwwroot", o => o.FallbackFile = "index.html");

Resource names are flattened at build time (wwwroot/css/site.css becomes MyApp.wwwroot.css.site.css), so the map is built once by reversing that. Because only the separators are ambiguous — site.min.css is indistinguishable from a site directory containing min.css — both readings are registered and either request resolves.

EmbeddedFileSource.Paths lists what it can serve, which is the first thing to check when a file 404s.

An archive, on disk or embedded in the assembly:

app.UseStaticFiles(new ZipFileSource("./content/site.zip"));
app.UseStaticFiles(new ZipFileSource(typeof(App).Assembly, "MyApp.wwwroot.zip"));

This is the better answer than loose embedded resources once the content is a publish output rather than a handful of files. A published Blazor app is a few thousand files; as embedded resources that is a few thousand manifest entries whose paths have been flattened into dotted names that have to be guessed back apart, and the assets are inflated into the binary. Zipped it is one entry, the paths survive intact, and the content stays compressed.

<ItemGroup>
<EmbeddedResource Include="wwwroot.zip" LogicalName="MyApp.wwwroot.zip" />
</ItemGroup>

For an archive that was zipped along with its parent folder, name the directory to serve from:

new ZipFileSource("./site.zip", "wwwroot"); // wwwroot/css/site.css is served as /css/site.css

The archive is never held open. The entry index — path, length, CRC and timestamp — is read once at construction, and each response opens its own reader over its own stream, so one source serves any number of requests at once. Reopening an embedded archive costs nothing: the resource stream is a window onto the already-mapped assembly image rather than a copy of it.

The ETag comes from the entry’s CRC, so it follows the content and survives a rebuild that produced identical bytes. ZipFileSource.Paths lists what an archive can serve.

The useful arrangement during development: a physical directory in front so an edited file is picked up without a rebuild, the packaged copy behind it so the shipped app still works.

app.UseStaticFiles(new CompositeFileSource(
new PhysicalFileSource("./wwwroot"),
new EmbeddedFileSource(typeof(App).Assembly, "MyApp.wwwroot")
));
Property Default Notes
RequestPath "" URL prefix — /assets serves /assets/app.js from app.js
DefaultDocuments index.html, index.htm Tried when a directory is requested
FallbackFile null The SPA index; see below
ServeUnknownFileTypes false See below
DefaultContentType application/octet-stream Used only when the above is on
CacheControl null Or use CacheFor(maxAge, immutable)
ServePrecompressedFiles false Serve a .br/.gz sidecar in place of the original
ContentTypeOverrides Extension → content type, on top of the built-in map
OnPrepareResponse null A last look before the response is written
app.UseStaticFiles("./wwwroot", o => o.FallbackFile = "index.html");

/orders/42 reaches the client-side router instead of a 404. The fallback is only applied to GET and HEAD requests that look like navigations (they accept HTML) and do not look like assets (no file extension) — so a missing script still 404s honestly rather than returning HTML the browser will fail to parse.

o.CacheFor(TimeSpan.FromDays(365), immutable: true);

Leaving CacheControl null sends none, which browsers treat as “revalidate” — and the ETag makes that cheap. Only mark content immutable when its URL changes with its content (a hashed bundle name); otherwise a browser keeps a stale copy for the whole age and no deploy will dislodge it.

OnPrepareResponse is the hook for a per-file policy:

o.OnPrepareResponse = ctx =>
{
if (ctx.File.Name.EndsWith(".html"))
ctx.HttpContext.Response.Headers["Cache-Control"] = "no-cache";
};
o.ServePrecompressedFiles = true; // serves app.wasm.br for app.wasm

Off by default, because a directory containing an unrelated .gz would otherwise start serving it as an encoding of a file it is not. On for a build that publishes precompressed assets it is strictly better: those were compressed once at maximum effort, and recompressing them per request spends CPU to produce a larger result. The content type still describes the file underneath.

The source has to offer them as well as the middleware asking. PhysicalFileSource and ZipFileSource both take PrecompressedEncodings (UseBlazorWebAssembly sets it for you when it builds the source), and CompositeFileSource passes the question through to whichever source answers. A source that has no notion of sidecars — EmbeddedFileSource, or your own — is simply asked for the file that was requested.

This is the part that has to be right, so it is worth stating what the middleware actually does.

  • Paths arrive already percent-decoded, so %2e%2e%2f is a plain ../ by the time anything sees it. That is exactly why the segment check happens on the decoded path: .., null bytes, \, : and invalid filename characters are all refused.
  • Containment is checked after normalization and after resolving links. A symlink is the one way a path that looks contained can leave.
  • Path comparison follows the platform. Treating a case-insensitive file system as case-sensitive is how a containment check passes while the open succeeds on a different file.
  • Dotfiles are refused by default. .env and .git live in content directories.
  • Unknown extensions are refused by default. Guessing a type for an unknown extension is how a user-writable directory turns into a way to serve HTML — and therefore script — from your origin.

A published Blazor app needs the SPA fallback, precompressed sidecars and a cache policy that treats fingerprinted _framework assets as immutable. UseBlazorWebAssembly arranges all three — see Blazor WebAssembly.