Vue Multi Pages in Dotnet Core

One of the nice things about Vue is the balance between bare framework and opinionated spa, or even multiple pages as seperate applications are easily configurable. They can also be integrated into ASP.Net Core’s MVC pages when Razor isn’t enough. This post will go into integrating two Vue apps (with/without routing) served by ASP.Net Core MVC.

Prerequirements:

  • yarn
  • @vue/cli
  • ASP.Net Core 2.2
  • VS Code

Start by creating an MVC Project:

mkdir DotnetVues
dotnet new mvc --name DotnetVues.Web -o src/DotnetVues.Web

Create a solution and add the project:

dotnet new sln DotnetVues
dotnet sln DotnetVues.sln add src/DotnetVues.Web/

Next create a new Vue app:

vue create client-app

Manually select the below features:

? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, Router, CSS Pre-processors, Linter, Unit
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript for auto-detected polyfills? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
? Pick a linter / formatter config: Prettier
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)Lint on save
? Pick a unit testing solution: Jest
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

Add Buefy:

vue add buefy

select the below choices

? Add Buefy style? scss
? Include Material Design Icons? Yes
? Include Font Awesome Icons? No

NOTE: this will change the dart-sass dependency to node-sass

Configure Vue for multi-page output

Create a vue.config.js file and add the following to add support for pages and change the built files directory to the MVC project’s wwwroot:

module.exports = {
  pages: {
    spa: "src/pages/spa/main.ts",
    singleDashboard: "src/pages/singleDashboard/main.ts"
  },
  outputDir: "../src/DotnetVues.Web/wwwroot/",
  filenameHashing: false,
};

The pages will identify the two entrypoints (next step). filenameHashing should be turned off to allow for easy reference in the Razor Views.

Add the following directories to client-app/src of the Vue app:

.
└── pages
    ├── singleDashboard
    └── spa

Copy the App.vue and main.ts into each of the folders:

.
├── App.vue
├── main.ts
├── router.ts
├── pages
│   ├── singleDashboard
│   │   ├── App.vue
│   │   └── main.ts
│   └── spa
│       ├── App.vue
│       └── main.ts

Move the views/* to pages/spa/ Move router.ts to pages/spa/

Update the pages/spa/router.ts:

import Vue from "vue";
import Router from "vue-router";
import Home from "@/pages/spa/views/Home.vue";

Vue.use(Router);

export default new Router({
  mode: "history",
  base: "/Spa", // This is going to be accessed from the 'spa' controller
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    },
    {
      path: "/about",
      name: "about",
      // this will split the route to a seperate chunk to keep the browser more responsive
      component: () => import("@/pages/spa/views/About.vue"), 
    },
    { path: "*", redirect: "/" } // fallback to home component if route is not found
  ]
});

Then delete client-app/src/App.vue as well as client-app/src/main.ts.

the Vue app should look like this:

.
├── README.md
├── babel.config.js
├── jest.config.js
├── package.json
├── postcss.config.js
├── public
│   ├── favicon.ico
│   └── index.html
├── src
│   ├── assets
│   │   ├── logo.png
│   │   └── scss
│   │       ├── _variables.scss
│   │       └── app.scss
│   ├── components
│   │   └── HelloWorld.vue
│   ├── pages
│   │   ├── singleDashboard
│   │   │   ├── App.vue
│   │   │   └── main.ts
│   │   └── spa
│   │       ├── App.vue
│   │       ├── main.ts
│   │       ├── router.ts
│   │       └── views
│   │           ├── About.vue
│   │           └── Home.vue
│   ├── shims-tsx.d.ts
│   └── shims-vue.d.ts
├── tests
│   └── unit
│       └── example.spec.ts
├── tsconfig.json
└── yarn.lock

Next remove routing from the singleDasboard project pages/singleDashboard/main.ts:

import Vue from 'vue';
import App from './App.vue';
import Buefy from 'buefy';
// import router from './router.ts; // delete
import './assets/scss/app.scss';

Vue.use(Buefy);

Vue.config.productionTip = false;

new Vue({
  // router, // delete
  render: h => h(App)
}).$mount("#singleDashboard");

And in pages/singleDashboard/App.vue:

<template>
  <div id="app">
    <div id="section">
        SPA-less multi-page sample
    </div>
  </div>
</template>

<style lang="scss">

</style>

Building the project will delete the wwwroot folder of the MVC project! (we will replace it all with Buefy/Bulma):

yarn build

Prepare the MVC Project for the two Vue apps

Now that we have built two seperate vue apps, let’s look at the MVC side. As the build process deleted all the bootstrap styles and JQuery, start by deleting all of those resources.

In: Views/Shared/_Layout.cshml

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - DotnetVues.Web</title>

    <!-- TODO Add common-css -->
    <!-- <environment include="Development">
        <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
    </environment>
    <environment exclude="Development">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/css/bootstrap.min.css"
              asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
              asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute"
              crossorigin="anonymous"
              integrity="sha256-eSi1q2PG6J7g7ib17yAaWMcrr5GrtohYChqibrV7PBE="/>
    </environment> -->
    <!-- <link rel="stylesheet" href="~/css/site.css" /> -->
</head>
<body>
    <header>
        <!-- Delete this and replace later wit ha bulma-based navbar -->
        <!-- <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
            <div class="container">
                <a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">DotnetVues.Web</a>
                <button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
                        aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="navbar-collapse collapse d-sm-inline-flex flex-sm-row-reverse">
                    <ul class="navbar-nav flex-grow-1">
                        <li class="nav-item">
                            <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
                        </li>
                    </ul>
                </div>
            </div>
        </nav> -->
    </header>
    <div class="container">
        <partial name="_CookieConsentPartial" />
        <main role="main" class="pb-3">
            @RenderBody()
        </main>
    </div>

    <footer class="border-top footer text-muted">
        <div class="container">
            &copy; 2019 - DotnetVues.Web - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
        </div>
    </footer>

    <!-- TODO Add webpack vendor chunks here -->
    <!-- <environment include="Development">
        <script src="~/lib/jquery/dist/jquery.js"></script>
        <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.js"></script>
    </environment>
    <environment exclude="Development">
        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"
                asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
                asp-fallback-test="window.jQuery"
                crossorigin="anonymous"
                integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=">
        </script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/js/bootstrap.bundle.min.js"
                asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"
                asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal"
                crossorigin="anonymous"
                integrity="sha256-E/V4cWE4qvAeO5MOhjtGtqDzPndRO1LBk8lJ/PR7CA4=">
        </script>
    </environment>
    <script src="~/js/site.js" asp-append-version="true"></script> -->

    @RenderSection("Scripts", required: false)
</body>
</html>

Add the common dependencies to the MVC Projec:

When Vue is built, the dependencies are added to the wwwroot directory like:

.
├── css
│   ├── chunk-common.css
│   └── spa.css
├── favicon.ico
├── img
│   └── logo.png
├── index.html
├── js
│   ├── chunk-common.js
│   ├── chunk-common.js.map
│   ├── chunk-vendors.js
│   ├── chunk-vendors.js.map
│   ├── singleDashboard.js
│   ├── singleDashboard.js.map
│   ├── spa.js
│   └── spa.js.map
├── singleDashboard.html
└── spa.html

NOTE: the build process also outputs unneeded html that can probably be omitted from the build process

Add the common js/css to MVC _Layout.cshtml:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - DotnetVues.Web</title>

    <!-- Because the version handled by ASP.Net Core, this should be for all environments -->
    <environment exclude="Development,Staging,Production">
        <link rel="stylesheet" href="~/css/chunk-common.css" />
    </environment>
    @RenderSection("Styles", required: false)
</head>
<body>
    <header>
        <!-- Change the navbar to ba buefy-styled one -->
        <nav class="navbar" role="navigation" aria-label="main navigation">
        <div class="navbar-brand">
            <a class="navbar-item brand-text" href="/">DotnetVues</a>
            <span aria-hidden="true"></span>
            <span aria-hidden="true"></span>
            <span aria-hidden="true"></span>
        </div>
        <div class="navbar-menu">
            <div class="navbar-end">
                <a asp-controller="SingleDashboard"
                    asp-action="Index" class="navbar-item">SingleDashboard</a>
                <a asp-controller="Spa"
                    asp-action="Index" class="navbar-item">Spa</a>
            </div>
        </div>
        </nav>
    </header>
    <div>
        <partial name="_CookieConsentPartial" />
        <main role="main" class="section">
            @RenderBody()
        </main>
    </div>

    <footer class="footer">
        <div class="container">
            &copy; 2019 - DotnetVues.Web - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
        </div>
    </footer>

    <!-- Add the common js portions here -->
    <environment include="Development,Staging,Production">
        <script src="~/js/chunk-common.js"></script>
        <script src="~/js/chunk-vendors.js"></script>
    </environment>
    @RenderSection("Scripts", required: false)
</body>
</html>

Add routes/views to MVC:

In the navbar there are two controllers that don’t acutally exist (SingleDashboard and Spa) so we will need to add them. MVC has a directory structure to adhere to, which defaults to:

.
├── Controllers
│   └── MyViewA.cs // has an action of Index
└── Views
    └── MyViewA
        └── Index.cshtml

Add the Controllers/SingleDashboard.cs route:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using DotnetVues.Web.Models;

namespace DotnetVues.Web.Controllers
{
    public class SingleDashboardController : Controller
    {
        public IActionResult Index()
        {
            return View();
        }
    }
}

Add the Views/SingleDashboard/Index.cshtml view:

@{
    ViewData["Title"] = "Vue-based SPA-less single dasboard";
}

@section scripts {
    <environment include="Development,Production,Staging">
        <script src="~/js/singleDashboard.js" asp-append-version="true"></script>
    </environment>
}

<div id="singleDashboard"></div>

Add the Controllers/Spa.cs Route:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using DotnetVues.Web.Models;

namespace DotnetVues.Web.Controllers
{
    public class SpaController : Controller
    {
        public IActionResult Index()
        {
            return View();
        }
    }
}

Add the Views/Spa/Index.cshtml View:

@{
    ViewData["Title"] = "Vue SPA";
}

@section scripts {
    <environment include="Development,Production,Staging">
        <script src="~/js/spa.js" asp-append-version="true"></script>
    </environment>
}

@section styles {
    <environment include="Development,Production,Staging">
        <script src="~/css/spa.css" asp-append-version="true"></script>
    </environment>
}

<div id="spa"></div>

NOTE: code-chunking is applied to css as well, creating a need to manage all assets per app

Navigating around we can see the singleDashboard URI loads the app and the SPA URI loads the app with routing to Home and About.

What happens when we enter the spa route directly though? It 404’s with a blank page, yikes. For requests to the /spa url only, we need to provide a fallback routing in MVC be compatible with vue-router’s history mode.

MVC’s routing rules are located in Startup.cs under public void Configure(IApplicationBuilder app, IHostingEnvironment env) method inside of the app.UseMvc:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    else
    {
        app.UseExceptionHandler("/Home/Error");
        // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
        app.UseHsts();
    }

    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.UseCookiePolicy();

    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
      
        // Whenever a request to the Spa Controller doesnt match a defined Action, return the Index.cshtml and let the front-end handle it
        routes.MapRoute(
            name: "spa-route",
            template: "{controller=Spa}/{*anything=Index}",
            defaults: new { action = "Index" });
    });
}

Debug again and the MVC Router returns index for unknown routes for only the SpaController.

Conclusion

While this is certainly not exhaustive, here are some more things I would like to explore more deeply:

  • Support development builds - VueDevTools can then be enabled
  • Setup Reverse proxy for Vue - route calls to MVC instance (local or hosted) during yarn serve
  • Disable CSS Code-chunking - the amount of custom css should be minimal enough to not justify chunking the code, also avoiding a maintenance nightmare
  • Create a MVC View Component (or tag-helper?) to help cut down on boiler code for the Razor Views

The finished product will look like:

.
├── DotnetVues.sln
├── client-app // Vue App
│   ├── README.md
│   ├── babel.config.js
│   ├── jest.config.js
│   ├── package.json
│   ├── postcss.config.js
│   ├── public
│   │   ├── favicon.ico
│   │   └── index.html
│   ├── src
│   │   ├── assets
│   │   │   ├── logo.png
│   │   │   └── scss
│   │   │       ├── _variables.scss
│   │   │       └── app.scss
│   │   ├── components
│   │   │   └── HelloWorld.vue
│   │   ├── pages
│   │   │   ├── singleDashboard
│   │   │   │   ├── App.vue
│   │   │   │   └── main.ts
│   │   │   └── spa
│   │   │       ├── App.vue
│   │   │       ├── main.ts
│   │   │       ├── router.ts
│   │   │       └── views
│   │   │           ├── About.vue
│   │   │           └── Home.vue
│   │   ├── shims-tsx.d.ts
│   │   └── shims-vue.d.ts
│   ├── tests
│   │   └── unit
│   │       └── example.spec.ts
│   ├── tsconfig.json
│   ├── vue.config.js
│   └── yarn.lock
└── src // ASP.Net Core MVC Project
    └── DotnetVue.Web
        ├── Controllers
        │   ├── HomeController.cs
        │   ├── SingleDashboard.cs
        │   └── Spa.cs
        ├── DotnetVues.Web.csproj
        ├── Models
        │   └── ErrorViewModel.cs
        ├── Program.cs
        ├── Properties
        │   └── launchSettings.json
        ├── Startup.cs
        ├── Views
        │   ├── Home
        │   │   ├── Index.cshtml
        │   │   └── Privacy.cshtml
        │   ├── Shared
        │   │   ├── Error.cshtml
        │   │   ├── _CookieConsentPartial.cshtml
        │   │   ├── _Layout.cshtml
        │   │   └── _ValidationScriptsPartial.cshtml
        │   ├── SingleDashboard
        │   │   └── Index.cshtml
        │   ├── Spa
        │   │   └── Index.cshtml
        │   ├── _ViewImports.cshtml
        │   └── _ViewStart.cshtml
        ├── appsettings.Development.json
        ├── appsettings.json
        └── wwwroot
            ├── css
            │   ├── chunk-common.css
            │   └── spa.css
            ├── favicon.ico
            ├── img
            │   └── logo.png
            ├── index.html
            ├── js
            │   ├── chunk-2d0b23f4.js
            │   ├── chunk-2d0b23f4.js.map
            │   ├── chunk-common.js
            │   ├── chunk-common.js.map
            │   ├── chunk-vendors.js
            │   ├── chunk-vendors.js.map
            │   ├── singleDashboard.js
            │   ├── singleDashboard.js.map
            │   ├── spa.js
            │   └── spa.js.map
            ├── singleDashboard.html
            └── spa.html