From Vue to Nuxt: Server-side rendering in a nutshell

Explore by Category:

Performance

From Vue to Nuxt: Server-side rendering in a nutshell

Going through the implementation of basic server-side rendering.

Building front-end apps is no longer limited to taking care of what is happening only in the browser. We need to dig into the server-side as well if we want to provide reliable software.

Technical Whitepaper

CTA_–_Technical_Whitepaper.png

A couple of years ago, when the single-page apps were born, we moved server logic to the client-side. Yet, very quickly, we have realized that our websites keep getting bigger, which comes with worse performance. To speed them up, we made many improvements such as tree-shaking, bundling code, lazy loading, and server-side rendering, which I will take a closer look at.

Before you read the article, you should be aware of the few assumptions I made on the purpose of this article:

  • all of the code used here is shorter and simplified; it doesn’t reflect the real one showing only the idea and the way of how algorithms work;
  • implementation is based on Nuxt.js one - in the text, you can see the links to the source code of Vue and Nuxt;


What is SSR and why we need it?

Once we moved most of the logic to the client-side, our browser had a problem with rendering everything in a reasonable time. Only the most high-performance server could cope with tons of JavaScript codes, numerous views, and various components; in the other cases, the performance metrics were below expectations.

A similar situation touches SEO. When the browser is responsible for rendering HTML, all of the crawlers have problems with parsing that.

What about the overall business value of Vue Server Side Rendering?

Well, it turns out the milliseconds make millions, less time to interaction on the page leads to higher conversion rate which is solved totally by the server-side rendering. We can do rendering work on the server side a way faster so the user is able be in a good tracking motion towards the purchase. 

Deloitte analysis proved that a mere 0.1s change in load time influences every step of the user journey, ultimately increasing conversion rates. Although it is nice to see it on paper, it feels like it is not breaking news. Lagging load time is both annoying and harmful to business bottom lines. 

Read More: " Poor performance is stopping you from making money on mobile commerce "

These are the reasons why SSR appeared on the horizon. Thanks to it, we can render (or partially render) pages on the server-side, and the server is generating markup to the client-side. In this case, the framework will handle it and use it as a sort of initial view for upcoming interactions. Also, the server can render the HTML, and so SEO is not an issue anymore.

How it actually works?

Independently on the frameworks, server-side rendering always works in the same way. When we visit a page by entering the URL or refreshing it, we are getting the markup from the server (SSR). Now, when we browse over the app, we use only client-side rendering as required HTML is already here - so the server doesn’t have to be engaged anymore. The whole process is consisting of two essential parts - rendering itself and the hydration.

s_EBC6498B0454D5E5DFBC9B3DCE56336E80BD88251485789B45CAE29C1AD1F660_1601634543361_ssr-1.jpg

The rendering process is handled by the server (usually written in express.js). Modern frameworks are managing it by a special function that takes the components and render them to the string or by the router that returns you rendered and matched components (by the current route). 

s_EBC6498B0454D5E5DFBC9B3DCE56336E80BD88251485789B45CAE29C1AD1F660_1601640505972_ssr-2.jpg

Once we have the components we want to render on the page obviously we would like to load some data asynchronously and here we need to go beyond the frameworks. 

Each component that needs some external data usually needs to implement a dedicated function responsible for fetching it. Because we have access to the rendered components, we can call this function for each component and when all of the fetches are ready, we can send the completed markup to the client.

s_EBC6498B0454D5E5DFBC9B3DCE56336E80BD88251485789B45CAE29C1AD1F660_1601653993119_ssr-3.jpg

The rendered HTML has just landed in the browser. Now what? Well.. now the hydration process takes over the control. A markup that comes from the server already contains the data inside and its raw representation in some window field, usually called _INITIAL_STATE_.

Hydration has two major goals: register the received markup to the framework (register events, fields, components, etc.) and assign the data to the proper properties on the client-side. The first goal framework is doing automatically, but the second one is something we need to take care of.

On the server-side, the framework has the data assigned to the variables, but now, on the client-side, we need to do it again as the only data we have is either included on the rendered HTML or passed to the initial state in the window object.

Implementation of basic nuxt SSR

Let’s jump to the implementation for Vue.js. Most of the users probably will use Nuxt.js and their solution  - just for a better understanding of how it’s actually done.

We start with a project that contains a fundamental implementation, described at https://ssr.vuejs.org/ .

We have two major files:

server-entry.js

mport { createApp } from "./main.js"; export default context => { return new Promise((resolve, reject) => { const { app, router } = createApp(); router.push(context.url); router.onReady(() => { const matchedComponents = router.getMatchedComponents(); if (!matchedComponents.length) { return reject({ code: 404 }); } resolve(app) }, reject); }); };

client-entry.js

import { createApp } from './main.js'; const { app, router } = createApp() router.onReady(() => { app.$mount('#app', true) })

As a consequence of having two different environments: browser and the server (node.js) - we need to prepare different bundles for the server and client-side. Both of them are sharing the same javascript code that creates the app (main.js). Based on the theoretical explaining we mentioned before - server entry will handle the rendering itself while client one will take care of the hydration process. Both files work even now, but they can’t load the data and that’s what we will implement.

Considering the below example:

You may be familiar with that sort of component and asyncData function, as this is well-know from Nuxt.js world. It’s the function that loads the data on the server and makes them available as data in the component. How can we achieve that? Let’s see.

We already have all of the matched components under the variable matchedComponents where each element contains all of the component objects, including the function we created - asyncData. The app is ready to send when we call resolve. That means, we have to call each asyncData function, wait for their responses and once we have it, we can resolve the app.

As a first step, create all of the promises we need to resolve:

const promises = matchedComponents .map( component => component.asyncData && component.asyncData().then(data => ({ data, component })) )

Now, we can simply use Promise.all to wait for all of them and send the app to the client:

Promise.all(promises).then(results => { resolve(app); })

We are able to ask for the data but how can we apply it to the component? As I mentioned, asyncData in Nuxt.js makes them available as regular data, so we need to do the same :

Promise.all(promises).then(results => { results.forEach(({ data, component }) => { const ComponentData = component._Ctor[0].options.data // 1 component._Ctor[0].options.data = function() { // 2 const originalData = ComponentData.call(this, this) // 3 return { ...originalData, ...data } // 4 } }) resolve(app); })


We are overloading the original data function from the Vue component by applying the data we have received from the asyncData call. Particularly, we can’t just override the original data even if it’s available in the component object, we need to override it in the constructor. How it’s done?

We are saving the original data function (1) and assign a new one (2). As we need the original data from the component, we call previously saved one (3). In the end, we can simply merge and return the original data and currently loaded from the async call (4).

At this point, if you run this and disable javascript in the browser, you can see that rendered markup contains the data, but… the data itself is not available anywhere. Here we need to work on the initial state.

context.state = [] // 1 Promise.all(promises).then(results => { results.forEach(({ data, component }) => { const ComponentData = component._Ctor[0].options.data context.state.push(data) // 2 component._Ctor[0].options.data = function() { const originalData = ComponentData.call(this, this) return { ...originalData, ...data } } }) resolve(app); })

Fortunately, Vue.js has something like context and the field state.

Everything we assign to the state on the server-side will be accessible in _INITIAL_STATE_ under the window object. As you can see, firstly I have defined an array (1) and for each component, I’m saving generated data - that’s it.

How about the hydration part? It’s pretty much the same, but we are not generating the state; instead, we are reading the existing one.

router.onReady(() => { const matchedComponents = router.getMatchedComponents(); matchedComponents.forEach((component, index) => { const initialComponentState = window.__INITIAL_STATE__[index] // 1 const ComponentData = component._Ctor[0].options.data component._Ctor[0].options.data = function() { const originalData = ComponentData.call(this, this) return { ...originalData, ...initialComponentState } // 2 } }) app.$mount('#app', true) })


Because we have stored the generated state in the window, it’s time to read it (1). Again, we need to override original data function, but we are merging original data with the initial state that we took from the window.
That’s all; we have just implemented our own asyncData.

Going to the future - composition API

The composition API it’s something new in Vue 3 and it’s completely changing the way of developing the apps. Of course it also changes the implementation of server-side rendering. 

At Alokai, we have been using composition API since very beginning. We can even tell that we are early-adopters as we started using this when there was the only plugin for Vue. Obviously, when it comes to Server Side Rendering - it didn’t work properly.

We made many workarounds to make it compatible with Nuxt.js but the final implementation that we have so far, we made when Nuxt.js had released a nuxt composition API - a set of composition API functions that support a couple of things, including Nuxt.js server side rendering.

Before we jump to the Nuxt.js, we should understand what we are working with - remember asyncData? How can it look like in the compositon-api world?

Instead of regular Vue.js component’s methods we have just a simple setup(). To define reactive properties, we have refs. How about async calls and SSR? For that purpose, we have useAsyncData - the feature we digging into now.

We start with fetching and rendering. The composition-api shares a special function called onServerPrefetch that calls the function only on the server; it is perfect on for fetching the data .

import { onServerPrefetch } from '@vue/composition-api'; const useAsyncData = (fn) => { onServerPrefetch(async () => { await fn(); }); }

Our function is being called on the server-side, but we don’t have access to received data from async call. They are being assigned to refs inside of the component; how can we reach them?

import { getCurrentInstance, onServerPrefetch } from '@vue/composition-api'; const useAsyncData = (fn) => { const vm = getCurrentInstance() // 1 const data = { ...vm._data } // 2 onServerPrefetch(async () => { await fn(); Object.entries(vm.__composition_api_state__.rawBindings).forEach( // 3 ([key, val]) => { data[key] = val.value // 4 } ) }) }

Vue.js composition api stores the fields in the instance under the rawBinding property . In the beginning we need to access current instance (1) and save the current data from a component (2). 

The rawBinding is stored in the current instance’s particular field, dedicated to the composition api: _composition_api_state_. All we need to do is iterate over the rawBindings (3), and merge current values with previously saved data (4). 

We did the same work as overriding the server-entry data function - doesn’t it look familiar? As you can guess, the next step is pushing the received data to the client.

import Vue from 'vue'; import { getCurrentInstance, onServerPrefetch } from '@vue/composition-api'; const useAsyncData = (fn) => { const vm = getCurrentInstance() const data = { ...vm._data } onServerPrefetch(async () => { await fn(); Object.entries(vm.__composition_api_state__.rawBindings).forEach( ([key, val]) => { data[key] = val.value } ) vm.$ssrContext.state = data; // 1 }) }

The current instance and its $ssrContext can access the same context as we used in the implementation of asyncData function - assigning anything to the state property is putting the data to the window.INITIAL_STATE(1).
The last part is hydration. The data is accessible via the window, so let's assign it to the ref variables.

import Vue from 'vue'; import { getCurrentInstance, onBeforeMount, onServerPrefetch } from '@vue/composition-api'; const useAsyncData = (fn) => { const vm = getCurrentInstance() const data = { ...vm._data } onServerPrefetch(async () => { await fn(); Object.entries(vm.__composition_api_state__.rawBindings).forEach( ([key, val]) => { data[key] = val.value } ) vm.$ssrContext.state = data; }) onBeforeMount(() => { // 1 Object.entries(window.__INITIAL_STATE__).forEach(([key, val]) => { // 2 Vue.set(vm, key, val) // 3 }) }) }

Based on Vue components lifecycle, the best place to re-assign the data is beforeMount hook . Composition API shares function to do it - onBeforeMount (1). Again we need to read the window._INITIAL_STATE_ (2), but this time we are setting the properties to the current instance itself (3) as a component saves there the current refs.

Here we go. To sum up, we created something very similar to the Nuxt.js useFetch mechanism which works perfectly!

… besides one crucial thing.Let’s jump to Nuxt.js

Composition API and Nuxt.js

The Nuxt.js team prepared quite a nice toolset of composition api function that we can use in our apps. There is a function called useFetch, which is the same thing as fetch already known from Nuxt ecosystem.

The implementation of useAsyncData that we did on the previous section recreates useFetch, but of course, I simplified this. 

We started with a component that uses our useAsyncData implementation before. In Nuxt.js this component will look exactly the same:

export default { setup () { const user = ref(null) const loading = ref(false); const loadUser = async () => { const userResp = await fakeLoadData({ firstName: 'John', lastName: 'Doe' }); return userResp; } useFetch(async () => { loading.value = true; user.value = await loadUser(); loading.value = false; }) return { user, loading } }, };


We replaced only useAsyncData to useFetch, and everything still works properly. The problem occurs when we add computed properties that depend on the ref state - when we run the app, we are getting an error that says we can't reassign computed property, plus a couple of mismatches.

Wait.. but we don't assign a computed property again, so why is such an error? Because actually, we do assign a computed property again.

onBeforeMount(() => { Object.entries(window.__INITIAL_STATE__).forEach(([key, val]) => { Vue.set(vm, key, val) // computed property error! }) })

In the hydration process, we are setting properties to the current instance, and of course, among them are the computed properties.

It appears even in the current Nuxt.js and nuxt composition API, and there is no right way to solve it. It doesn't mean that Nuxt engineers did something wrong. We have to keep in mind that composition API plugins are not production-ready; they are just a demo version of what we will meet in Vue 3.

Even though it didn't work for us, as an early-adopters of composition API, we had to find out how to solve it temporarily. Nuxt also provides a special ref that is hydrated on the client-side with the corresponding date generated on the SSR, called ssrRef. When we mix ssrRef and onServerPrefetch function, we can achieve something very similar to ordinary ref and useFetch.

export default { setup () { const user = ssrRef(null) const loading = ssrRef(false); const loadUser = async () => { const userResp = await fakeLoadData({ firstName: 'John', lastName: 'Doe' }); return userResp; } onServerPrefetch(async () => { loading.value = true; user.value = await loadUser(); loading.value = false; }) return { user, loading } }, };

Switching refs to ssrRefs and useFetch to onServerPrefetch solves the problem. Still, in the real world, onServerPrefetch isn’t enough - you need to solve also the problem of transitions through the pages; however - let’s keep it simple at this point.

The difference between the first (ref, useFetch) and the second (ssrRef, onServerPrefetch) approach noticeable - we are switching just two functions; the rest of the code stays the same.

To be prepared for Vue 3, we have created our functions: vsfRef and onSSR and function for configuring them - configureSSR. By doing this, we can easily replace the SSR implementation to any other in the future. For now, vsfRef is set to ssrRef and onSSR to onServerPrefetch (with some improvements), later that will become ref and useFetch.

configureSSR({ vsfRef: ssrRef, onSSR: onServerPrefetch })

Is the server-side rendering so low-level stuff?

In most of the projects based on Nuxt.js or even other ones, you will never touch the implementation of server-side rendering. Once you do this, you never touch this again. It is the part of front-end engineering that everyone uses, but usually, no one even knows how it works in detail, and it’s ok.

It’s usually handled by the framework (such a Nuxt.js), so we don’t have to worry about it. We can say that’s kind of low-level as we have to dig into the framework itself, but it’s not that hard as it might look like; it just requires more patience.

Speed up your Digital Transformation with MACH

EbookMockup-14.png

Share:

Frequently asked questions

shape

Ready to dive in? Schedule a demo

Get a live, personalised demo with one of our awesome product specialists.