Static Assets and UI Plumbing: MapStaticAssets, Fingerprinting, CSS Isolation and Client Libraries
MapStaticAssets discovers files at build time, compresses them with gzip/Brotli and gives fingerprinted URLs a one-year immutable cache. We measure it with real headers and show why MapControllerRoute(...).WithStaticAssets() in the .NET 10 MVC template does nothing and how to fix it, the tag helper trap in CSS isolation, and using LibMan.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402, ASP.NET Core runtime 10.0.13), LibMan (Library Manager) CLI (Command-Line Interface) 3.0.114 and htmx 2.0.11.
Guess First
The _Layout.cshtml of a .NET 10 project created with dotnet new mvc contains this line:
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
Program.cs has app.MapStaticAssets() and MapControllerRoute(...).WithStaticAssets() exactly as the template wrote them. You publish the app with dotnet publish, run it in Production and look at the HTML (HyperText Markup Language) of the /Home/Privacy page. What is the href: /css/site.css, /css/site.css?v=..., or a fingerprinted address like /css/site.b9sayid5wm.css? Does the answer change on a Razor Page that uses the same layout? That second question carries the most interesting finding of this post.
From UseStaticFiles to MapStaticAssets
Up to .NET 8, static files were served by a single middleware: app.UseStaticFiles(). On every request it looks into wwwroot and, if the file exists, returns it with ETag and Last-Modified headers. It doesn't compress, it doesn't send Cache-Control unless you configure a cache lifetime, and it doesn't touch file names. For cache busting, the asp-append-version tag helper attribute in the layout appends the file's hash to the URL (Uniform Resource Locator) as ?v=.
MapStaticAssets, introduced in .NET 9, starts from a different place: files are discovered at build time, not at runtime. Bookstore's pipeline looks like this:
app.UseRouting();
// for files that aren't in the build manifest (e.g. uploads)
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllerRoute(
name: "areas",
pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// MapControllerRoute(...).WithStaticAssets() does nothing (dotnet/aspnetcore#59152)
app.MapControllers()
.WithStaticAssets();
app.MapRazorPages()
.WithStaticAssets();
Two lines of this code differ from the template; I'll explain both below with real output. First, let's see what MapStaticAssets produces at build time. After dotnet publish -c Release, the output folder contains a manifest file named Bookstore.Web.staticwebassets.endpoints.json, and every file in wwwroot has compressed copies next to it:
-rw-r--r-- 232803 bootstrap.min.css
-rw-r--r-- 22709 bootstrap.min.css.br
-rw-r--r-- 30970 bootstrap.min.css.gz
In our project the manifest has 642 endpoints. Here is the entry for the fingerprinted, Brotli-compressed version of site.css:
{
"Route": "css/site.b9sayid5wm.css",
"AssetFile": "css/site.css.br",
"Selectors": [
{
"Name": "Content-Encoding",
"Value": "br",
"Quality": "0.004310344828"
}
],
"ResponseHeaders": [
{ "Name": "Cache-Control", "Value": "max-age=31536000, immutable" },
{ "Name": "Content-Encoding", "Value": "br" },
{ "Name": "Content-Length", "Value": "231" },
{ "Name": "Content-Type", "Value": "text/css" },
{ "Name": "ETag", "Value": "\"FVzJe6Q2UlG8wiE0wKPzsNcJ5m9zWTbKIN9rO8SkzT8=\"" },
{ "Name": "Last-Modified", "Value": "Sun, 08 Nov 2026 02:02:28 GMT" },
{ "Name": "Vary", "Value": "Accept-Encoding" }
],
"EndpointProperties": [
{ "Name": "fingerprint", "Value": "b9sayid5wm" },
{ "Name": "integrity", "Value": "sha256-j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg=" },
{ "Name": "label", "Value": "css/site.css" }
]
}
(For readability I collapsed the header entries onto single lines and removed one property.) The response headers were computed at build time and written to the file. At runtime MapStaticAssets reads this manifest and registers an endpoint per entry; when a request arrives it doesn't look at the disk or compute a hash, it sends the file with the ready-made headers. According to Microsoft's documentation, only gzip is produced during development, and both gzip and Brotli during publish; images and fonts that are already compressed are left out.
Headers: Fingerprinted and Unfingerprinted Addresses
In Production I requested the same file from two different addresses:
$ curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip, deflate, br' http://localhost:17811/css/site.b9sayid5wm.css
HTTP/1.1 200 OK
Content-Length: 231
Content-Type: text/css
Cache-Control: max-age=31536000, immutable
Content-Encoding: br
ETag: "FVzJe6Q2UlG8wiE0wKPzsNcJ5m9zWTbKIN9rO8SkzT8="
Vary: Accept-Encoding
$ curl -s -D - -o /dev/null http://localhost:17811/css/site.css
HTTP/1.1 200 OK
Content-Length: 667
Content-Type: text/css
Cache-Control: no-cache
ETag: "j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg="
Vary: Accept-Encoding
The difference is stark. The fingerprinted address comes with one year (max-age=31536000) and immutable: the browser will never ask for this file again. It can promise that safely, because if the file's content changes the fingerprint changes, and so does the URL. The unfingerprinted address comes with no-cache. no-cache doesn't mean "don't cache"; it means "ask the server every time you use it". The browser sends the ETag of its copy in If-None-Match:
$ curl -s -D - -o /dev/null -H 'If-None-Match: "j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg="' http://localhost:17811/css/site.css
HTTP/1.1 304 Not Modified
Content-Type: text/css
A 304 has no body, but it's still a request and a round trip. With 15 assets on a page, that's 15 304s on every page load. With fingerprinted addresses these requests never happen.
I measured the effect of compression on Bootstrap's CSS (Cascading Style Sheets) file:
identity 232803
gzip 30970
br 22709
With Brotli the file shrinks to roughly a tenth, and the runtime cost is zero because compression happened once at build time. In the old setup you got the same gain by burning CPU (Central Processing Unit) on every request with AddResponseCompression, or by leaving the job to a reverse proxy.
The Development environment tells a different story. Even the fingerprinted address comes with Cache-Control: no-cache; the framework deliberately overrides cache headers so that stale files don't show up during Hot Reload. You can turn this off with EnableStaticAssetsDevelopmentCaching: true in appsettings.Development.json. If you don't know this while measuring locally, you'll waste time asking "where is immutable?".
Tag Helpers, Import Maps and a Silent Bug in the Template
Who writes the fingerprinted URL into the view? WithStaticAssets() adds a ResourceAssetCollection to the metadata of the relevant endpoints. When the link and script tag helpers render an address starting with ~/, they look it up in this collection and write the fingerprinted counterpart. The empty <script type="importmap"></script> in the layout's <head> produces an import map from the same collection; when a JavaScript module writes import "./js/site.js", the browser loads the fingerprinted file.
Now the answer to the opening question. With the template's own Program.cs, in Production, the real HTML of two pages using the same layout:
<!-- Razor Page: /Account/Login -->
<script type="importmap">{
<link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.46ein0sx1k.css" />
<link rel="stylesheet" href="/css/site.b9sayid5wm.css" />
<link rel="stylesheet" href="/Bookstore.Web.vhlbjall1w.styles.css" />
<!-- MVC view: /Home/Privacy -->
<link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/site.css?v=j6fhJSuuyLpOSLuPJU0TsDV0iNjor5S3rDnvxJrt4bg" />
<link rel="stylesheet" href="/Bookstore.Web.styles.css?v=bdNtA_QoaAbCJducQYa0ZwEA7ziL7VseAqxTBL0_NzA" />
The Razor Page gets fingerprinted addresses and the import map. The MVC (Model-View-Controller) view doesn't render the import map at all and falls back to the old ?v= behavior. The Bootstrap line has no asp-append-version, so it carries no version information at all. I tried this in a completely empty dotnet new mvc project too, with the same result. Listing the endpoint metadata reveals the reason: the controller endpoints have no ResourceAssetCollection.
The cause is in ASP.NET Core's source code. WithStaticAssets() looks for the __EndpointRouteBuilder key in the Items dictionary of the builder it's given and, if it doesn't find it, silently does nothing. MapControllers() returns a builder that contains this key, but MapControllerRoute() and MapDefaultControllerRoute() return a new route-specific builder without it. On GitHub this is recorded as dotnet/aspnetcore #59003, and as #59152 for the template. At the time of writing #59152 is open and in the ".NET 12 Planning" milestone; the PR (Pull Request) opened to fix it, #65188, hasn't been merged yet.
The fix is the line in the pipeline above: app.MapControllers().WithStaticAssets(). A convention added to the default builder returned by MapControllers() applies to all controller actions, including those reached through conventional routes. With this line /Home/Privacy produces the same output:
<link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.46ein0sx1k.css" />
<link rel="stylesheet" href="/css/site.b9sayid5wm.css" />
<link rel="stylesheet" href="/Bookstore.Web.vhlbjall1w.styles.css" />
<script src="/lib/jquery/dist/jquery.min.o1o13a6vjx.js"></script>
<script src="/lib/bootstrap/dist/js/bootstrap.bundle.min.493y06b0oq.js"></script>
<script src="/js/site.xtxxf3hu2r.js"></script>
Note that the bootstrap.min.css line has no asp-append-version, yet the address is fingerprinted. With the collection in place, the tag helper translates every address written with ~/ that has a match in the manifest. API (Application Programming Interface) controllers ([ApiController]) don't get this metadata; the source code skips them on purpose.
CSS Isolation: Page-Specific Styles
Instead of piling page-specific CSS into site.css, you can put a .css file with the same name next to the page. For the Razor Pages admin list, Pages/Manage/Books/Index.cshtml.css:
h1 {
font-size: 1.5rem;
}
td form {
display: inline;
}
At build time the Razor SDK (Software Development Kit) adds a page-specific attribute to each element and narrows the selectors with that attribute. All scoped files are collected into a single bundle (Bookstore.Web.styles.css, which the layout already references):
/* /Pages/Manage/Books/Index.cshtml.rz.scp.css */
h1[b-ifdkusweri] {
font-size: 1.5rem;
}
td form[b-ifdkusweri] {
display: inline;
}
Looking at the rendered HTML, h1 and td got the attribute, but the delete button's form didn't:
<h1 b-ifdkusweri>Manage books</h1>
<td b-ifdkusweri>The Pragmatic Path</td>
<form method="post" action="/Manage/Books?id=1&handler=Delete">
The reason: elements carrying attributes like asp-page-handler, asp-for or asp-page are produced by tag helpers, and the scope attribute isn't added to them. The td form[b-ifdkusweri] selector matches nothing, and the style silently doesn't apply. The fix is ::deep:
td ::deep form {
display: inline;
}
This compiles to td[b-ifdkusweri] form: every form inside a td that has the attribute. You need the same approach for HTML produced by partial views and view components; those elements come from another .cshtml file, so they don't get the page's scope attribute.
Client Libraries: Template, LibMan and npm
dotnet new mvc brings Bootstrap, jQuery, jQuery Validation and jQuery Validation Unobtrusive as files copied under wwwroot/lib. No package manager knows which version they are; the comment lines at the top of the files are the only record. The .NET 10 template ships Bootstrap 5.3.3, jQuery 3.7.1 and jQuery Validation 1.21.0.
When adding a new library there are two reasonable paths. The first is LibMan: Microsoft's small tool that downloads specific files from a CDN (Content Delivery Network) into wwwroot. You don't need a global tool; you add it to the project as a local tool:
dotnet new tool-manifest
dotnet tool install Microsoft.Web.LibraryManager.Cli
dotnet libman init --default-provider jsdelivr --default-destination wwwroot/lib
dotnet libman install htmx.org@2.0.11 --files dist/htmx.min.js --destination wwwroot/lib/htmx
The resulting libman.json goes into the repository; the downloaded files can go into .gitignore if you like:
{
"version": "3.0",
"defaultProvider": "jsdelivr",
"defaultDestination": "wwwroot/lib",
"libraries": [
{
"library": "htmx.org@2.0.11",
"destination": "wwwroot/lib/htmx",
"files": [
"dist/htmx.min.js"
]
}
]
}
On another machine or in CI (Continuous Integration), dotnet tool restore and dotnet libman restore are enough:
Restoring library htmx.org@2.0.11...
wwwroot/lib/htmx/dist/htmx.min.js written to disk
1 libraries restored in 0.05 seconds
The second path is npm. If the project already has a frontend build step (TypeScript compilation, Sass, a bundler), it's more consistent to add the library to package.json and have the build write its output under wwwroot. The important rule here: MapStaticAssets only puts files that are under wwwroot at build time into the manifest. If your bundler writes its output outside wwwroot, you need to link it into wwwroot with a <Content> item and Link, as Microsoft's documentation shows.
As for bundling: the old ASP.NET BundleConfig has no counterpart in ASP.NET Core. With HTTP (Hypertext Transfer Protocol) 2's parallel requests over a single connection and fingerprinted, compressed, immutable assets, a handful of separate files in a small admin panel is not a problem. Microsoft's documentation recommends a bundler, or combining MapStaticAssets with UseStaticFiles, at around 1,000 assets or more, because the manifest's metadata is loaded into memory when the app starts. Choosing a bundler is a topic for the upcoming Build Tools series.
Production Notes
Behind a CDN or reverse proxy, fingerprinted assets are the easiest content there is: because they're immutable, the CDN fetches them once and keeps them for a year, and no purge is needed after a deploy because a new version means a new URL. The HTML page itself should be short-lived; old HTML points to old fingerprints. The rest of the caching strategy is in the upcoming Caching series.
Files written to wwwroot at runtime aren't in the manifest. In the published app I created wwwroot/uploads/cover-42.txt by hand and got a 404 when I requested it. After adding UseStaticFiles() after UseRouting() in the pipeline, the same file returned 200, while site.css still comes from MapStaticAssets, with Brotli. The static file middleware leaves a request alone when routing has already matched an endpoint, so the two mechanisms run side by side without clashing. Still, it's better to keep user uploads outside wwwroot and serve them from an endpoint with authorization checks; the details are in the file upload post of the Web API series.
If you want to cut middleware cost, app.MapStaticAssets().ShortCircuit() returns the response as soon as an asset endpoint matches; authentication, authorization and later middleware don't run. As long as all static assets are anonymous, that's a safe win.
Common Pitfalls
Giving an unfingerprinted file a long max-age. If you write public, max-age=31536000 for /css/site.css in UseStaticFiles' OnPrepareResponse, users will see the old CSS for a year after a deploy; the browser never asks the server. Long lifetimes are only for files whose URL changes with their content.
Trusting the template's MapControllerRoute(...).WithStaticAssets() line. As you saw above, it does nothing for MVC views, and it doesn't fail either. Add MapControllers().WithStaticAssets() and check the fingerprinted addresses in the Production output with your own eyes.
Assuming scoped CSS applies to tag helper elements. Forms, inputs and links with asp-* attributes don't get the scope attribute; use ::deep.
Hard-coding the fingerprinted file name. If you put site.b9sayid5wm.css as a constant in an email template or in JavaScript, you'll get a 404 after the next CSS change. Always write the address as ~/css/site.css and leave the translation to the tag helper.
Measuring in Development. In Development headers are overridden to no-cache; measure caching behavior on published output.
A Small Experiment
★☆☆ Run dotnet publish -c Release on your own project and count how many endpoints are in the *.staticwebassets.endpoints.json file. How many separate entries are there for site.css, and why?
★★☆ Remove the MapControllers().WithStaticAssets() line from Program.cs, run the app in Production and compare the <head> of an MVC view with that of a Razor Page. Then create a JavaScript module named wwwroot/js/module.js and load it from the view with import "./js/module.js". Without the import map, which URL does the browser's Network tab request?
★★★ Simulate a CDN scenario locally: put YARP (Yet Another Reverse Proxy) or nginx on another port in front of the app and verify that the Cache-Control headers survive the proxy. For site.css, which gets no-cache, check whether the proxy forwards the If-None-Match request to the server.
In this post we set the path and the lifetime of the files going to the browser. Next up is the text on the screen: how is the same catalog served in Turkish and English, where does the culture come from, and why can the letter "i" break a login under Turkish culture? In the next post we move on to localization in MVC and Razor Pages.