How to Build a True Multi-Page Website Using ASP.NET, VUE.js, Vite.js: Making the Site Multi-Page with minimal copy-pasting

David W. Gray
8 min readJan 8, 2024

--

This article is part of a series where I show how to build a “true” multipage website using ASP.NET, Vue.js, and Vite.js — The first article describes the requirements for the website and why I’ve landed on this specific combination of technologies and requirements as well as providing a table of contents of sorts for the series. In this article, I’ll walk through how I minimize the boilerplate code in the ASP.NET part of the site.

The Objective:

I have a fully functional ASP.NET website that renders the core content page with Vue.js, including using Vite.js to serve up assets. AspNetViteMpa/initial-integration is the GitHub tag at that point. Now, I’d like to turn this into a multi-page website that serves up a different Vue.js App for each page.

Most importantly, I want to minimize the boilerplate code I need to copy-paste as I create each page. As I’ve said before, and will repeat until I’m blue in the face, I’m allergic to duplicate code.

As a bonus, I’d like to have at least one page that remains a vanilla ASP.NET.

The Client:

The first thing I’m going to do is refactor my client code so that it contains a directory for each page. I cover how I manage the client code in this article, including how I avoid boilerplate code in the client source code and build process. For the purpose of this article, the result is that I’ll have a directory structure where each vue.js root can be referenced from /src/pages/{page-name}/main.ts.

The Controller:

I’ll start by creating a C# class to act as a model to describe each of my pages. For this simple example, the model will merely include the Description, Name and Title of the page. For my real-world version of this scenario, I include many other things that differ about the pages. These are things like data that is consumed by the Vue.js, reference to a help page, whether to serve ads on the page, and even custom JavaScript that’s specific to the page (I use the last of these to do things like adding the FaceBook like button initialization script).

public class VueModel
{
public VueModel(string name, string title, string description)
{
Name = name;
Title = title;
Description = description;
}
public string Description { get; }
public string Name { get; }
public string Title { get; }
}

I’ll then create a Controller Base Class (VueController) that will render the single Vue.cshtml page with the model object that the controller action passes in.

public class VueController : Controller
{
protected ActionResult RenderVue(string name, string title, string description)
{
return View("Vue", new VueModel(name, title, description));
}
}

Which then allows me to write single-line actions for each of my pages:

public IActionResult Index()
{
return RenderVue("home", "Home Page", "Home Page for AspNetViteMpa Sample");
}

public IActionResult Vite()
{
return RenderVue("vite-info", "Vite Info", "Vite Info Page for AspNetViteMpa Sample");
}

public IActionResult AspNet()
{
return RenderVue("dotnet-info", "Asp.Net Info", "Asp.Net Info Page for AspNetViteMpa Sample");
}

public IActionResult Vue()
{
return RenderVue("vue-info", "Vue Info", "Vue Info Page for AspNetViteMpa Sample");
}

I suppose I could attempt to make the actions data-driven and avoid creating a function for each action. But in my real-world example, for most pages, the server does some work first to generate the data that the Vue.js application renders. So I’d have to have some kind of custom C# code for each page anyway. Given that, getting rid of the last bit of boilerplate in defining the C# method seems unnecessary.

The VueController layer is an unnecessary abstraction for this simple example since all actions are on the HomeController class. But for any real-world application, I’d want to have the VueController class so that I could use this functionality in multiple controllers.

The View:

That leaves me with figuring out how to write a .cshtml file that can host a different Vue.js root depending on the information contained in the VueModel object.

The core of what I’d like to do is take the Index.cshtml page that I created when setting up the first version of my example and replace the references to main* and index* with the Name property of the VueModel.

That would look something like:

@* This isn't working code, just a sketch *@
<environment include=”Development”>
<script type="module"src="https://localhost:7256/src/{Model.Name}.ts"></script>
</environment>
<environment include="Production">
@section Styles {
<linkrel="stylesheet"asp-href-include="/vite-client/assets/{Model}-*.css">
}
@section Scripts {
<scriptasp-src-include="/vite-client/assets/{Model}-*.js"></script>
}
</environment>
<div id="app"></div>

This would work for the developer scenario. However, I ran into an issue for production that Rollup.js puts the files in somewhat arbitrary places. This is made even more painful because Vite.js has an option that will let you either generate a single .css file for your project or split it into chunks (based on the cssCodeSplit parameter).

How do I map from my simple Vue application name (based on the directory where it lives in src) to the (possibly multiple) files generated for production? That’s where the manifest file comes into play.

The Manifest File

A manifest file is a mapping from the files you created in your source to what was produced by the build system. Fortunately, the NuGet package I used for the HTTP proxy middleware (Vite.AspNetCore) also comes with a Vite manifest service that can be injected into controllers and razor pages.

This service locates and loads the manifest file into an IViteManifest object. This simple object implements an indexer method to look up the metadata about each chunk based on a source file name.

For the more straightforward case (cssCodeSplit == false), that allows me to do something pretty simple.

@{
var style = Manifest[$"{Base}/style.css"]?.File";
var entry = Manifest[/vite-client/src/pages/{Model.Name}/main.ts"]?.File;
}
@section Styles {
<linkrel="stylesheet"asp-href-include="@entry">
}
@section Scripts {
<script type="module" src="@entry"></script>
}
{
"_ProductTile-7431252b.js": {
"file": "assets/ProductTile-7431252b.js"
},
"_dotnet-e4994ca1.js": {
"assets": ["assets/dotnet-8e34f380.svg"],
"file": "assets/dotnet-e4994ca1.js"
},
"_vite-9172d22c.js": {
"assets": ["assets/vite-63a26457.svg"],
"file": "assets/vite-9172d22c.js"
},
"_vue-fb809154.js": {
"assets": ["assets/vue-7e7c7361.svg"],
"file": "assets/vue-fb809154.js"
},
"src/assets/dotnet.svg": {
"file": "assets/dotnet-8e34f380.svg",
"src": "src/assets/dotnet.svg"
},
"src/assets/vite.svg": {
"file": "assets/vite-63a26457.svg",
"src": "src/assets/vite.svg"
},
"src/assets/vue.svg": {
"file": "assets/vue-7e7c7361.svg",
"src": "src/assets/vue.svg"
},
"src/pages/dotnet-info/main.ts": {
"file": "assets/dotnet-info-a04b7507.js",
"imports": ["_ProductTile-7431252b.js", "_dotnet-e4994ca1.js"],
"isEntry": true,
"src": "src/pages/dotnet-info/main.ts"
},
"src/pages/home/main.ts": {
"file": "assets/home-4adcea55.js",
"imports": [
"_ProductTile-7431252b.js",
"_dotnet-e4994ca1.js",
"_vite-9172d22c.js",
"_vue-fb809154.js"
],
"isEntry": true,
"src": "src/pages/home/main.ts"
},
"src/pages/vite-info/main.ts": {
"file": "assets/vite-info-df7af843.js",
"imports": ["_ProductTile-7431252b.js", "_vite-9172d22c.js"],
"isEntry": true,
"src": "src/pages/vite-info/main.ts"
},
"src/pages/vue-info/main.ts": {
"file": "assets/vue-info-916357ab.js",
"imports": ["_ProductTile-7431252b.js", "_vue-fb809154.js"],
"isEntry": true,
"src": "src/pages/vue-info/main.ts"
},
"style.css": {
"file": "assets/style-4caaf122.css",
"src": "style.css"
}
}

For the default and more complicated case (cssCodeSplit == true), I have to find all the .css files associated with my entry point and add a reference to each of them.

I do this with a recursive function that runs through the manifest information for my entry point and loads all of the .css files that it and any of its imports depend on.

private List<string> CssFromManifest(string key)
{
var node = Manifest[key];
if (node == null)
{
return new List<string>();
}

var files = node.Css?.ToList() ?? new List<string>();

var imports = node.Imports;
if (imports == null)
{
return files;
}

foreach (var import in imports)
{
files.AddRange(CssFromManifest(import));
}

return files;
}
{
"ProductTile.css": {
"file": "assets/ProductTile-4791a93f.css",
"src": "ProductTile.css"
},
"_ProductTile-5595669c.js": {
"css": ["assets/ProductTile-4791a93f.css"],
"file": "assets/ProductTile-5595669c.js"
},
"_dotnet-e4994ca1.js": {
"assets": ["assets/dotnet-8e34f380.svg"],
"file": "assets/dotnet-e4994ca1.js"
},
"_vite-9172d22c.js": {
"assets": ["assets/vite-63a26457.svg"],
"file": "assets/vite-9172d22c.js"
},
"_vue-fb809154.js": {
"assets": ["assets/vue-7e7c7361.svg"],
"file": "assets/vue-fb809154.js"
},
"src/assets/dotnet.svg": {
"file": "assets/dotnet-8e34f380.svg",
"src": "src/assets/dotnet.svg"
},
"src/assets/vite.svg": {
"file": "assets/vite-63a26457.svg",
"src": "src/assets/vite.svg"
},
"src/assets/vue.svg": {
"file": "assets/vue-7e7c7361.svg",
"src": "src/assets/vue.svg"
},
"src/pages/dotnet-info/main.ts": {
"file": "assets/dotnet-info-5237c380.js",
"imports": ["_ProductTile-5595669c.js", "_dotnet-e4994ca1.js"],
"isEntry": true,
"src": "src/pages/dotnet-info/main.ts"
},
"src/pages/home/main.css": {
"file": "assets/main-cfcc63ab.css",
"src": "src/pages/home/main.css"
},
"src/pages/home/main.ts": {
"css": ["assets/main-cfcc63ab.css"],
"file": "assets/home-7b3b578a.js",
"imports": [
"_ProductTile-5595669c.js",
"_dotnet-e4994ca1.js",
"_vite-9172d22c.js",
"_vue-fb809154.js"
],
"isEntry": true,
"src": "src/pages/home/main.ts"
},
"src/pages/vite-info/main.ts": {
"file": "assets/vite-info-6280b6ab.js",
"imports": ["_ProductTile-5595669c.js", "_vite-9172d22c.js"],
"isEntry": true,
"src": "src/pages/vite-info/main.ts"
},
"src/pages/vue-info/main.ts": {
"file": "assets/vue-info-8bde0d78.js",
"imports": ["_ProductTile-5595669c.js", "_vue-fb809154.js"],
"isEntry": true,
"src": "src/pages/vue-info/main.ts"
}
}

Putting this all together, I have a Vue.cshtml file that looks like this:

@using Vite.AspNetCore.Abstractions
@inject IViteManifest Manifest;
@model VueModel

@{
ViewData["Title"] = Model.Title;
ViewData["Description"] = Model.Description;

string Base = "vite-client";
}

<environment include="Development">
@{
string DevEntry = $"/src/pages/{Model.Name}/main.ts";
}
<script type="module" src="@DevEntry"></script>
</environment>

<environment include="Production">
@functions
{
// Returns a list of css files from a single manifest entry
private List<string> CssFromManifest(string key)
{
var node = Manifest[key];
if (node == null)
{
return new List<string>();
}

var files = node.Css?.ToList() ?? new List<string>();

var imports = node.Imports;
if (imports == null)
{
return files;
}

foreach (var import in imports)
{
files.AddRange(CssFromManifest(import));
}

return files;
}
}

@{
string ProdEntry = $"{Base}/src/pages/{Model.Name}/main.ts";
string? ProdCss = Manifest[$"{Base}/style.css"]?.File;
}
@section Styles {
@* This will pull in the main css file (which exists if cssCodeSplit=false) *@
@if (ProdCss != null)
{
<link rel="stylesheet" href="/@ProdCss">
}
@* This pulls in the all the relate css files (which exist if cssCodeSplit=true) *@
@{
var cssFiles = CssFromManifest(ProdEntry);

foreach (var file in cssFiles)
{
<link rel="stylesheet" href="/@file">
}
}
}

@section Scripts {
<script type="module" src="/@Manifest[ProdEntry]!.File"></script>
}
</environment>

<div id="app"></div>

Now, for each page, I only have to do two things:

  1. Write the actual Vue.js component. Due to the shenanigans I pulled in the last post, I can start that with the App.vue SFC file, no need for the main.ts boilerplate or even touching the vite.config file each time I add a page.
  2. Write a single-line action function in my control to specify the name of the Vue component I created in step 1.

AspNetViteMpa/complete-first-pass is the GitHub tag for the project at this point.

The Home page of my multi-page site

The core page above is rendered with a Vue.js app. The footer is still ASP.NET, and I left the privacy page as vanilla ASP.NET MVC, with each of the other pages is populated by a different Vue.js app.

Conclusion

At this point, I’m happy with the results. I get all the goodness of Vite.js in the development environment, and everything works smoothly in production.

There are a couple of things I’m doing in my real-world website that I haven’t shown here.

  1. I’m using Bootstrap for styling on both my Vue.js and vanilla ASP.NET pages, so the end user can’t tell them apart (without dropping to the dev console).
  2. I’m doing some fun things to make it easier to pass my Vue.js application data from the MVC code.

And there are several things on my radar to improve with this process for my app:

  1. Figure out server-side rendering
  2. Tune the chunking of the app to optimize what gets loaded for each page
  3. Extend the main.ts template in my client code to enable pulling in different libraries for some pages and possibly customize the experience in other ways.

I hope this series has been helpful. Let me know if any of the above are of interest to you, and I’ll consider that as I curate my next steps.

--

--

David W. Gray

I am a software engineer, mentor, and dancer. I'm also passionate about speculative fiction, music, climate justice, and disability rights.