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.
Where files come from
Section titled “Where files come from”IStaticFileSource rather than a directory path, because the interesting case on a phone is not a
directory.
From disk
Section titled “From disk”app.UseStaticFiles("./wwwroot", o => o.CacheFor(TimeSpan.FromHours(1)));From the assembly
Section titled “From the assembly”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.
From a zip
Section titled “From a zip”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.cssThe 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.
Both, in order
Section titled “Both, in order”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")));Options
Section titled “Options”| 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 |
Single-page apps
Section titled “Single-page apps”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.
Caching
Section titled “Caching”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";};Precompressed sidecars
Section titled “Precompressed sidecars”o.ServePrecompressedFiles = true; // serves app.wasm.br for app.wasmOff 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.
Security
Section titled “Security”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%2fis 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.
.envand.gitlive 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.
Blazor WebAssembly
Section titled “Blazor WebAssembly”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.


