Include External JavaScript and CSS in ASP.NET Pages (Webforms + MVC + Razor)
Wiring external JavaScript and CSS into an ASP.NET page feels obvious, but doing it correctly — cache-busted, bundled, minified, ordered — takes more care than dropping a <script> tag into the markup. Three patterns covered: Webforms bundles, MVC HTML helpers, and modern Razor Pages tag helpers.
I wrote the first version of this snippet in 2011 for a Webforms codebase where every page had its own ad-hoc <script> and <link> tags — inconsistent cache-busting, no minification, resources loading in the wrong order. The pattern has evolved through MVC 4/5, MVC Core, and now Razor Pages + tag helpers in .NET 8/9, but the underlying problems are the same: versioned URLs, dependency ordering, and avoiding duplicate includes.
What this snippet covers
- Webforms (legacy) —
ScriptManagerandSystem.Web.Optimizationbundling for .NET Framework 4.x - MVC 5 / MVC Core —
Html.Style()/Html.Script()helpers with@section Scriptsfor per-page includes - Razor Pages + .NET 8/9 — tag helpers (
<link asp-append-version="true">) with automatic cache-busting via content hash - Bundling and minification — WebOptimizer for Core, gulp/webpack/Vite for hybrid SPAs
- Conditional includes — page-specific JS that only loads on the page that needs it
- 2026 recommendation — Vite for SPA frontend, Razor tag helpers with
asp-append-versionfor server-rendered pages
Use it
Pick the pattern that matches your framework. Webforms bundle registration goes in Global.asax. MVC helpers go in _Layout.cshtml and per-page @section Scripts. Razor Pages tag helpers drop directly into the <head> or end of <body>.
// ============================================================
// LEGACY: Webforms (ASP.NET 4.x) - Bundle registration
// ============================================================
// Global.asax
protected void Application_Start()
{
BundleTable.Bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
"~/Scripts/jquery-{version}.js"));
BundleTable.Bundles.Add(new StyleBundle("~/bundles/site").Include(
"~/Content/site.css"));
}
// Page.aspx
<%: Scripts.Render("~/bundles/jquery") %>
<%: Styles.Render("~/bundles/site") %>
// ============================================================
// MVC 5 / MVC Core — Layout + per-page sections
// ============================================================
// _Layout.cshtml
<head>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
@RenderSection("Styles", required: false)
</head>
<body>
@RenderBody()
<script src="~/js/site.js" asp-append-version="true"></script>
@RenderSection("Scripts", required: false)
</body>
// Checkout.cshtml — only loads stripe.js on the checkout page
@section Styles {
<link rel="stylesheet" href="~/css/checkout.css" asp-append-version="true" />
}
@section Scripts {
<script src="https://js.stripe.com/v3/" defer></script>
<script src="~/js/checkout.js" asp-append-version="true" defer></script>
}
// ============================================================
// MODERN: .NET 8/9 Razor Pages + WebOptimizer
// ============================================================
// Program.cs
builder.Services.AddWebOptimizer(pipeline =>
{
pipeline.AddCssBundle("/css/bundle.css",
"/css/site.css", "/css/components.css");
pipeline.AddJavaScriptBundle("/js/bundle.js",
"/js/utils.js", "/js/site.js");
});
app.UseWebOptimizer();
// _Layout.cshtml
<link rel="stylesheet" href="/css/bundle.css" />
<script src="/js/bundle.js" defer></script>Cache-busting and versioning
The asp-append-version="true" tag helper generates a hash of the file contents and appends it as a query string — site.css?v=abc123. When the file changes, the hash changes, browsers treat it as a new URL and fetch fresh. This is strictly better than manually bumping version query strings because it requires no deploy-time action. WebOptimizer does the same for bundles, and the bundle URL becomes content-addressed — /css/bundle.css?v=hash-of-all-inputs. Combined with HTTP caching headers set to Cache-Control: public, max-age=31536000, immutable, you get aggressive browser caching without the stale-content problem.
2026 recommendations
- Server-rendered .NET apps — Razor Pages + tag helpers + WebOptimizer. Stable, fast, no npm required
- Blazor Server — reference CSS in the component scoped-CSS file (
Component.razor.css), Blazor handles bundling - Blazor WASM — use CSS isolation or a Vite build pipeline; skip WebOptimizer (it runs on server, WASM is client)
- .NET + SPA frontend (React, Vue, Svelte) — Vite or Turbopack for the frontend, .NET serves the API only
- Legacy MVC 5 on .NET Framework — keep
System.Web.Optimizationfor now, migrate to WebOptimizer during the Core port
FAQs
Is System.Web.Optimization dead?
It’s Webforms-era and .NET Framework-only — never ported to .NET Core. It still works on .NET Framework 4.8.x and will until end of Windows Server 2019 support. For any Core / .NET 5+ project, use WebOptimizer or a frontend build tool.
Should I use ScriptBundle or individual script tags?
In 2026, individual tags with HTTP/2 multiplexing perform better than bundling for most sites — bundling was a workaround for HTTP/1.1’s request limits. Still bundle for JavaScript that has interdependencies the browser can’t parallelise. Keep CSS in a single bundle to avoid render-blocking chains.
What about Bootstrap 5 / Tailwind CSS in .NET?
Tailwind requires a build step — integrate with Microsoft.AspNetCore.SpaServices or run npx tailwindcss -w in a separate terminal during development. Bootstrap 5 still works with plain tag helpers if you skip the JS plugins or write small custom replacements.
Can I use WebOptimizer with CSS preprocessors?
Yes. WebOptimizer has a CompileScss() middleware for SASS and postcss integration. For PostCSS-heavy pipelines (Tailwind, autoprefixer), a proper Vite or webpack build is easier than extending WebOptimizer.
When should I load JavaScript with defer vs async?
defer for everything that needs the DOM — scripts execute in order after HTML parses. async for third-party analytics scripts that have no DOM dependency. The helper shown above uses defer as the safe default. Avoid inline scripts in the <head> without defer — they block rendering.
Does this work on Azure App Service?
Yes for all three patterns. Azure App Service handles .NET Framework Webforms and .NET 8/9 Razor Pages equally. For best performance, enable the Response Compression middleware (brotli/gzip) and let Azure Front Door cache static bundles.