If you want to build an MPA (Multi-Page Application) in ASP.NET Core with Vite then Vite.AspNetCore provides you with just the tools you need. The project is well-documented on its GitHub page and there are some very complete samples to base your project off of.
While testing out my setup, I gained some insights into how the library works and I made this little retrospective to document them.
Here's my project: AspNetVite
First steps
I started off by creating an ASP.NET Core MVC application.
mkdir AspNetVite
cd AspNetVite
dotnet new mvc
dotnet new gitignore
Next, I added the NuGet package and updated my Program.cs by following the project's setup instructions.
Then I copied some config files from one of the sample projects to my own project. These were: package.json, tsconfig.json, tsconfig.node.json, and (probably the most important one) vite.config.ts.
I customized package.json (name, author, version, ...) and ran my first npm i. You can see all changes I made in my commit log. These four Node-related files can be found in this commit or you can grab them from the sample project linked earlier.
A big difference with the traditional ASP.NET MVC template is that Vite.AspNetCore uses an Assets folder. In vite.config.ts, you can see that Vite refers to Assets/main.ts and that it will wipe your wwwroot. β οΈ
All TypeScript, Styling, and even the precious favicon had to make the move to Assets. I created an Assets/main.ts with a simple log statement and chose to do my styling with Sass so I created an Assets/main.scss that I imported from the TypeScript file.
A TypeScript file can be imported in a view using the vite-src tag-helper so I updated _Layout.cshtml by adding this line:
<script type="module" vite-src="~/main.ts"></script>
... and updated _ViewImports.cshtml to enable the tag-helper:
@addTagHelper *, Vite.AspNetCore
(I also moved the Scripts section to the head element because we no longer live in the 2000s π.)
Check out the changes here.
There was still some code in wwwroot that had to be moved over:
- I moved styling from
site.css(and also from_Layout.cshtml.css!) tomain.scss. - I moved
favicon.icoto theAssetsdirectory. - I removed the entire
wwwrootfrom my git repository.
The code depends on Bootstrap and JQuery so instead of having them in the wwwroot, I needed to download them through npm instead. In package.json, I added these dependencies:
"dependencies": {
"@popperjs/core": "^2.11.8",
"jquery": "^3.7.1",
"jquery-validation": "^1.21.0",
"jquery-validation-unobtrusive": "^4.0.0",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
},
Assets/main.scss was then updated to import Bootstrap styles:
+@use "bootstrap/dist/css/bootstrap.css";
+@use "bootstrap-icons/font/bootstrap-icons.css";
h1.display-4 {
background-color: #0f5132;
}
... and Assets/main.ts, was updated to import Bootstrap's JavaScript code, JQuery, and other dependencies:
+import '@popperjs/core';
+import 'bootstrap';
+import 'jquery';
+// Using the next two lines is like including partial view _ValidationScriptsPartial.cshtml
+import 'jquery-validation';
+import 'jquery-validation-unobtrusive';
import './main.scss';
console.log('Hello, world!');
_Layout.cshtml still referred to the old Bootstrap and JQuery, so it needed fixing:
-<script type="importmap"></script>
-<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
-<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
-<link rel="stylesheet" href="~/AspNetVite.styles.css" asp-append-version="true" />
<script type="module" vite-src="~/main.ts"></script>
@await RenderSectionAsync("Scripts", required: false)
+<link rel="stylesheet" vite-href="~/main.scss" asp-append-version="true" />
...
-<script src="~/lib/jquery/dist/jquery.min.js"></script>
-<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
-<script src="~/js/site.js" asp-append-version="true"></script>
This was probably the biggest breaking change of the entire process. The full list of changes can be seen here.
Thankfully, I was rewarded with a first working version. (Hurray!)
Time to start cooking! π¨βπ³
Here you can see me try out Bootstrap Icons.
This was easy:
<p>There should appear an 'archive' icon here: <i class="bi bi-archive"></i></p>
(note that I imported Bootstrap Icons from Assets/main.scss earlier)
I also tested a Bootstrap popover as can be seen here.
I had to add some HTML:
<button type="button" class="btn btn-lg btn-danger" data-bs-toggle="popover"
data-bs-title="Popover title"
data-bs-content="And hereβs some amazing content. Itβs very engaging. Right?"
>Click to toggle popover</button>
... and a bit of TypeScript:
import '@popperjs/core';
-import 'bootstrap';
+import * as bootstrap from 'bootstrap';
import 'jquery';
// Using the next two lines is like including partial view _ValidationScriptsPartial.cshtml
import 'jquery-validation';
import 'jquery-validation-unobtrusive';
import './main.scss';
console.log('Hello, world!');
+/**
+ * https://getbootstrap.com/docs/5.3/components/popovers/#enable-popovers
+ */
+function enablePopovers() {
+ const popoverTriggerList = document.querySelectorAll('[data-bs-+toggle="popover"]');
+ Array.from(popoverTriggerList).map(popoverTriggerEl => new +bootstrap.Popover(popoverTriggerEl));
+}
+
+enablePopovers();
To validate the complete setup, I tested client-side validation. Since client-side validation is needed on certain views but not on others, this was the perfect time to split up the front-end logic. I created Assets/validation.ts with this content:
// Importing this file is like including partial view _ValidationScriptsPartial.cshtml
import 'jquery-validation';
import 'jquery-validation-unobtrusive';
console.log('Loaded validation.ts');
And removed the validation code from main.ts:
import '@popperjs/core';
import * as bootstrap from 'bootstrap';
import 'jquery';
-// Using the next two lines is like including partial view _ValidationScriptsPartial.cshtml
-import 'jquery-validation';
-import 'jquery-validation-unobtrusive';
import './main.scss';
-console.log('Hello, world!');
+console.log('Loaded main.ts');
...
I created a contact form, an action method in HomeController (the whole nine yards), and imported the client-side validation code from my view:
@section Scripts
{
<script type="module" vite-src="~/validation.ts"></script>
}
Not only does this provide confirmation that everything works, but it's also a first step towards building a well-structured multi-page application.
In a multi-page application we want to have multiple bundles. Each bundle can then be imported only on those pages that need it. A production application can quickly expand into dozens of bundles, many of which will be specific to a single page. Through the use of ECMAScript modules, common code (such as utility functions or helper classes) can still be shared across bundles.
Moving to production
Everything worked as long I was in "Development" mode, but "Production" was actually broken.
Three things needed fixing:
- Vite config had to be fixed so that it creates multiple bundles, not just for
main.ts. - In
_Layout.cshtmla different kind of import was needed for "Production" as opposed to "Development". - From
csproj, I had to invokenpmcommands and specify which files are needed in the publishedwwwroot.
To fix the bundles for production, I updated vite.config.ts:
import { UserConfig, defineConfig } from 'vite';
+import fs from 'fs';
+import path from 'path';
export default defineConfig(async () => {
+ const files = fs.readdirSync('./Assets');
+ const inputEntries = files
+ .filter(file => file.endsWith('.ts'))
+ .reduce((acc, file) => {
+ const fileName = path.parse(file).name;
+ acc[fileName] = path.join('./Assets', file);
+ return acc;
+ }, {} as Record<string, string>);
+
const config: UserConfig = {
appType: 'custom',
root: 'Assets',
publicDir: 'public',
build: {
emptyOutDir: true,
manifest: true,
outDir: '../wwwroot',
assetsDir: '',
rollupOptions: {
- input: 'Assets/main.ts'
+ input: inputEntries
},
},
...
It's important to note that this configuration will treat all .ts files directly under Assets as "entries" (= files that trigger the creation of a new bundle). Code that shouldn't result in an additional bundle should be placed in a subdirectory.
For example:
βββ Assets
Β Β βββ entry1.ts
Β Β βββ entry2.ts
Β Β βββ main.scss
Β Β βββ main.ts
Β Β βββ util
βΒ Β βββ utility1.ts
Β Β βΒ Β βββ utility2.ts
Β Β βββ presentation
βΒ Β βββ presentation-logic1.ts
Β Β βΒ Β βββ presentation-logic1.ts
Β Β βββ services
Β Β βββ service1.ts
Β Β Β Β βββ service2.ts
Due to the way Vite.AspNetCore works, I also had to change the way styles are picked up in production. In _Layout.cshtml:
- <link rel="stylesheet" vite-href="~/main.scss" asp-append-version="true" />
+ <environment include="Development">
+ <link rel="stylesheet" vite-href="~/main.scss" asp-append-version="true" />
+ </environment>
+ <environment include="Production">
+ <link rel="stylesheet" vite-href="~/main.ts" asp-append-version="true" />
+ </environment>
Yes, you read that right... in production, styles are includes through .ts files.
Finally, I updated my .csproj file and specified which files should be copied to wwwroot and which (npm) commands are needed to build the application, both in development and in production.
The changes to .csproj can be seen here.
Conclusion
Overall the experience was quite smooth, thanks to the existing sample applications. Once the project structure is migrated, everything "Just Workedβ’". I found the updates to .csproj and the different way of importing styles in production to be the trickiest. In my opinion, these things weren't very clear from the documentation, but maybe I missed something. π
I hope my findings can be of use to someone. Do let me know if that's the case. Just drop a comment below. Happy coding!
Top comments (0)