Zero-Rebuild Static File Hosting in Blazor: Shipping Downloadable Resources Without Touching Your Build Pipeline
The problem
You've got a production Blazor app. Someone needs a downloadable resource shipped from it — a PDF, a report, a one-off landing page — and they need it today. The instinct for most developers is to reach for a new Razor page, a controller action, or a database-backed file service. All three mean touching the build, running a CI/CD pipeline, and hoping nothing else breaks in the process.
Most of the time, you don't need any of that. ASP.NET Core — and by extension Blazor Server, Blazor WASM, and the newer Blazor Web App model — ships with a static file pipeline that's sitting there unused in almost every project. Understanding it properly means you can add, replace, or remove downloadable content on a live site in seconds, with zero build and zero redeploy.
The core idea: wwwroot is not part of your compiled app
Every Blazor template includes a wwwroot folder. It's easy to think of it as "where the CSS goes," but its actual role is broader: it's the static file root, served directly by the Kestrel/IIS static file middleware (app.UseStaticFiles()), completely independent of Razor component compilation.
That distinction matters:
Lives in wwwroot Lives in .razor / .cs
Served by Static file middleware Blazor router / compiled DLL Requires rebuild to change? No Yes Can be edited live on the server Yes (FTP/SCP/panel upload) No — must be published Examples PDFs, images, standalone HTML, CSS, JS Pages, components, services
If your content is static — a file that doesn't need C# logic, model binding, or server-side state — it belongs in wwwroot, full stop.
Setting it up
- Create a subfolder for the resource.
wwwroot/ downloads/ guide-english.pdf guide-hindi.pdf downloads.html
- Reference files with paths relative to wwwroot.
Inside downloads.html, or inside a Razor page, the link is simply:
html Download PDF
No route attribute, no controller, no IFileProvider injection required.
- If the file is a standalone static page — skip Razor entirely.
This is the part developers often miss. If your "download page" doesn't need @code, doesn't need DI, and doesn't need Blazor routing, don't wrap it in a .razor file at all. Drop it in as plain .html:
wwwroot/downloads/index.html → served at https://yoursite.com/downloads/
ASP.NET Core's static file middleware resolves index.html automatically for a directory request. That's a fully live, cacheable, CDN-friendly page with zero Blazor involvement — it never touches the SignalR circuit (Server) or the WASM runtime (WASM), so it loads instantly even before your app boots.
The real win: updating content with no redeploy
Once that structure exists in your published output on the server, you can:
Replace guide-english.pdf with a new version by uploading over it — live traffic picks up the new file on the next request, no restart needed. Add a new file (guide-hindi.pdf) the same way. Edit downloads.html directly on the server for a copy change, an updated link, or a new banner — again, no build.
This is the single biggest advantage over the "just make a Razor page" instinct: static assets are decoupled from your app's compiled lifecycle. You get CI/CD-free content updates for anything that doesn't require server logic.
Gotcha #1: duplicate wwwroot folders in publish output
If you inherited a project with a messy publish history, you may find two wwwroot folders sitting side by side in the deployment directory (wwwroot and wwwroot-1, for example) — usually the result of a publish profile targeting the same output folder twice, or a manual copy that was never cleaned up.
Only one of them is actually being served. Before dropping files in, verify which one is live:
bash
Drop a uniquely-named test file into each candidate folder
echo "test-a" > wwwroot/probe-a.txt echo "test-b" > wwwroot-1/probe-b.txt
Then check which one resolves
curl -I https://yoursite.com/probe-a.txt curl -I https://yoursite.com/probe-b.txt
Whichever returns 200 OK is your real static root. Delete the test files afterward, and treat the other folder as dead weight — or better, figure out why your publish process is producing it twice.
Gotcha #2: watch your PDF file sizes if you're generating them programmatically
If you're generating downloadable PDFs (reports, guides, invoices) via an HTML-to-PDF renderer like wkhtmltopdf, WeasyPrint, or Puppeteer, keep an eye on embedded fonts. A 26-page document with clean, well-structured HTML has no business being 90+ MB — and when it is, the usual suspect is font embedding, not content:
Full, non-subsetted font embedding. Some renderers embed the entire font program (every glyph in the font file) rather than subsetting it down to only the characters actually used on the page. A single non-subsetted Unicode-heavy font (especially ones covering complex scripts) can be several MB on its own — multiply that across a bold and a regular weight and you've got real bloat, even though it should only be one or two shared objects for the whole document, not one per page. CDN font imports failing silently. If your CSS pulls a webfont via @import url(fonts.googleapis.com/...) and that request fails (blocked network, offline render environment, firewall), some renderers silently fall back to a bundled system font instead of erroring out — and that fallback font may not be the lean one you expect. Diagnosing it: pdffonts yourfile.pdf (from poppler-utils) will show you exactly what's embedded, whether it's subsetted (sub column), and whether it's actually the font you intended. If you see emb: yes, sub: no on a large font, that's your smoking gun. Fixing it: prefer statically-instanced, subsettable fonts over full variable-font files; make sure your build/render environment can actually reach any CDN font URLs you reference (or self-host the font file locally so there's no network dependency at all); and if you're stuck with a renderer that won't subset, run the output through Ghostscript (gs -sDEVICE=pdfwrite -dPDFSETTINGS=/ebook) or qpdf --optimize-images as a post-processing compression pass before shipping the file to users.
Mobile users in particular will silently fail to download or open large PDFs on flaky connections — treat file size as a UX metric, not an afterthought.
Takeaways Static content doesn't need Razor. If it's a file, not a feature, it belongs in wwwroot. Rebuild once, then never again for content-only changes. Ship the initial page/link structure through your normal pipeline; every file swap after that is a plain upload. Verify your live static root before assuming it's wwwroot. Publish history has a way of leaving duplicates behind. Treat generated file size as part of the UX. A beautiful PDF that fails to download on mobile isn't shipping anything at all — measure it, diagnose it with the right tools, and compress before you publish.
Static file hosting is one of the most underused parts of the ASP.NET Core pipeline — mostly because it's too simple to notice. But for developers shipping content alongside an app rather than as a feature of it, it's the difference between a five-minute update and a full release cycle.