DEV Community

loading...
Cover image for Nuxt SSR: transfer Typescript class instances

Nuxt SSR: transfer Typescript class instances

Florent Catiau-Tristant
I'm a Full Stack JS developper, working with Vue, Nuxt, Node, NestJS...
・9 min read

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

serialise_error_pojo

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
:

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

The result you get:

Todo is expired

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
Enter fullscreen mode Exit fullscreen mode

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).

What about converting a POJO to a class instance?

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 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>
Enter fullscreen mode Exit fullscreen mode

You reload the page and... wait? What is it not working? It's showing the text as if the todo was not expired?

Todo is 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.

  1. You reload the page, so it's gonna be rendered by the server.
  2. Nuxt runs the asyncData hook and fetch the todo object.
  3. The Todo class instance is created
  4. The page component is rendered.

Then, in Nuxt engine:

  1. Nuxt sends the rendered page as a string containing the dehydrated HTML.
  2. Nuxt sends the fetched data on server side as a stringified JSON to the client.
  3. The client side get this response and set it to window._NUXT_
  4. 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])
          })
        }
      },
    },
  },
},
Enter fullscreen mode Exit fullscreen mode

The classToPlain method comes from the class-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),
     }
  }
}
Enter fullscreen mode Exit fullscreen mode

The decorator serves two objectives:

  1. Identify which data property has to be (de)serialised.
  2. 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])
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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])
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
},
Enter fullscreen mode Exit fullscreen mode

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! 🙌

Discussion (0)