Stu Ratcliffe

Vue.js server-side rendering with ASP.NET Core


Steve Sanderson has previously blogged about how his goal is to make ASP.NET Core the best backend choice for single-page applications (SPAs) built with frameworks such as Angular and React.

As part of this goal, Microsoft have released a number of visual studio / yeoman project templates to make scaffolding these applications a breeze. Steve's article above contains everything you'll need to get you started with installing and using these templates, so I won't go into it here. However, the template for using Vue.js as the SPA framework is somewhat lacking. Unlike the Angular or React + Redux templates, there is no server-side rendering support included, leaving you to figure out the webpack configuration on your own. Hopefully this article will solve that problem!

A number of other articles have already been written on a similar topic, specifically here and here. Mihaly's post is brilliant, and I've used a lot of his findings as the foundations for this article and sample application that goes with it, so full credit to him for that! The issue I had with the solution provided was that it did not include client-side routing, and when adding VueRouter into the mix, it flagged up a problem with the way the initial vuex store state was being populated.

Mihaly's sample application is still running on an older version of .NET Core, using project.json files rather than the newer (older) .csproj files. Therefore, rather than take his solution as a starting point, we'll create a fresh project using the latest tooling.

Project Setup

Assuming you have visual studio 2017 and/or the latest version of the dotnet CLI, drop into a command prompt and run:

dotnet new mvc

at the location you wish to create our application. You should receive some output similar to the following:

Content generation time: 447.7971 ms
The template "ASP.NET Core Web App" created successfully.

I'm using VS Code due to the excellent tooling available for Vue.js development, so from my command prompt I can simply type code . to open my new project in VS Code.

Still in the command prompt, run the following to ensure everything is setup and running OK for now:

dotnet restore
dotnet run

If you're greeted by the boilerplate MVC application at http://localhost:5000 then we are good to continue!

So first of all we can clear out a lot of boilerplate files. In Controllers/HomeController.cs we can remove out all actions except the Index action. Your controller should look like the following:

public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();
    }
}

Next, we can remove Views/Shared, Views/_ViewStart.cshtml and everything except the Index view from View/Home. We can also delete everything from the wwwroot folder. Finally, we can delete the .bowerrc, bower.json and bundleconfig.json files from the project root folder.

This now leaves us with a bare bones project structure ready for our client side Vue code to be added. As we will be using npm and webpack for all client assets and bundling, we didn't need the default files created by the dotnet CLI. We also only need a single controller action to serve our client application, from then on we will use client side routing to handle the different pages of our application.

Adding a Vue.js application

To get started with our client side code, we need a package.json file to inform npm which modules our application depends on. This file lives at the root of our project, and it's contents are as follows:

{
  "version": "1.0.0",
  "name": "VueDotnetSSR",
  "private": true,
  "dependencies": {
    "aspnet-prerendering": "^1.0.4",
    "axios": "^0.14.0",
    "lodash": "^4.15.0",
    "nprogress": "^0.2.0",
    "vue": "^2.0.3",
    "vue-router": "^2.7.0",
    "vue-server-renderer": "^2.0.3",
    "vuex": "^2.0.0"
  },
  "devDependencies": {
    "aspnet-webpack": "^2.0.1",
    "babel-core": "^6.25.0",
    "babel-loader": "^6.2.5",
    "babel-preset-es2015": "^6.13.2",
    "babel-preset-stage-2": "^6.13.0",
    "css-loader": "^0.23.1",
    "json-loader": "^0.5.4",
    "style-loader": "^0.18.2",
    "vue-loader": "^9.7.0",
    "webpack": "^3.3.0",
    "webpack-hot-middleware": "^2.18.2",
    "webpack-merge": "^4.1.0"
  }
}

I'm not going to explain what all of these are for, as it would take far too long and this article is going to be long enough as it is! Ultimately, we have "devDependencies" which are all for configuring webpack to bundle our client side code for us. Under "dependencies" we have the Vue libraries we will need, a universal HTTP library, and some helper libraries for enabling server-side rendering (SSR) in our application.

With this file in place, drop back into the command prompt and run npm install. Once this has finished, we'll create our webpack.config.js file, which again lives at the root of our application, and instructs webpack how to bundle our client code. For now, the contents will look like this:

var path = require('path')

module.exports = {
  entry: { 'main-client': './ClientApp/app.js' },
  output: {
    path: path.resolve(__dirname, './wwwroot/dist'),
    publicPath: '/dist/',
    filename: 'main-client.js'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      }
    ]
  }
}

Again, this isn't a post about webpack, so the short story of this file is that we're telling webpack where the entry point of our code is, how we want to deal with different files types, and where we want the output to be placed. Our entry point does not yet exist, so lets go ahead and add it in! Create a folder at the root of the project called "ClientApp" and then create a ClientApp/app.js file within:

import Vue from 'vue';
import App from './components/App.vue';

const app = new Vue({
    ...App
});

app.$mount('#app');

In here we configure our Vue instance, and mount it to an element within the dom with an ID of "app". We'll get to that in a minute, but for now we're trying to import a component that doesn't exist. Lets create a folder and component file as follows: ClientApp/components/App.vue. This file will have the following contents:

<template>
    <div>
        <h1>Hello from Vue!</h1>
    </div>
</template>

If you aren't familiar with single file vue components, please go and read the vue docs - they are very good and explain a lot of the things I'm talking about here much better than I ever could!

The only thing left to do for now is update our Views/Home/Index.cshtml view to contain the div element we've told Vue to mount to, and a script reference to our javascript bundle that webpack is going to build for us. Change the contents of Views/Home/Index.cshtml to the following:

<div id="app"></div>
<script src="~/dist/main-client.js" asp-append-version="true"></script>

The script tag is pointing at another non-existent file at the minute, as webpack hasn't been told to do anything! Drop back into your command prompt, ensure you are at the root of the application and run webpack.

If you receive this error:

'webpack' is not recognized as an internal or external command,
operable program or batch file.

It's because you haven't got webpack installed globally on your machine. Run the following command: npm install -g webpack followed by our webpack command again, and you should see.......another error! This time it's complaining about the ...App line in our ClientApp/app.js file. This is an ES6 feature that browsers simply cannot understand. In order to use these features we are using an npm package called babel (installed earlier when we setup the package.json file) to transpile next generation javascript code down into a format that browsers can read and understand.

To fix this issue, add another file to the root of the project, this time called .babelrc with contents as follows:

{
    "presets": ["es2015", "stage-2"]
}

With this file in place, we are finally ready to run webpack for real. You should see some output similar to the following:

Hash: 0864c4c5c0fa858ef6e8
Version: webpack 3.3.0
Time: 999ms
         Asset    Size  Chunks             Chunk Names
main-client.js  205 kB       0  [emitted]  main
   [0] ./ClientApp/app.js 608 bytes {0} [built]
   [3] (webpack)/buildin/global.js 509 bytes {0} [built]
    + 4 hidden modules

This has now generated our wwwroot/dist/main-client.js file that we referenced from within our Views/Home/Index.cshtml file earlier. With this in place we can now run our application again and see it working! Run dotnet run from the command prompt, open a browser at http://localhost:5000 and you should see our Vue app running!

Hot Module Replacement

So we have a functioning Vue application being served by ASP.NET Core. However, if we make changes to our client app code, we have to manually call webpack to recreate our bundle, then force refresh the browser to pick up the changes. We can do better!

Open up your projects .csproj file, mines called VueDotnetSSR.csproj, and add the following line to the <ItemGroup> section:

<PackageReference Include="Microsoft.AspNetCore.SpaServices" Version="1.1.1" />

Again, if you are using VS Code it will prompt you to see if you want it to restore the new package, but if not you can always run dotnet restore from the command prompt to achieve the same result.

With this package in place, we can make some modifications to our Startup.cs file to enable the ASP.NET Core WebpackDevMiddleware with HMR enabled.

Add a using statement to the top of Startup.cs:

using Microsoft.AspNetCore.SpaServices.Webpack;

Then update the Configure method to look like this:

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
    loggerFactory.AddConsole(Configuration.GetSection("Logging"));
    loggerFactory.AddDebug();

    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseWebpackDevMiddleware(new WebpackDevMiddlewareOptions
        {
            HotModuleReplacement = true
        });
    }

    app.UseStaticFiles();

    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
    });
}

The WebpackDevMiddleware looks for a webpack.config.js file and automatically executes it for you, as well as watching for changes within the scope of this file, and automatically updates the client bundle on the fly, pushing changes to the browser almost instantly.

We can test this out by deleting the wwwroot/dist folder, then running our application again with dotnet run. If everything is configured correctly, in addition to the usual message you see when running the application, you should see something similar to the following in your command prompt:

info: Microsoft.AspNetCore.NodeServices[0]
      webpack built 964374ecbd77be5f875b in 1232ms

You'll also notice that our wwwroot/dist folder is now back, and contains a new bundle file. Browse to http://localhost:5000 and our app should be running exactly the same as before. However, if we make a change to the heading in our ClientApp/components/App.vue file, you will see the browser displays the updates almost instantly, without the need for refreshing!

Working with Data

So far we've just got a simple component with some text in it, so lets fetch some data from an API and display it on the page.

First off we'll create the API's to display this data. This sample is pretty much directly taken from Mihaly's post as that was what I was using to get me started!

We need to add to actions to Controllers/HomeController.cs, which should then look like this:

public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();
    }

    [Route("initialMessages")]
    public JsonResult InitialMessages()
    {
        var initialMessages = FakeMessageStore.FakeMessages.OrderByDescending(m => m.Date).Take(2);

        var initialValues = new ClientState()
        {
            Messages = initialMessages,
            LastFetchedMessageDate = initialMessages.Last().Date
        };

        return Json(initialValues);
    }

    [Route("fetchMessages")]
    public JsonResult FetchMessages(DateTime lastFetchedMessageDate)
    {
        return Json(FakeMessageStore.FakeMessages.OrderByDescending(m => m.Date).SkipWhile(m => m.Date >= lastFetchedMessageDate).Take(1));
    }
}

Don't try and run this yet as there are some missing classes in here that we need to create. Namely Models/ClientState.cs, Models/FakeMessageStore.cs and Models/Message.cs. We'll create those now, and this is what they should look like:

public class ClientState
{
    [JsonProperty(PropertyName = "messages")]
    public IEnumerable<Message> Messages { get; set; }

    [JsonProperty(PropertyName = "lastFetchedMessageDate")]
    public DateTime LastFetchedMessageDate { get; set; }
}
public class FakeMessageStore
{
    private static DateTime startDateTime = new DateTime(2016, 08, 28);

    public static readonly List<Message> FakeMessages = new List<Message>()
    {
        Message.CreateMessage("First message title", "First message text", startDateTime),
        Message.CreateMessage("Second message title", "Second message text", startDateTime.AddDays(1)),
        Message.CreateMessage("Third message title", "Third message text", startDateTime.AddDays(2)),
        Message.CreateMessage("Fourth message title", "Fourth message text", startDateTime.AddDays(3)),
        Message.CreateMessage("Fifth message title", "Fifth message text", startDateTime.AddDays(4)),
        Message.CreateMessage("Sixth message title", "Sixth message text", startDateTime.AddDays(5)),
    };
}
public class Message
{
    [JsonProperty(PropertyName = "date")]
    public DateTime Date { get; set; }

    [JsonProperty(PropertyName = "title")]
    public string Title { get; set; }

    [JsonProperty(PropertyName = "text")]
    public string Text { get; set; }

    private Message() { }

    public static Message CreateMessage(string title, string text, DateTime date)
    {
        return new Message()
        {
            Title = title,
            Text = text,
            Date = date
        };
    }
}

With these new files and controller changes, you can test the API's by running the application and navigating to http://localhost:5000/initialMessages. If you see some JSON data then all is working well.

Now we can fetch some data, let's display it inside our ClientApp/components.App.vue component. We first need a way of fetching and storing this data for use within the component. We are going to use a library called Vuex, which is an official plugin for Vue for managing application data and state. That's all I'm going to say about Vuex, if the concepts are new to you then please read the docs

We need to add a Vuex store to our application. Create the following folder / file - ClientApp/vuex/store.js with contents that look like:

import Vue from 'vue'
import Vuex from 'vuex'
import { fetchInitialMessages, fetchMessages } from './actions'
import minBy from 'lodash/minBy';

Vue.use(Vuex)

const store = new Vuex.Store({
    state: { messages: [], lastFetchedMessageDate: -1 },

    mutations: {
        INITIAL_MESSAGES: (state, payload) => {
            state.messages = payload.messages;
            state.lastFetchedMessageDate = payload.lastFetchedMessageDate;
        },
        FETCH_MESSAGES: (state, payload) => {
            state.messages = state.messages.concat(payload);
            state.lastFetchedMessageDate = minBy(state.messages, 'date').date;
        }
    },

    actions: {
        fetchInitialMessages,
        fetchMessages
    },

    getters: {
        messages: state => state.messages,
        lastFetchedMessageDate: state => state.lastFetchedMessageDate
    }
});

export default store;

In addition to this file, we need an ClientApp/vuex/actions.js file which is going to hold the actions which fetch data from our server, and then commit the mutations that store that data within our Vuex store.

import axios from 'axios'

export const fetchInitialMessages = ({ commit }) => {
    axios.get('initialMessages').then(response => {
        commit("INITIAL_MESSAGES", response.data);
    }).catch(err => {
        console.log(err);
    });
}

export const fetchMessages = ({ commit }, lastFetchedMessageDate) => {
    axios.get('/fetchMessages?lastFetchedMessageDate=' + lastFetchedMessageDate).then(response => {
        commit("FETCH_MESSAGES", response.data);
    }).catch(err => {
        console.log(err);
    });
}

We now need to tell our Vue application to use this new store. Modify ClientApp/app.js as follows:

import Vue from 'vue';
import App from './components/App.vue';
import store from './vuex/store.js';

const app = new Vue({
    store,
    ...App
});

app.$mount('#app');

We can now update our ClientApp/components/App.vue component to dispatch an action from the store to load some data from the server, and then display it on the page.

<template>
    <div>
        <h1>Hello from Vue!</h1>
        <Message v-for="(msg, index) in messages" :message="msg" :key="index" />
        <button @click="fetchMessages(lastFetchedMessageDate)">Fetch a message</button>
    </div>
</template>

<script>
import { mapGetters, mapActions } from 'vuex';
import Message from './Message.vue';

export default {
    components: { Message },
    computed: mapGetters(['messages', 'lastFetchedMessageDate']),
    methods: mapActions(['fetchMessages']),
    created () {
      return this.$store.dispatch('fetchInitialMessages')
    }
}
</script>

If you've still got the app running, you'll notice errors showing up in the browser now, because we're referencing a component that does not yet exist. We need to create our ClientApp/components/Message.vue component file:

<template>
    <div>
        <h2>{{ message.title }}</h2>
        <p>{{ message.text }}</p>
    </div>
</template>

<script>
    export default {
        props: ['message']
    }
</script>

When you add this file, the errors in the browser should disappear. It's worth noting at this point that webpack and HMR does a good job of showing your changes in real-time in the browser, but I've found that when modifying files such as the vuex store files, I've had to do a full browser refresh before new actions and such are available to the components.

With these changes, you should be able to run the application, and see that as soon as the page loads, our vue application makes an AJAX call to our .NET API and fetches the initial data that we setup earlier. On subsequent button clicks, a new record is fetched and appended to the list of messages in the UI.

Client-side Routing

We currently only have a single 'page' in our SPA. This is the feature that pushed me to move away from Mihaly's solution due to the way he was providing the initial data to the application on page load. Rather than letting the Vue components themselves trigger AJAX calls to fetch the data they need, he was providing it as a model to the razor view, and then using one of the tag helpers from the Microsoft.AspNetCore.SpaServices package to pass that model into a bundle renderer to populate the application state on the server side.

This cannot work with a full SPA with client side routing, as the same initial state would be sent to all 'pages', regardless of whether they need it or not. We'll get to the SSR part shortly, but for now we're just going to add the VueRouter official plugin, create another page, and setup our client side routing.

Create a ClientApp/components/Dashboard.vue file, and add the following contents:

<template>
  <h1>Dashboard</h1>  
</template>

We're now going to move the list of messages out of the app component, and into a new ClientApp/components/Messages.vue component instead:

<template>
    <div>
        <Message v-for="(msg, index) in messages" :message="msg" :key="index" />
        <button @click="fetchMessages(lastFetchedMessageDate)">Fetch a message</button>
    </div>
</template>

<script>
import { mapGetters, mapActions } from 'vuex';
import Message from './Message.vue';

export default {
    components: { Message },
    computed: mapGetters(['messages', 'lastFetchedMessageDate']),
    methods: mapActions(['fetchMessages']),
    created () {
      return this.$store.dispatch('fetchInitialMessages')
    }
}
</script>

We can now modify our ClientApp/components/App.vue component to use VueRouter to display a different component depending on the route we're on, and some router links to navigate between pages:

<template>
    <div>
        <h1>Hello from Vue!</h1>
        
        <router-link to="/">Dashboard</router-link>
        <router-link to="/messages">Messages</router-link>

        <router-view></router-view>
    </div>
</template>

The final step is to create our router file at ClientApp/router/index.js:

import Vue from 'vue';
import VueRouter from 'vue-router';

import Dashboard from '../components/Dashboard.vue';
import Messages from '../components/Messages.vue';

Vue.use(VueRouter);

const router = new VueRouter({
  mode: 'history',
  routes: [
    { path: '/', component: Dashboard },
    { path: '/messages', component: Messages },
  ]
});

export default router;

This file is where we define all of the routes in our application, and configure which component should be the base component for that route. The final step is to tell ClientApp/app.js about our router:

import Vue from 'vue';
import App from './components/App.vue';
import store from './vuex/store.js';
import router from './router';

const app = new Vue({
    router,
    store,
    ...App
});

app.$mount('#app');

If you're app is still running, you'll need to force refresh the browser to pick up the routing changes. If not, dotnet run now and you should have a working client routed app.

The only thing that's currently missing, is being able to navigate directly to a client side route. What I mean by this, is if you are browsing the messages page at http://localhost:5000/messages and perform a full browser refresh, you'll notice you get a blank white screen. This is because the .NET core web server can't find a matching route. We need to provide what we call a fallback route so that we can hit our client side routes without starting off at the main dashboard page.

In Startup.cs add the following line within the `app.UseMvc(routes => { ... }); section:

routes.MapSpaFallbackRoute("spa-fallback", new { controller = "Home", action = "Index" }); 

Restart the application now, and you should be able to navigate directly to http://localhost:5000/messages!

Server-Side Rendering

This is where it gets complicated! If you look closely, you'll notice that there is a flicker as the page loads and the javascript kicks in to render our application. This is hard to notice when running locally, but you'll notice it far more when running on a remote web server. The list of messages is easier to spot, because the javascript has to load to render the component, which then triggers the AJAX call to load the list of messages before they can be displayed.

This isn't the best user experience, but there are things that can be done to improve it. For example, loading spinners can be used while AJAX calls are in progress, but this won't fix the initial flicker of the page rendering client side. This is where SSR fits in.

There are a number of changes that we need to make in order to get our app ready to be rendered on the server. Our webpack configuration needs a drastic overhaul, which makes it far more complicated than the simple config we have currently. We also need to find a way of populating our vuex store state on the server. This is a lot trickier than it sounds.

First of all, we're going to tell Vue how to boot our application on both the client and server. We'll create two files: ClientApp/client.js and ClientApp/server.js:

//ClientApp/client.js
import { app, router, store } from './app';

store.replaceState(__INITIAL_STATE__);

router.onReady(() => {
 router.beforeResolve((to, from, next) => {
   const matched = router.getMatchedComponents(to)
   const prevMatched = router.getMatchedComponents(from)

   let diffed = false
   const activated = matched.filter((c, i) => {
     return diffed || (diffed = (prevMatched[i] !== c))
   })

   if (!activated.length) {
     return next()
   }

   Promise.all(activated.map(c => {
     if (c.asyncData) {
       return c.asyncData({ store, route: to })
     }
   })).then(() => {
     next()
   }).catch(next)
 })

 app.$mount('#app')
})
//ClientApp/server.js
import { app, router, store } from './app';

export default context => {
  return new Promise((resolve, reject) => {
    router.push(context.url);

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          return Component.asyncData({
            store
          });
        }
      })).then(() => {
        context.state = store.state;
        resolve(app);
      }).catch(reject)
    }, reject)
  })
};

There's a lot going on in these files, but the gist of it is that our ClientApp/client.js is going to be the new entry point for our client side webpack configuration, and as such takes responsibility away from ClientApp/app.js to actually mount our application to the DOM. It looks for a global object called __INITIAL_STATE__ and uses it to hydrate the client side vuex store with the data that has already been fetched during the server side render. Finally, it hooks into the VueRouter to see if any of the components linked to the route being rendered have a function named asyncData. If they do, it invokes these functions, but only if they haven't already been invoked during a server render.

The ClientApp/server.js file is going to become the entry point for our server side webpack configuration. It has a similar setup to the client file, in that it hooks into the router and looks for a function called asyncData on the components and invokes it in order to populate the store on the server side. This is what enables us to achieve a fully server rendered application. This file will be invoked by a node script that uses an npm package for server rendering vue applications. This file is our bundle renderer - ClientApp/renderOnServer.js:

process.env.VUE_ENV = 'server';

const fs = require('fs');
const path = require('path');

const filePath = path.join(__dirname, '../wwwroot/dist/main-server.js')
const code = fs.readFileSync(filePath, 'utf8');

const bundleRenderer = require('vue-server-renderer').createBundleRenderer(code);

module.exports = function (params) {
    return new Promise(function (resolve, reject) {
      const context = { url: params.url };  

      bundleRenderer.renderToString(context, (err, resultHtml) => {
          if (err) {
              reject(err.message);
          }
          resolve({
              html: resultHtml,
              globals: {
                  __INITIAL_STATE__: context.state
              }
          });
      });
        
    });
};

With these new files in place, we need to let webpack know about them! Our updated webpack.config.js file is now a lot more complicated:

const path = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');

module.exports = (env) => {
    const isDevBuild = !(env && env.prod);

    const sharedConfig = () => ({
        stats: { modules: false },
        resolve: { extensions: ['.js', '.vue'] },
        output: {
            filename: '[name].js',
            publicPath: '/dist/'
        },
        module: {
            rules: [
                {
                    test: /\.vue$/,
                    loader: 'vue-loader',
                },
                {
                    test: /\.js$/,
                    loader: 'babel-loader',
                    include: __dirname,
                    exclude: /node_modules/
                },
                { 
                    test: /\.css$/, 
                    loader: "style-loader!css-loader" 
                }
            ]
        }
    });

    const clientBundleOutputDir = './wwwroot/dist';
    const clientBundleConfig = merge(sharedConfig(), {
        entry: { 'main-client': './ClientApp/client.js' },
        output: {
            path: path.join(__dirname, clientBundleOutputDir)
        }
    });

    const serverBundleConfig = merge(sharedConfig(), {
        target: 'node',
        entry: { 'main-server': './ClientApp/server.js' },
        output: {
            libraryTarget: 'commonjs2',
            path: path.join(__dirname, 'wwwroot/dist')
        },
        module: {
            rules: [
                {
                    test: /\.json?$/,
                    loader: 'json-loader'
                }
            ]
        },
    });

    return [clientBundleConfig, serverBundleConfig];
}

Again, this isn't a post on webpack, so I'll be brief. We now have client / server specific configs in this file, each of which uses the previously mentioned new files as their entry points. I used the official React + Redux application template as a base, so I'm hoping it should be fairly solid, as I'm by no means a webpack expert!

We also need to modify our ClientApp/app.js file to stop it trying to mount the application to the DOM. Instead, we just export the app, store and router modules to be used within the client / server files:

import Vue from 'vue';
import App from './components/App.vue';
import store from './vuex/store.js';
import router from './router';

const app = new Vue({
    router,
    store,
    ...App
});
  
export { app, router, store };

Finally, we need to modify Views/Home/Index.cshtml to actually tell ASP.NET that we want to server render our application, and call our bundle renderer module:

<body>
    <div id="app" asp-prerender-module="ClientApp/renderOnServer"></div>
    <script src="~/dist/main-client.js" asp-append-version="true"></script>
</body>

The important part is asp-prerender-module="ClientApp/renderOnServer">. This is where we tell the SpaServices package to call our bundle renderer module that we just created, thus triggering our server rending. Before this will work though we need to tell the application what to do with the tag helper we've just used. Add the following line to Views/_ViewImports.cshtml:

@addTagHelper "*, Microsoft.AspNetCore.SpaServices"

We've told our new client / server files to look for component functions called asyncData for fetching our initial page data. We are currently triggering the AJAX calls from with the created() lifecycle hook within Vue, which isn't available to us when rendering on the server.

Update the ClientApp/components/Messages.vue component to use an asyncData method for fetching the initial message list:

<template>
    <div>
        <Message v-for="(msg, index) in messages" :message="msg" :key="index" />
        <button @click="fetchMessages(lastFetchedMessageDate)">Fetch a message</button>
    </div>
</template>

<script>
import { mapGetters, mapActions } from 'vuex';
import Message from './Message.vue';

export default {
    components: { Message },
    computed: mapGetters(['messages', 'lastFetchedMessageDate']),
    methods: mapActions(['fetchMessages']),
    asyncData ({ store }) {
      return store.dispatch('fetchInitialMessages')
    }
}
</script>

Restarting and running the application now will be a fairly disappointing task. The page should load, and display the button to fetch new messages, but we don't have our initial list at all anymore. It took me a while to track this one down, but it boils down to our vuex store actions, and the way we trigger the AJAX calls using the npm module axios.

In ClientApp/vuex/actions.js we have a fetchInitialMessages action. As previously mentioned we use axios to perform the AJAX requests, which is a universal (i.e. runs on both client/server) HTTP module. The caveat is that in the browser, axios can automatically fill in the host section of the URL, so performing a get request to /initialMessages works just fine. However, on the server, it has no way of knowing what the host portion should be unless we tell it! Modify this action to be as follows:

export const fetchInitialMessages = ({ commit }) => {
    return axios.get('http://localhost:5000/initialMessages').then(response => {
        commit("INITIAL_MESSAGES", response.data);
    }).catch(err => {
        console.log(err);
    });
}

It's also worth mentioning that we had to add the 'return' on to return axios.get(...); to return a promise that we can wait for on the server side, or the application will continue without the store data and you'll receive an empty list again on page load.

With these changes, we now have a fully server rendered application! You can test this out using either the chrome developer tools, or a tool such as postman. If you load the http://localhost:5000/messages page directly, you'll notice that the initial messages are part of the HTML returned from the server, and no further AJAX calls were made to retrieve them. However, if you load the dashboard page initially, then switch to the messages page, an AJAX call is triggered to fetch the initial messages before the messages page can be displayed. This is what we configured in the ClientApp/client.js file, by using the VueRouter hooks to wait for this data load to happen before it will allow the new route to be rendered.

Loading indicators

As previously mentioned, when switching between pages, we've configured the application to wait for any asyncData calls to complete before moving to the next page. If this API call takes a while, we're left with the user not really knowing if anything is happening.

To combat this, we're going to use an npm module called NProgress. We've already installed it as we added it to the package.json file right at the start, so all we need to do is hook into it!

In ClientApp/client.js we'll add the following import statements to the top of the file:

import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

We can now call NProgress.start() and NProgress.done() at the appropriate points to show our loading progress during page changes. The updated ClientApp/client.js file should now look like this:

import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { app, router, store } from './app';

store.replaceState(__INITIAL_STATE__);

router.onReady(() => {
 router.beforeResolve((to, from, next) => {
   const matched = router.getMatchedComponents(to)
   const prevMatched = router.getMatchedComponents(from)

   let diffed = false
   const activated = matched.filter((c, i) => {
     return diffed || (diffed = (prevMatched[i] !== c))
   })

   if (!activated.length) {
     return next()
   }

   NProgress.start();

   Promise.all(activated.map(c => {
     if (c.asyncData) {
       return c.asyncData({ store, route: to })
     }
   })).then(() => {
     NProgress.done();
     next()
   }).catch(next)
 })

 app.$mount('#app')
})

And with that, our application is complete!

Conclusion

As usual, this post has ended up being a hell of a lot longer than originally intended. However, there's a lot going on, and my personal opinion is that it's good for a reader to be able to start reading the article with nothing, and end up with a working product at the end, without needing to go and download any source code if they don't want to.

However, I know a lot of people would rather just see snippets of the important sections and then a download link for the working source code. Let me know in the comments if this is you!

We've looked at integrating a basic Vue.js application with an ASP.NET Core backend. We started off with a simple application with only one 'page', and gradually added features such as hot module replacement, data loading and rendering, client side routing, server side rendering, and finally a progress indicator for page changes.

The code for this article can be found at my github repository.

Discussion

  • Jaxel
    Jaxel
    25 July 2017

    Great and thorough article, Thanks for the effort!!! Just wanted to point out that the Hot Module Replacement part doesnt seem to be working though. I even downloaded the github repo and tested it to see if it was something diff on my version.

  • Stu Ratcliffe
    Stu Ratcliffe
    26 July 2017

    Hi Jaxel, Thank you for the positive feedback, really appreciate it! The HMR is working well for me, modifying the Vue components triggers the updates in the browser almost instantly. This is from cloning the sample code down on to my work laptop to try and test that it's not something I've got installed on my machine at home but forgotten to include in the code. The only thing I had to do differently was configure the ASPNETCORE_ENVIRONMENT variable, and set it to "Development". If the code runs in any other mode, the HMR middleware will not run, and you won't see the 'Webpack built' message in the console. My only other suggestion is checking your versions of the dotnet-cli, NPM and Node! On this work machine I'm currently running dotnet version 1.0.4, NPM version 5.0.4 and Node version 6.9.4. Hope this helps!

  • Jaxel
    Jaxel
    13 August 2017

    Yep that did the trick, running by default from the console will point to the prod environment. Thanks!

  • Erik K
    Erik K
    29 August 2017

    Great article! What do I need to run this project in production? I have tried to run "dotnet publish" and started the .exe file, but I get errors like "Error: Cannot find module 'aspnet-prerendering'" Any suggestions?

  • Sumeet Gohil
    Sumeet Gohil
    08 September 2017

    I am facing undefined error: Uncaught ReferenceError: __INITIAL_STATE__ is not defined store.replaceState(__INITIAL_STATE__); context.state --> undefined. resolve({ html: resultHtml, globals: { __INITIAL_STATE__: context.state } }); What is wrong here ?

  • Amilkar Reyna
    Amilkar Reyna
    08 October 2017

    Very nice article!

  • Riz
    Riz
    10 October 2017

    Another main benefit of SSR is SEO.

  • Göksel Uyulmaz
    Göksel Uyulmaz
    11 October 2017

    Stu Thanks for this great blog. Do you think asp .net core 2 as backend + vuejs as frontend, is ready for real world production grade, mid/big grade projects? I mean rest api, authentication, authorization/identity, SSR(Prerendering), http priming, debugging, testing, node services, db access/ORM .... all the backend related stuff which can be all done now for example with laravel, express, feather.js, can be done fully without obstacles/problems, or exact opposite more easily with asp .net core? can we get now/near future enough microsoft/community support like, bug fixing,code samples, guidance, blogs, books.... for asp .net core backend( + javascript frontend), to learn real world needed stuffs/skills easily/comprehensively?

  • Riz
    Riz
    12 October 2017

    What's the best way to automatically replace the http://localhost:5000/ part of the initialMessages action with the actual site domain when deploying to production?

  • Shawn
    Shawn
    23 October 2017

    Firstly i have to say your article well written, i followed the instructions till the end and was trying to build on top of it, first i tried was to implement lazy routes (https://router.vuejs.org/en/advanced/lazy-loading.html) as mentioned in the guide. but i found that vue doesn't work correctly with server side rendering and lazy routes. I was wondering if you had tried the same and if it was possible.

  • Karol
    Karol
    23 November 2017

    Great example, works fine in dev environment. But when I try to run it in IIS, page doesn't open. I am getting 404. All the other pages (without vue ssr) work normally. I installed node.js and iisnode. Do I need some extra configuration?

  • Bill
    Bill
    15 December 2017

    Hot module reload not working. Did an initial pass and updated all components. When that did not work, I cloned your repo and ran that to see how that worked. dotnet run does not invoke webpack at all. Am I missing something obvious here?

  • FOX
    FOX
    23 December 2017

    Thanks for very detail & great tutorial!!! How to move http://localhost:5000 in action fetchInitialMessages to configs file? (for multi environments purpose)

  • Jack
    Jack
    09 January 2018

    Hi, could you make super simple example, like one .net mvc view, one component with store? i think this one is quite complex to follow. Kind regards

  • Fernando
    Fernando
    19 January 2018

    For those using Mac OS you can follow this post on setting up your environment to development. https://docs.microsoft.com/en-us/aspnet/core/fundamentals/environments#macos

  • RobertWah
    RobertWah
    08 June 2018

    Привлекательность аренды крана-манипулятора в СПб и Ленингадской области http://www.manipulator-47.ru подтверждается самими благодарными клиентами, которые нуждались именно в таком виде сервиса. Рентабельность говорит сама за себя, когда со всеми работами, справляется всего одна машина. Вам только стоит ознакомиться со способом оформления заказа, и вы сэкономите массу денег! http://www.manipulator-47.ru - https://c.radikal.ru/c21/1804/50/900c92de371d.jpg

  • EROLitmews
    EROLitmews
    24 June 2018

    Visitors to Spa can find many questionnaires massage specialist of any age and nationality performing nuru massage in the city Staten Island. Women are able not only to give pleasure in this way, but also to the strong semi-gentlemen. Beauties perform erotic a massage that will produce a gentleman a vivid impression. Prices for tantric massage depends on qualification Women and the skills that she possesses. Before making a choice, carefully study the prices for services and customer feedback about the work of one or another masseur specialist. We are sure that the search for a real professional masseur will be crowned with success and you will be satisfied with the quality of our services. Masseurs are skilled workers in their field and they will help you relax after a hard day. Our showroom works in New York. - <a href=https://bamboo.massage-nyc.com/>Bamboo Erotic Massage</a>

  • peter
    peter
    22 July 2018

    is it possible to run this sample as a Azure web app?