Shawn Wildermuth

Author, Teacher, and Filmmaker
.NET Foundation Board Member

Hosting Vue in ASP.NET Core: A Different Take


CalipersIn the several years that I've been developing and teaching Vue, I've tried a lot of different ways to make ASP.NET Core and Vue play nice with each other. One of the strategies that I've seen employed (especially with Angular and React) is the Spa Framework Extensions out of Microsoft. Because Vue didn't work out of the box (or have a template) I dismissed this approach for a long time. Now that the platform has matured and there is an open source extension for Vue,

I thought I'd revisit it. Though I still think it's not exactly the right approach. Let's see why.

The Vue CLI Middleware Project's Approach

Originally, I thought the fix was to just deal with it separately. That meant opening it in VS Code or just using the command-line to run the project. I wanted something more integrated and I think I found a solution I don't mind.

Let's look at an example. The Vue CLI Middleware project (https://github.com/EEParker/aspnetcore-vueclimiddleware) provides a Vue specific way to using the Vue CLI to host the project (via the SpaServices Extensions) so that it happens kind of magically.

There is an example of how this works in the GitHub Project:

https://github.com/EEParker/aspnetcore-vueclimiddleware/tree/master/samples/VueCliSample

My real issue with this approach is that it expects that your project is going to be a Single Page Application (instead of more than one) and it's going to add API calls for the Vue project to use. As many of you know, I prefer the idea of a small number of apps/pages instead of one gigantic Vue app.

At runtime, the middleware is running the 'serve' command which not only serves the project but watches for changes and supports hot-module reload:

endpoints.MapToVueCliProxy(
    "{*path}",
    new SpaOptions { SourcePath = "ClientApp" },
    npmScript: (System.Diagnostics.Debugger.IsAttached) ? "serve" : null,
    regex: "Compiled successfully",
    forceKill: true
    );

This works, but again, supposes only a single app (and assumes that you'll host the final project in a plain HTML file not in MVC or Razor Pages). I wanted more flexibility.

Another contention for me was that it used extensions to point the project at the /dist folder of the Vue project (deeply nested). For example, you configure where the resulting folder is:

services.AddSpaStaticFiles(opt => opt.RootPath = "ClientApp/dist");

I like the project but not the approach. Let's see my changes.

Using the Vue CLI Middleware Project: My Way

Before I add the middle-ware, first I need to make a couple of small changes to the Vue project. The first change is that I'd add a vue.config.js file to the Vue project. The only purpose for this is to redirect the output of builds to the ASP.NET Core project:

module.exports = {
  // Put this in the ASP.NET Core directory
  outputDir: "../wwwroot/app" 
};

The other change is to add a new script to the project.json file to allow us to build the project in development mode and watch for changes:

  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "watch": "vue-cli-service build --mode development --watch"
  },

The watch script will be used to build the entire project (again, into the wwwroot folder) and rebuild every time a change happens. This is similar to the serve command but it doesn't expect us to only use index.html to host our Vue project. This becomes more important as we use multiple projects as we will see soon.

Instead I start by importing the reference into the project:

Adding Middleware

Instead of adding the middleware to point at the Vue project's dist folder, I can be minimally invasive and just add the middleware endpoint (who's real job is to run and watch the Vue CLI build):

      app.UseEndpoints(endpoints =>
      {
        endpoints.MapRazorPages();
        endpoints.MapControllers();

        // Only do for development
        if (env.IsDevelopment())
        {
          endpoints.MapToVueCliProxy(
            "{*path}",
            new SpaOptions
            {
              SourcePath = "client"
            },
            npmScript: "watch",
            regex: "Compiled successfully",
            forceKill: true
            );
        }
      });

This means that I'm only using the middlware during development. Note that the npmScript is the new watch instead of serve. I am doing that specifically because I want to test the Vue app on my own pages.

For example, in my example I am just putting the Vue code on my index.cshtml page (a Razor page):

@page
@model IndexModel
@{
  ViewData["Title"] = "Home page";
}
@section Scripts {
  <script src="~/app/js/chunk-vendors.js"></script>
  <script src="~/app/js/index.js"></script>
}
<div id="app"></div>

This gives me two things I want. One, I am using a Layout page in Razor Pages (or MVC) to be responsible for the layout and menus where applicable. It also means, that if I'm using some frameworks (e.g. Bootstrap) on some pages that don't use Vue, I can continue to use them inside my Vue views because I'm just inside the Razor pages.

Supporting Multiple Vue 'Pages'

I like this approach, too, because it encourages me to not just build a single Vue monolithic project, but instead to allow me to use the Vue CLI's support for pages. See the vue.config.js's pages configuration:

module.exports = {
  // Put this in the ASP.NET Core directory
  outputDir: "../wwwroot/app",
  pages: {
    index: "src/main.js",
    contact: "src/contact.js"
  }
};

In this case, I'm building two separate Vue projects (by having separate startup files) so that I can build smaller, discrete Vue projects. Because they're in the same project, they can share all their code. This way I can have a separate Contact page Vue project where the Contact.cshtml just hosts the contact Vue project:

@page
@model ContactModel
@{
  ViewData["Title"] = "Contact";
}
@section Scripts {
  <script src="~/app/js/chunk-vendors.js"></script>
  <script src="~/app/js/contact.js"></script>
}
<div id="contact"></div>

Final Thoughts

The middleware wasn't exactly written with this in mind. I might do a pull request that prevents me from actually serving the index.html by default as I'm not using that and it will throw an error if that file doesn't exist. (e.g. make sure one of your pages is still called index).

What do you think about this approach? I like it because in production there is no middlware at all. It's just Razor Pages (or MVC) serving the Vue projects as .js files. It does require that we modify the build scripts to make sure Node and Vue CLI are installed when we build our CI builds. But that's not a big deal IMO.

You can find the example in GitHub:

https://github.com/shawnwildermuth/VueInCore

Any thoughts on how to improve on this?