DEV Community

Cover image for Managing ASP.NET Core MVC front-end dependencies with npm and webpack (part 2)
Lars Willemsens
Lars Willemsens

Posted on • Updated on

Managing ASP.NET Core MVC front-end dependencies with npm and webpack (part 2)

This post is the second part in a two-part article on managing Javascript and CSS dependencies within a multiple-page application written in ASP.NET Core MVC.
In the first part we’ve specified our front-end dependencies, bumped the version numbers and set up a webpack build system.
In this part we’ll be tackling performance issues and we’ll make sure that the entire project (front-end and back-end) can be built using a single command.


While everything seems to be working ok, there are a few improvements to be made. When fast clicking between pages (i.e., 'Home' and 'Privacy') you might notice that CSS gets applied after the page is rendered by the browser. That’s because the npm package style-loader plugs the CSS into the page after it was loaded causing the browser to re-render the page!
On top of that, the Javascript bundle — which contains the CSS — is very large. It contains the entire Bootstrap CSS as well as some Bootstrap Javascript functions and all of JQuery!

Let’s take care of this. Our aim is the following:

  • Bundling CSS into a separate CSS file that can be statically referenced from an HTML link tag
  • Splitting up the Javascript code into separate bundles. Many pages have Javascript code that is unique to them and not every page needs JQuery

As mentioned in part 1, we’re only keeping JQuery around because ASP.NET Core’s client-side form validation depends on it.

To make sure that the solution that I’m about to present here fits all use cases, let’s set up a simple prototype. These are the pages we’re going to use:

Alt Text

All of the pages include the site’s CSS as well as a bit of Javascript that is common to all pages. The individual pages are:

  • The Index page, which has a bit of custom Javascript code running on it (index.js)
  • The Privacy page, which has a Bootstrap component on it that needs special Bootstrap JavaScript code to function. We'll load that code from bootstrap_js.js - not to be confused with Bootstrap’s CSS, which is used everywhere on the site.
  • The Contact page, which has a form on it that is backed by ASP.NET Core’s form validation. Validation can be done both server-side and client-side. We'll load client-side validation code from validation.js.

With all of this in the pipeline you might be wondering why we went down this road in the first place. We will end up with individual CSS/JS files that are hard-referenced from the HTML pages… that’s what we started with! Well, sort of, but not quite. Here’s what’s different:

  • We’re referencing our libraries with a specific version number
  • The dependencies are not placed inside the project tree
  • We’ll end up with a performance gain (in the standard MVC template all of Bootstrap and JQuery are referenced from all pages)
  • Our build system is extensible: Want Sass? No problem! Want to use the very latest ECMAScript features? You got it! Need minification or obfuscation? No problemo!

Just imagine what it would be like if all of this was already present in the standard MVC template. Then you’d have all of this modern front-end goodness without having to set it up yourself. Ha!

Ok, let’s go.

Splitting up the bundle

This is the current state of things:

$ ll wwwroot/dist/
total 3164
-rw-rw-r-- 1 lars lars 1482707 Mar 10 13:48 site.entry.js
-rw-rw-r-- 1 lars lars 1756579 Mar 10 13:48
Enter fullscreen mode Exit fullscreen mode

That’s over 1400K worth of Javascript and CSS code.

These are the separate blocks that we had identified in the diagram above:

  • Sitewide CSS (Bootstrap CSS and custom CSS)
  • Sitewide Javascript
  • Bootstrap’s JS code: for fancy popup buttons and the like
  • Validation scripts (basically JQuery with some extras): for forms that use ASP.NET Core’s form validation
  • A sample Javascript code block that is unique to a specific page (let’s take ‘Home’, so index.js)

This is what our view files look like after we move all of the common parts into _Layout.cshtml:

Alt Text

To split things up we’ll dive into ClientApp/src/js/ and turn site.js into four files:

  • site.js: Javascript and CSS code that is needed on each page (currently contains Bootstrap CSS and custom CSS). Will result in a separate JS and CSS file as seen in the diagram
  • bootstrap_js.js: Bootstrap’s Javascript code
  • validation.js: JQuery, including the validation scripts for our forms
  • index.js: some dummy code that’s only applicable to ‘Home’

Here’s what they look like:


This file lost a few lines when compared to the previous version. CSS is needed on every page of the application so we’re including all of our CSS here:

import 'bootstrap/dist/css/bootstrap.css';

// Custom CSS imports
import '../css/site.css';

console.log('The \'site\' bundle has been loaded!');
Enter fullscreen mode Exit fullscreen mode


Here, we’re including bootstrap’s Javascript code. If an import statement doesn’t include a file extension, then it’s a JS file:

import '@popperjs/core';
import 'bootstrap';

console.log('The \'bootstrap_js\' bundle has been loaded!');
Enter fullscreen mode Exit fullscreen mode


These import lines were previously in site.js. We’re putting them into their own file so that they can be included separately:

import 'jquery';
import 'jquery-validation';
import 'jquery-validation-unobtrusive';

console.log('The \'validation\' bundle has been loaded!');
Enter fullscreen mode Exit fullscreen mode


… some dummy code:

console.log('The \'index\' bundle has been loaded!');
Enter fullscreen mode Exit fullscreen mode

Configuring the webpack build

Separate files means separate entries in webpack. Each entry is handled as a separate module and will result in a separate Javascript file. The resulting file for each entry will be named after the entry followed by the .entry.js suffix.

While we’re at it, we’ll extract the CSS out of the Javascript bundle. Instead of using the style-loader npm package we’ll use mini-css-extract-plugin, which takes care of the extraction.

Brace yourself, webpack.config.js is coming…

 const path = require('path');
+const MiniCssExtractPlugin = require("mini-css-extract-plugin");

 module.exports = {
     entry: {
-        site: './src/js/site.js'
+        site: './src/js/site.js',
+        bootstrap_js: './src/js/bootstrap_js.js',
+        validation: './src/js/validation.js',
+        index: './src/js/index.js'
     output: {
         filename: '[name].entry.js',
         path: path.resolve(__dirname, '..', 'wwwroot', 'dist')
     devtool: 'source-map',
     mode: 'development',
     module: {
         rules: [
                 test: /\.css$/,
-                use: ['style-loader', 'css-loader'],
+                use: [{ loader: MiniCssExtractPlugin.loader }, 'css-loader'],
                 test: /\.(eot|woff(2)?|ttf|otf|svg)$/i,
                 type: 'asset'
-    }
+    },
+    plugins: [
+        new MiniCssExtractPlugin({
+            filename: "[name].css"
+        })
+    ]
Enter fullscreen mode Exit fullscreen mode

At the very top and the very bottom you can see we’re importing an npm package and adding it as a plugin respectively. Most plugins have a wide range of configuration options, but we only need to specify which filename to use for CSS files ([name].css).

In the entry section the different entries are defined and near the center of the file we’ve replaced style-loader with the plugin.

So one npm package is being replaced by another. Update package.json accordingly:

     "name": "Net6NpmWebpack",
     "description": "ASP.NET Core MVC project with npm and webpack front-end configuration.",
     "repository": "",
     "license": "MIT",
     "version": "4.0.0",
     "dependencies": {
         "@popperjs/core": "^2.11.2",
         "jquery": "^3.6.0",
         "jquery-validation": "^1.19.3",
         "jquery-validation-unobtrusive": "^3.2.12",
         "bootstrap": "^5.1.3"
     "devDependencies": {
         "webpack": "^5.70.0",
         "webpack-cli": "^4.9.2",
         "css-loader": "^6.7.1",
-        "style-loader": "^3.3.1"
+        "mini-css-extract-plugin": "^2.6.0"
     "scripts": {
         "build": "webpack"
Enter fullscreen mode Exit fullscreen mode

Let’s generate those new bundles. From the ClientApp directory, enter:

$ npm install
$ npm run build
Enter fullscreen mode Exit fullscreen mode

Which build artifacts were generated this time?

$ ll ../wwwroot/dist/
total 2116
-rw-rw-r-- 1 lars lars 301988 Mar 13 14:14 bootstrap_js.entry.js
-rw-rw-r-- 1 lars lars 273306 Mar 13 14:14
-rw-rw-r-- 1 lars lars    270 Mar 13 14:14 index.entry.js
-rw-rw-r-- 1 lars lars    223 Mar 13 14:14
-rw-rw-r-- 1 lars lars 207495 Mar 13 14:14 site.css
-rw-rw-r-- 1 lars lars 522699 Mar 13 14:14
-rw-rw-r-- 1 lars lars   3141 Mar 13 14:14 site.entry.js
-rw-rw-r-- 1 lars lars   1868 Mar 13 14:14
-rw-rw-r-- 1 lars lars 365376 Mar 13 14:14 validation.entry.js
-rw-rw-r-- 1 lars lars 470538 Mar 13 14:14
Enter fullscreen mode Exit fullscreen mode

The Views

After splitting up the bundle into multiple smaller bundles we now have to review our link and script tags.

With mini-css-extract-plugin in the picture, the CSS will have to be imported statically. CSS is used everywhere so we jump into _Layout.cshtml:


     <title>@ViewData["Title"] - net6npmwebpack</title>
     <script src="~/dist/site.entry.js" defer></script>
+    <link rel="stylesheet" href="~/dist/site.css">

Enter fullscreen mode Exit fullscreen mode

The Home/Index.cshtml page has custom Javascript:

     ViewData["Title"] = "Home Page";

+@section Scripts
+    <script src="~/dist/index.entry.js" defer></script>
 <div class="text-center">

Enter fullscreen mode Exit fullscreen mode

The Privacy.cshtml page gets a fancy Bootstrap component. Let’s pick
a dropdown menu button!

     ViewData["Title"] = "Privacy Policy";
+@section Scripts
+    <script src="~/dist/bootstrap_js.entry.js" defer></script>

 <p>Use this page to detail your site's privacy policy.</p>
+<div class="dropdown">
+    <button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton1" data-bs-toggle="dropdown" aria-expanded="false">
+        Dropdown button
+    </button>
+    <ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
+        <li><a class="dropdown-item" href="#">Action</a></li>
+        <li><a class="dropdown-item" href="#">Another action</a></li>
+        <li><a class="dropdown-item" href="#">Something else here</a></li>
+    </ul>
Enter fullscreen mode Exit fullscreen mode

… and then there’s the Contact page, a new page that we’ll build from scratch just to test form validation. We’ll need a view, a view-model, some new actions in the controller and a link on the site’s navigation bar.

Let’s start with the form itself, a new view created as Home/Contact.cshtml:

@model ContactViewModel

    ViewBag.Title = "Contact";
    Layout = "_Layout";

@section Scripts
    <script src="~/dist/validation.entry.js" defer></script>


<form asp-controller="Home" asp-action="Contact">
    <div class="mb-3">
        <label asp-for="Subject" class="form-label"></label>
        <input asp-for="Subject" class="form-control"/>
        <span asp-validation-for="Subject" class="small text-danger"></span>

    <div class="mb-3">
        <label asp-for="Message" class="form-label"></label>
        <textarea asp-for="Message" class="form-control"></textarea>
        <span asp-validation-for="Message" class="small text-danger"></span>

    <button class="btn btn-primary" type="submit">Submit</button>
Enter fullscreen mode Exit fullscreen mode

Subject, Message, ContactViewModel, … what are you on about!?
Let’s move out of the Views directory and into Models

using System.ComponentModel.DataAnnotations;

namespace net6npmwebpack.Models;

public class ContactViewModel
    [StringLength(30, MinimumLength = 3)]
    public string Subject { get; set; }

    [Required(ErrorMessage = "Please enter a message.")]
    public string Message { get; set; }
Enter fullscreen mode Exit fullscreen mode

… but the form is GET-ing and POST-ing all over the place, how is that handled?
Time to edit HomeController:


+        [HttpGet]
+        public IActionResult Contact()
+        {
+            return View();
+        }
+        [HttpPost]
+        public IActionResult Contact(ContactViewModel contactVM)
+        {
+            if (ModelState.IsValid)
+            {
+                // Send an email or save the message in a table...
+                // Redirect to a page that says "Thanks for contacting us!"...
+                return RedirectToAction("Index");
+             }
+            return View();
+        }

Enter fullscreen mode Exit fullscreen mode

… ok and the link?
Back to _Layout.cshtml:


                         <li class="nav-item">
                             <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
+                        <li class="nav-item">
+                            <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Contact">Contact</a>
+                        </li>

Enter fullscreen mode Exit fullscreen mode

Done! (almost)
We’ve now got a full-blown webpack and NPM powered front-end with excellent performance and modern Javascript goodness.

We don’t need _ValidationscriptsPartial.cshtml anymore so be sure to remove that one from your repository:

$ rm Views/Shared/_ValidationScriptsPartial.cshtml
Enter fullscreen mode Exit fullscreen mode

If you’re consistent about adding defer to your script tags (and you should be! :)) then you can go one step further and move the Scripts section inside _Layout to that page’s head section.

 <!DOCTYPE html>
 <html lang="en">
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>@ViewData["Title"] - net6npmwebpack</title>
     <script src="~/dist/site.entry.js" defer></script>
+    @await RenderSectionAsync("Scripts", required: false)
     <link rel="stylesheet" href="~/dist/site.css">


     <footer class="border-top footer text-muted">
         <div class="container">
             &copy; 2022 - net6npmwebpack - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
-    @await RenderSectionAsync("Scripts", required: false)
Enter fullscreen mode Exit fullscreen mode

The project so far can be found on GitLab as version 3 of NetCoreNpmWebpack.

Give it a spin. You’ll notice that performance is good.

Note: We're currently only including the bootstrap_js.entry.js file on the page that contains the dropdown. This will, unfortunately, mess with our responsiveness. When we're on an extra small screen, a hamburger menu will be shown which requires Bootstrap. So if you care about responsiveness, you'll be better off importing the Bootstrap JavaScript code from site.js and removing bootstrap_js.js altogether.

Building the project

Running the project is perhaps easier said than done. Let’s recap:

$ npm install          # only after a modification to package.json
$ npm run build
$ dotnet build
$ dotnet run
Enter fullscreen mode Exit fullscreen mode

That’s too much typing for anyone, let’s automate that a bit.

The .csproj file can be extended with some extra build commands. Honestly, csproj-Hocus Pocus is a bit of uncharted territory for me (although it reminds me of the Ant build system), but this seems to work fine:

 <Project Sdk="Microsoft.NET.Sdk.Web">

+        <IsPackable>false</IsPackable>
+        <MpaRoot>ClientApp\</MpaRoot>
+        <WWWRoot>wwwroot\</WWWRoot>
+        <DefaultItemExcludes>$(DefaultItemExcludes);$(MpaRoot)node_modules\**</DefaultItemExcludes>
+    <ItemGroup>
+        <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="6.0.3"/>
+    </ItemGroup>
+    <ItemGroup>
+        <!-- Don't publish the MPA source files, but do show them in the project files list -->
+        <Content Remove="$(MpaRoot)**"/>
+        <None Remove="$(MpaRoot)**"/>
+        <None Include="$(MpaRoot)**" Exclude="$(MpaRoot)node_modules\**"/>
+    </ItemGroup>
+    <Target Name="NpmInstall" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(MpaRoot)node_modules') ">
+        <!-- Ensure Node.js is installed -->
+        <Exec Command="node --version" ContinueOnError="true">
+            <Output TaskParameter="ExitCode" PropertyName="ErrorCode"/>
+        </Exec>
+        <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from, and then restart your command prompt or IDE."/>
+        <Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..."/>
+        <Exec WorkingDirectory="$(MpaRoot)" Command="npm install"/>
+    </Target>
+    <Target Name="NpmRunBuild" BeforeTargets="Build" DependsOnTargets="NpmInstall">
+        <Exec WorkingDirectory="$(MpaRoot)" Command="npm run build"/>
+    </Target>
+    <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
+        <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
+        <Exec WorkingDirectory="$(MpaRoot)" Command="npm install"/>
+        <Exec WorkingDirectory="$(MpaRoot)" Command="npm run build"/>
+        <!-- Include the newly-built files in the publish output -->
+        <ItemGroup>
+            <DistFiles Include="$(WWWRoot)dist\**"/>
+            <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
+                <RelativePath>%(DistFiles.Identity)</RelativePath>
+                <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
+                <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
+            </ResolvedFileToPublish>
+        </ItemGroup>
+    </Target>
+    <Target Name="NpmClean" BeforeTargets="Clean">
+        <RemoveDir Directories="$(WWWRoot)dist"/>
+        <RemoveDir Directories="$(MpaRoot)node_modules"/>
+    </Target>

Enter fullscreen mode Exit fullscreen mode

As you may have figured out from the diff above, the npm install command is only executed in case the node_modules directory is absent. That’s something to keep in mind in case you make modifications to package.json!

Now we can build the project in its entirety using the dotnet build command. Excellent! (pressing the run or compile button from your IDE works just as well)

Auto-building the bundle

To make life even easier, we want to automagically rebuild the bundle whenever the front-end code changes. At the same time we don’t want to restart ASP.NET Core’s HTTP server (Kestrel) when that happens.

To make this happen, we’ll add a webpack watcher for the front-end files that will trigger a rebuild. In package.json:

     "scripts": {
-        "build": "webpack"
+        "build": "webpack",
+        "watch": "webpack --watch"
Enter fullscreen mode Exit fullscreen mode

While editing front-end code our workflow will look like this:

  1. npm run watch (executed from within the ClientApp directory)
  2. dotnet run

(Note: I would advise against using dotnet watch since it seems to continuously detect changes to the bundle causing an endless rebuild loop)

Version 4 of the sample project can be found here.

Wrapping up

We now have a flexible and extensible project that is using modern front-end technologies and has excellent performance.

We’ve had to cover quite a bit of ground since many of these techniques are absent in most tutorials. Bower, Grunt and Gulp were dominant just a few years ago, but are now on their decline. Many sources on the internet still refer to these kings of yesteryear. However, on Bower’s website you can see that they are actively recommending alternatives.

I think that this guide may have filled a gap by bringing npm and webpack into MVC and MPA applications, more specifically .NET Core and .NET 5/6 apps.

What’s left?

There is no distinction yet between “Development” and “Production”. Minification of JavaScript code as well as CSS pruning are still to be added. I’m confident, though, that the flexibility of the build system won’t make that too challenging.

If you have any other suggestions, then please let me know in the comments below.

Good luck building your MVC application!

Top comments (14)

fawkes07 profile image
Hugo Ruvalcaba

Hey bro!

Great post! this guide was awesomely helpfully as first aproach of webpack! just one question. I followed this project as learn process but I stoped with a little prolem, My dropdown buttons doesn't work :(

Searching in other sources I see that popper is requeried for this but in your code is already imported! (@popperjs/core). So what is the real problem here? Any idea? I just want to be sure I know why this could happend to avoid and learn how to fixit by my own for future projects.

Again. Thanks a lot for your time writing this!

larswillemsens profile image
Lars Willemsens

First thing to check: on that page where you're using the dropdown, you should include the 'bootstrap_js.entry.js' script.
If you have the log statement in there, you should see 'The 'bootstrap_js' bundle has been loaded!' in the console.
Then, ensure that the popper import is listed in 'bootstrap_js.js'. To be absolutely sure, you can even inspect 'bootstrap_js.entry.js'. It's unreadable, but searching for popper should give you some results.
Hope this helps!

joonhwan profile image

With my limited knowledge of webpack + asp net core, I've never had satisfying project template during last years.

Thanks for this great series of posts.

d0rf47 profile image
Michael j.

These two articles were seriously helpful man! thank you!

martajohnsson profile image
Marta Johnsson

Hi, Your guide was very helpful. But I wounder how I can use jQuery now.
I'm just testing to add some script:
<br> $(document).ready(function () {<br> console.log(&quot;ready!&quot;);<br> });<br>
On a contact page in script section but getting: Uncaught ReferenceError: $ is not defined

I know I should not put code like that but I'm working on a huge refactoring and there is a ton of such scripts everywhere.

larswillemsens profile image
Lars Willemsens

Hey, thanks for your comment! You won't be able to use jQuery in a script section using this setup. You can use jQuery in a JavaScript file that will be picked up by webpack, however.
If you do need jQuery in an inline JavaScript section, then I'd recommend adding reference to a jQuery CDN as well.
Hope this helps your refactoring. Good luck!!

martajohnsson profile image
Marta Johnsson

Thank You for Your answer.
I did find another solution. I can use expose-loader to make it work.
test: require.resolve("jquery"),
loader: "expose-loader",
options: {
exposes: ["$", "jQuery"],

Thread Thread
larswillemsens profile image
Lars Willemsens

Great! That looks like an ideal solution. Thanks for the addition!

martajohnsson profile image
Marta Johnsson

I've been trying to add purgeCSS in my postcss.config.js without success. Do You have any idea why it should't work as excepted?
My css is just blank after that process.
I would like to work with scss files from node_modules/bootstrap/scss/bootstrap and be able to add custom configuration.
I ask because You mantion CSS pruning in Your article.

martajohnsson profile image
Marta Johnsson

So silly mistake. I did send wrong path to content. It should be: content: ["./../Views/*/.cshtml"]. Now it works like a charm.

pykos profile image
Blaž Lozej

Great article, thanks it really helped a lot...

michaelkaramanolis profile image
Michael Karamanolis • Edited

Quick question, how would i deploy from github to Heroku currently i tried using the following buildpack but no joy
Log follows
-----> Building on the Heroku-20 stack
-----> Using buildpack:
-----> Core .NET app detected

Installing dotnet
-----> Removing old cached .NET version
-----> Fetching .NET SDK
-----> Fetching .NET Runtime
-----> Export dotnet to Path
-----> Project File
-----> Project Name
publish /tmp/build_2b8f36bf/SkyQR.csproj for Release on heroku_output

Welcome to .NET 6.0!

SDK Version: 6.0.201

Installed an ASP.NET Core HTTPS development certificate.
To trust the certificate run 'dotnet dev-certs https --trust' (Windows and macOS only).

Learn about HTTPS:

Write your first app:
Find out what's new:
Explore documentation:
Report issues and find source on GitHub:

Use 'dotnet --help' to see available commands or visit:

Microsoft (R) Build Engine version 17.1.0+ae57d105c for .NET
Copyright (C) Microsoft Corporation. All rights reserved.
Determining projects to restore...
Restored /tmp/build_2b8f36bf/SkyQR.csproj (in 5.27 sec).
SkyQR -> /tmp/build_2b8f36bf/bin/Release/net6.0/linux-x64/SkyQR.dll
/usr/bin/sh: 2: /tmp/tmp4cf9164d7b7042719df0c7cd48e78bf8.exec.cmd: npm: not found
/tmp/build_2b8f36bf/SkyQR.csproj(35,5): error MSB3073: The command "npm run build" exited with code 127.
! Push rejected, failed to compile Core .NET app.
! Push failed

michaelkaramanolis profile image
Michael Karamanolis

I figured it out
pls follow this mans instructions creating a container
big shout out!!!!
note the the cli expects for dotnet 6.0
Yay its now working ...

atrevorwaters profile image
Trevor Waters

This was amazing, I had to make an account to say Thank you! So much accomplished in one place, it's really hard to find.