Nuxt SSR: transfer Typescript class instances
Florent Catiau-Tristant
Posted on May 14, 2021
Hey there! 👋
In this article, I will teach you how to transfer class instances through Server Side Rendering in Nuxt.
You may have tried to do use class instances yourself and faced some unexpected behaviour in your Nuxt app 😖?
asyncData (context) {
return {
todo: new Todo()
}
}
After developing a solution for myself, I released it as a Nuxt module. Check it out: nuxt-ssr-class-serialiser.
Be sure to give me some feedbacks, it's my first one!
The purpose of this article is to explain this module logic.
This article isn't really beginner friendly.
You might understand it without the following knowledge, but I can only recommend you to document yourself about those topics:
- Nuxt basis: Nuxt documentation
- Typescript decorators: logrocket guide to TS decorators
- Server Side Rendering (SSR): vuestorefront guide to vue SSR
- Vue class components: vue-class-component documentation
The context
Here, I'm exposing the problem we're trying to solve:
- Why do we need class instances?
- And why doesn't it work out of the box? You can skip this section if you know what situation this is all about.
A simple page setup
Let's say you have a page with a route "id" parameter that corresponds to a TODO entity.
http://localhost:3000/todos/15
You fetch it from an api, which returns you this object:
{
id: 15,
description: "Write this article you're thinking of for weeks.",
tags: ["Programming", "Blogging"],
dueDate: "1987-04-20"
}
Now imagine you want to know if this TODO has expired its due date so you can show it nicely on the page.
You could write the code in the page itself like so:
<template>
<div>
<p>{{ todo.description }} </p>
<span v-show="isTodoExpired">Todo is expired!</span>
<span v-show="!isTodoExpired">Todo due date: {{ todo.dueDate }}</span>
</div>
</template>
<script lang="ts>
export default TodoPage extends Vue {
asyncData ({ $route }) {
const todo = axios.get(`/api/todos/${$route.params.id}`).then(res => res.data)
return {
todo,
}
}
get isTodoExpired (): boolean {
const dueDate = new Date(this.todo.dueDate)
const today = new Date()
return dueDate < today
}
}
</script>
The result you get:
And the code it totally fine!
But imagine you have to use this piece of code at different places in your app?
For me, the cleanest way to do is to create a class instance. This way, for every todo entity, you'll be able to know if it's expired or not directly from the object.
export class Todo {
id: number;
description: string;
tags: Array<string>;
dueDate: string;
constructor(description: string, tags: Array<string>, dueDate: string) {
this.id = Math.random() * 1000 // Create dummy id
this.description = description
this.tags = tags
this.dueDate = dueDate
}
get isExpired (): boolean {
const dueDate = new Date(this.dueDate)
const today = new Date()
return dueDate < today
}
}
const todo = new Todo('Old todo', [], '1987-04-20')
console.log(new Todo().isExpired) // true
Nice! We have a Todo class that can contain every helper method attached to a todo object. We could imagine other methods to write in such as isTagged
, addTag
or whatever (remember this is a dummy example. Real world apps would have more complex entities to manipulate).
Most of the time, we'll try to convert a plain javascript object to a class instance without having to map all its property in a constructor. For this, I use the class-transformer library, which can create a class instance from a javascript object. It's used like so What about converting a POJO to a class instance?
const todo: Todo = plainToClass(Todo, myTodoObj)
Updating the page with our new class
With this class, we can update our page:
<template>
<div>
<p>{{ todo.description }} </p>
<span v-show="todo.isExpired">Todo is expired!</span>
<span v-show="!todo.isExpired">Todo due date: {{ todo.dueDate }}</span>
</div>
</template>
<script lang="ts>
export default TodoPage extends Vue {
todo!: Todo // declare asyncData data to be type safe from `this`
asyncData ({ $route }) {
const todoObj = axios.get(`/api/todos/${$route.params.id}`).then(res => res.data)
return {
todo: plainToClass(Todo, todoObj), // Could be a new Todo() as well
}
}
}
</script>
You reload the page and... wait? What is it not working? It's showing the text as if the todo was not expired?
The code is totally fine here. The problem we have is about SSR.
Why is it not working as expected?
I'll summarise what is happening in this situation.
- You reload the page, so it's gonna be rendered by the server.
- Nuxt runs the
asyncData
hook and fetch the todo object. - The
Todo
class instance is created - The page component is rendered.
Then, in Nuxt engine:
- Nuxt sends the rendered page as a string containing the dehydrated HTML.
- Nuxt sends the fetched data on server side as a stringified JSON to the client.
- The client side get this response and set it to
window._NUXT_
- The app renders the HTML, loads the data from
window._NUXT_
and starts hydrating it.
So what's wrong here?
The key is "Nuxt send the fetched data as a stringified JSON". It converts the object returned by asyncData
to JSON, to be able to send it by HTTP to the client.
But your todo
attribute is a class instance. How do you convert that to JSON and then to a string?
You can't.
Or at least not entirely.
Actually, it can serialise it by keeping the class properties, but losing everything else (constructor, methods, getters etc.).
So on the client side, your todo
object isn't a class instance anymore, it's back to a plain old javascript object (aka POJO).
A solution
Now we understand why our code is failing. Our class instance is stringified, losing all its methods.
So, in order to get back those class methods, we need to deserialise the POJO back to its class, i.e. create a new class instance from the object.
I advise you to not read diagonally from here. We all do this, but I'll try to be as straightforward as possible despite the complexity.
1. [Server side] Proper server serialisation
Nuxt SSR engine exposes some hooks we can use to customise it.
The hooks we are interested in are listed here: nuxt renderer hooks.
By the time I'm writing this article, this documentation isn't up to date. Some hooks of the form render:
are deprecated and is replace by the form vue-renderer:
(check it on the source code directly)
The goal here is to get the data from the asyncData
lifecycle hook, and serialise it ourselves so we avoid the Nuxt warning we saw earlier ("Warn: Can't stringify non-POJO")
We can update the nuxt.config.js
file like this:
hooks: {
'vue-renderer': {
ssr: {
context (context) {
if (Array.isArray(context.nuxt.data)) {
// This object contain the data fetched in asyncData
const asyncData = context.nuxt.data[0] || {}
// For every asyncData, we serialise it
Object.keys(asyncData).forEach((key) => {
// Converts the class instance to POJO
asyncData[key] = classToPlain(asyncData[key])
})
}
},
},
},
},
The
classToPlain
method comes from theclass-transformer
library
This hook is triggered when Nuxt is about to serialise the server side data to send it to the client side window.__NUXT__
variable. So we give it some help here by telling him how to deal with the variables that are class instance.
The point we're still missing here is how to identify the objects that actually needs that parsing. We'll get back to this part later.
2. [Client side] Deserialising back to instances
The server side data is now properly serialised. But it's still only POJO, not class instances.
Now, from the client, we have to deserialise it to create new class instances!
On client side, Nuxt doesn't provide - yet? - any custom hooks for SSR data handling, like the vue-renderer
hook for custom SSR code.
So the easiest solution I've came up with is to use the beforeCreate
hook in the page we are using this data.
In order to be DRY, I created a custom decorator to handle that. It's used like this:
export default TodoPage extends Vue {
@SerializeData(Todo)
todo!: Todo
asyncData ({ $route }) {
const todoObj = axios.get(`/api/todos/${$route.params.id}`).then(res => res.data)
return {
todo: plainToClass(Todo, todoObj),
}
}
}
The decorator serves two objectives:
- Identify which data property has to be (de)serialised.
- Provide which constructor to use for this specific property.
Internally, it enriches the beforeCreate
hook on client side to deserialise the data from the SSR POJO received.
Here is what it looks like:
import Vue, { ComponentOptions } from 'vue'
import { ClassConstructor, plainToClass } from 'class-transformer'
import { createDecorator } from 'vue-class-component'
/** Decorator to deserialise SSR data on client side with the given constructor
* @param classType The class constructor to use for this property
*/
export const SerializeData = <T> (classType: ClassConstructor<T>) => createDecorator((options, key) => {
// On client side only
if (process.client) {
wrapBeforeCreate(options, key, classType)
}
})
/** Enrich the beforeCreate hook with a deserialiser function. Ensure we still call the original hook if it exists. */
function wrapBeforeCreate <T> (options: ComponentOptions<Vue>, key: string, classType: ClassConstructor<T>) {
const originalBeforeCreateHook = options.beforeCreate
options.beforeCreate = function deserializerWrapper (...args) {
deserializer.call(this, key, classType)
originalBeforeCreateHook?.apply(this, args)
}
}
/** Deserialise a POJO data to a class instance
* @param key the property name
* @param classType The class constructor used to create the instance
*/
function deserialiser <T> (this: Vue, key: string, classType: ClassConstructor<T>) {
const { data } = this.$nuxt.context.nuxtState || {}
const [asyncData] = data // not really sure why it's an array here tbh.
if (asyncData && asyncData[key]) {
// Convert back the data to a class instance
asyncData[key] = plainToClass(classType, asyncData[key])
}
}
When the component is compiled down to javascript, it should be looking like this:
export default {
asyncData() {
const todoObj = axios.get(`/api/todos/${$route.params.id}`).then(res => res.data)
return {
todo: plainToClass(Todo, todoObj),
}
}
beforeCreate() {
deserialiser('todo', Todo)
}
}
Now, when using the decorator, the POJO data will be transformed to a class instance when the page is rendering! 🎉
3. Polishing the server side
With this decorator, we can improve the server side deserialiser to identify the properties instead of trying to convert them all to POJOs.
The idea is simple: we can register a temporary data to be used by our custom renderer hook.
Here is the final code of the decorator:
import Vue, { ComponentOptions } from 'vue'
import { ClassConstructor, plainToClass } from 'class-transformer'
import { createDecorator } from 'vue-class-component'
/** Decorator to handle SSR data as class instances
* @param classType The class constructor to use for this property
*/
export const SerializeData = <T> (classType: ClassConstructor<T>) => createDecorator((options, key) => {
if (process.server) {
wrapAsyncData(options, key)
} else {
wrapBeforeCreate(options, key, classType)
}
})
/** Enrich the asyncData hook with a registering function.
* Ensure we still call the original hook if it exists.
*/
function wrapAsyncData (options: ComponentOptions<Vue>, key: string) {
const originalAsyncDataHook = options.asyncData
options.asyncData = async function wrapperAsyncData (...args) {
const originalAsyncData: Record<string, any> = (await originalAsyncDataHook?.apply(this, args)) || {}
registerSerializableProp(originalAsyncData, key)
return originalAsyncData
}
}
/** Add a config property to store the data that must be serialised */
function registerSerializableProp (asyncData: any, key: string) {
asyncData.serializerConfig = asyncData.serializerConfig || []
asyncData.serializerConfig.push(key)
}
/** Enrich the beforeCreate hook with a deserialiser function.
* Ensure we still call the original hook if it exists.
*/
function wrapBeforeCreate <T> (options: ComponentOptions<Vue>, key: string, classType: ClassConstructor<T>) {
const originalBeforeCreateHook = options.beforeCreate
options.beforeCreate = function deserializerWrapper (...args) {
deserializer.call(this, key, classType)
originalBeforeCreateHook?.apply(this, args)
}
}
/** Deserialise a POJO data to a class instance
* @param key the property name
* @param classType The class constructor used to create the instance
*/
function deserialiser <T> (this: Vue, key: string, classType: ClassConstructor<T>) {
const {data} = this.$nuxt.context.nuxtState
const [asyncData] =data
if (asyncData && asyncData[key]) {
asyncData[key] = plainToClass(classType, asyncData[key])
}
}
The new part is ran only for server side (notice the process.server
at the beginning of the decorator function).
We create a serializerConfig
property that stores all the keys that we have to serialise.
Going back to our custom hook:
context (context) {
if (Array.isArray(context.nuxt.data)) {
const data = context.nuxt.data[0] || {}
// If we have a `serializerConfig` property
if (Array.isArray(data.serializerConfig)) {
// Loop on all its values
data.serializerConfig.forEach((dataKeyToSerialise) => {
data[dataKeyToSerialise] = classToPlain(data[dataKeyToSerialise])
})
// Remove the temporary object, now obsolete.
delete data.serializerConfig
}
}
},
And this is it! We have a fully functional class instance transfer in Nuxt SSR!
Conclusion
By reading this article, we learnt that:
- SSR can't deal with class instances out of the box
- We can develop a workaround for this
- Nuxt SSR engine provides helpful hooks
Summary of the solution provided:
- Create a custom
SerialiseClass
decorator to identify the component properties to be serialised manually - Listen to the Nuxt
vue-renderer:ssr:context
hook to convert the identified class instances to POJO - Use the decorator to deserialise the data back to class instances on client side with the
beforeCreate
lifecycle hook.
It sure is subject to further improvements, as I may not know some magic trick that could handle that more easily.
Thank you very much for reading my first article! I'm opened to any feedback (about the article content, typos, ideas etc.) and questions.
Have a great day! 🙌
Posted on May 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.