Server-side rendering and the journey to the center of Nuxt.js

Michael Gallagher
DailyJS
Published in
6 min readMar 29, 2021
Infographic vector created by freepik — www.freepik.com

Nuxt.js is what comes to mind when we think about server-side rendering (SSR) in Vue. Besides that, it is also a convention-over-configuration style framework which is very extensible. Known for developer experience, it does a wonderful job at abstraction, but as time goes on, we all have reasons and requirements for diving deeper.

For the second time in recent months, I had the requirement to block a page from loading based on a client-side API request.

This may not seem very interesting to a regular Vue developer, who would probably just add a navigation guard to the route in the Vue Router. But in the world of SSR, this is a catch 22 scenario.

A server-side render implies preparing the page on the server, and not just template markup, but executing stages of its lifecycle. All this, before arriving client-side and then some client-side process will need to show a mask and loading spinner, before either allowing the page or redirecting elsewhere. It doesn’t quite fit well together.

Tools of the trade

Before getting to the nuts and bolts, best review what tools that Nuxt provides which could help.

Middlewares are the Nuxt construct which acts as a navigation guard, just like the Vue Router. Nuxt controls the router as part of its abstraction from the intricacies of a universal application (runs on the client or server).

Plugins allow functionality to be executed with the Nuxt context at application bootstrapping. This may be useful as a SSR only occurs at application bootstrapping, we’ll cover some flows below.

Modules can hook into the project build and also many Nuxt hooks. But for our purposes, they don’t offer anything we need that can’t be achieved another way.

Does serverMiddleware provide anything for our use case? This extension point is very useful, but allows functionality to be applied to the Web Server that Nuxt uses, it does not provide any Nuxt context and lives outside and alongside the Nuxt server-side rendering. Short answer, No.

Middleware is the obvious choice, right?

When it runs on the client-side, it runs as part of a navigation guard in the Vue Router, just like a normal Vue solution would. The question one might ask is, can I set the middleware to run only on the client-side?

Short answer, No. Middlewares by definition are intended to run prior to rendering the page, therefore if it were set to only run client-side, it will run later and is no longer a Middleware.

In universal mode, middlewares will be called once on server-side (on the first request to the Nuxt app, e.g. when directly accessing the app or refreshing the page) and on the client-side when navigating to further routes. With ssr: false, middlewares will be called on the client-side in both situations.

First time reading this might make you think there is some middleware-specific ssr config setting that might make it run client-only, but it is not the case, middlewares will always run client-side with SSR is completely disabled. Not the solution we want.

Plugins will save the day for sure!

So plugins will run both on the server and client side by default, but they can be configured to run only on one. They run at application bootstrap only, so they won’t catch a client-side navigation to a page, so they can’t be the whole solution, but maybe they are part?

Though not explicitly called out in the docs, Plugins can be setup async and blocking. Here’s a test plugin:

export default async function() {
const where = process.server ? 'server' : 'client'
console.log(`start plugin (${where})`)
await new Promise((resolve, reject) => {
setTimeout(() => {
console.log('finished plugin')
resolve()
}, 1000)
})
}

Note that the above code could also serve as a middleware too, to see this behaviour in action see this Nuxt CodeSandbox. See the console and navigate between the index and about pages.

And the spanner in the works? Well there are a few, but the biggest one is that although the client-side plugin is technically blocking, it is only blocking for the client-side bootstrapping, the server-side work is already done. This is very clear if you take the sandbox above and set the wait time to say 10secs, you will see that during those 10secs, the server-side rendered markup is staring you in the face! Oops, not quite so blocking, it might explain why the docs don’t call this out the async/await block. The block is still valuable to ensure load order on the client-side, but it doesn’t block the server-rendered content, so it won’t work for us.

What’s left?

The catch 22 can’t be solved by trying to make a square peg fit in a round hole. Going back to our requirement, we need a navigation guard that only runs on the client, and so, if we hit the page directly we have no choice but to defer the SSR and rely on a client-side render. Doing this means we avoid extra processing on the server which best case is a drain on resources, worst case is incorrect and causes defects. But we don’t want SSR completely disabled, there are routes where this won’t run and they should be rendered server-side.

So what does the solution look like? Well a middleware is needed, but if it executes on the server (process.server) then we set some global state to track the work deferred to client-side, otherwise we execute directly, and we use the default layout as a central control point in the rendering process.

<template>
<loading v-if="isDeferred" active is-full-screen />
<div class="container" v-else>
<Nuxt />
</div>
</template>

The global state is managed in Vuex, which already is setup to transfer state between server-side and client-side.

export const state = () => ({
jobs: [],
running: false
});
export const getters = {
isDeferred: ({ jobs, running }) => running || !!jobs.length
};
export const mutations = {
start(state) {
state.running = true;
},
stop(state) {
state.running = false;
state.jobs = [];
},
defer(state, job) {
state.jobs.push(job);
}
};
export const actions = {
async run({ state, dispatch, commit }) {
if (!process.client) {
throw new Error("deferred queue should never be triggered from
a SSR");
}
if (!state.running && state.jobs.length) {
commit("start");
const runJob = (job) => dispatch(job, null, { root: true });
try {
await Promise.allSettled(state.jobs.map(runJob));
} finally {
commit("stop");
}
}
},
// Receive a job to execute client side only, it will execute
// immediately if running client-side, otherwise it is queued
async clientOnly({ commit, dispatch }, job) {
if (process.server) {
commit("defer", job);
} else {
await dispatch(job, null, { root: true });
}
}
};

The fully working solution can be seen in this Nuxt CodeSandbox. The key files to read are:

  • /layouts/default.vue
  • /middleware/test.js
  • /pages/about.vue
  • /store/deferred.js
  • /store/test.js

The deferred module is the generic mechanism for deferring a middleware, it works because the work to be performed is inside a store action (see test module) and the queue is simply a list of strings, each one a store action which is run without a payload.

In the sandbox, refreshing the About page will show that it is created on the client, not the server. Using the layout’s <Nuxt/> as the cutoff between server and client-side, is pretty clean cut, but it isn’t completely black and white. Due to the order of lifecycle, asyncData will actually run before the component is created (read from the route data), and therefore it will run on the server still.

As mentioned at the start of the article, this implementation was based on needing the functionality in 2 places. There could be lots of variations on this solution, the store state might be overkill if you have only a single use. But the premise and flow should remain fairly much the same.

Hope you find the code helpful or the scenario interesting, and a final piece of advice, if in doubt, read the built Nuxt code.

--

--

Michael Gallagher
DailyJS

Living in the vibrant city of Buenos Aires, developing eCommerce software with Hip. Modern JS is my passion, Vue my tool of choice