DEV Community

aabdullin
aabdullin

Posted on

Making Nuxt.js clone with Vue 3 and Vite (Vue Custom Server Side Rendering)

Competently writing applications in JavaScript is sometimes not an easy task, but this task can become much more difficult in a situation where one day your application will need indexing by search engines such as Google, DuckDuckGo, Bind, etc. And the problem is that not all of them are able to index SPA applications correctly, and before you will face the task of turning from Client Side Render (CSR) into an application with Server Side Render (SSR) support.

This article will be useful for those who want to write an application with Custom Server Side Rendering. For those who want to add SSR support to the existing application. But also, of course, those who work with Nuxt, and want to learn more about how it works under the hood.

You can find all the code from the article and an example of the project here.

P.S. If you want jump to the code, scroll to Let's start writing code section

SSR Framework Requirements

And so I want to more accurately outline my requirements for an ideal framework for server side rendering, I have prepared a visual visualization:

SRR Framework features

Learn more about each item:

  • Lazy Loading - the framework should be able to split routes into different chunks to reduce the weight of the bundle that the user loads by logging into the application. The benefit of Vite and the plugin for Vue out of the box in the process of rendering the application on the server side is to register all the modules that will need to be uploaded to the user, more here.
  • Middlewares - the framework should allow you to write Middleware, in order to redirect unregistered users that should not have access to the page, or make some pages possible to view only in guest mode.
  • Nested routes - the router should allow you to create a complex composition of nested pages.
  • Data Fetching - each page should be able to load data from the API, and take into account the nested pages.
  • Integration with Pinia - Each page pulls certain actions at the time of loading data, but what happens if one of the API calls returns a 404 error? For example, we have an action fetchProduct that transmits the product id, and if we receive a 404, we should be able to say that we should give the page with the 404 status to the user, or even make a redirect directly in the action, without taking this logic outside the action. It seems to me that the close integration of the store with the SSR router to solve such situations is a frequent omission in the world of modern SSR frameworks.
  • Working with Head - And of course, what kind of SEO without meta tags, the framework should provide the ability to edit the contents of the <head /> tag both on the server and client side.

Why custom Server Side Rendering?

First reason what i see it is a interest in how Nuxt/Next works under the hood for educational purposes, and second when you have a lot of expirience with Nuxt/Next and already exactly know why need it, and you have already met the limitations in other frameworks πŸ˜„

For example:
How to customize nuxt default html template in Nuxt 3?
In Nuxt 2 we have BODY_SCRIPTS, but not in Nuxt 3 (At the time of writing article).

Or i have problem what i am not able to get user ip address, because it is undefined in Nuxt 3 server-middleware πŸ˜• (At the time of writing article).

  • A few words Vite

Vite very fast bundler oriented EcmaScript Modules.
He have Vue SSR example repository.

  • Routing
  • Lazy Loading
  • Data Fetching
  • Middlewares
  • Working with
  • Integration with Pinia

  • Routing

We use Vue Router, it's very flexible API compared to any routers.

  • Lazy Loading

Default Vue Router Lazy loading mechanism. All works out of the box 😍

  • Data Fetching

Let’s talk about the data fetching method.
For fetching data in Nuxt 3, you could see that useAsyncData is used, and I suggest taking a look at how it works inside, here and we see that they use onServerPrefetch for fetching data, but how does it work?
In fact, as soon as Vue encounters onServerPrefetch, rendering of the component stops until the asynchronous operation is performed, and only after that Vue will continue the rendering process. Learn more about onServerPrefetch here.

Here I want to draw your attention to two things:

  1. onServerPrefetch was added to Vue in order to give the developer the opportunity to fetch data at any level of component nesting, and not as it is arranged in Nuxt 2 when the custom option asyncData is available only at the page component level.
  2. But this approach also has disadvantages, because it is a compromise between convenience (Development Experience) and performance. Because each call to onServerPrefetch will stop the rendering process and wait until the rendering can be continued, and in the case of nested routes we have one problem, I suggest looking at the waterfall graph:

onServerPrefetch vs asyncData

Here we able to see, then we have 3 level nested route, before calling the next onServerPrefetch, we must wait for the previous one to complete its work. And in case then we have 3 API calls that resolve after ~1.4, ~2.1, ~1.5 seconds:

  • onServerPrefetch/useAsyncData the user need wait all API requests, that equal in total to 5 seconds.
  • asyncData the user need wait only longest API request, that equal to 2.1 seconds.

This is concepts of how we able fetch data.
And because nested routing is important concept in our framework we choose asyncData option. And we have one limitation, all page level components that use asyncData need to use defineComponent API.

A little more about the nested routes

For example Next has one big architectural flaw (At the time of writing article), it does not know how to work with nested routes. For those who do not know, Vue Router allows using the component <router-view /> to display child pages, at any nesting level. I have prepared a visual visualization of what nested routes are, for those who are familiar with React + Remix I think it will be very familiar. Because nested routes are killer features Remix.
By the way, while I was writing the article, Layouts RFC came out on the Next blog.js, which means that soon there will be support in Next.js .
Let's go back to the Remix framework:

Remix nested routes

Here you can clearly see that the components <Root />, <Sales />, <Invoices />, <Invoice /> are pages, each of which has its own component, just as it works with nested routes in Vue Router.
Vue Router uses a config file for the declaration of routes, I think this is the most convenient and flexible way to declare routes. But this approach in the case of SSR burdens us with the need to be able to fetch the data of all nested pages that match the url with our routes, more details below.

Why Vue 3 is the best for Server Side Rendering?

Evan Yoo and the Vue Core Team have done a lot of work on optimizing the Vue SFC compiler, and improved TypeScript support.
But the most important killer feature of Vue is that for SSR bundle, the runtime of components is different from the runtime of components for CSR. There is a separate compiler that converts our templates literally into strings.
For clarity, let's compare the runtime stages of the Vue 2/3 and React components at the time of rewriting on the server:

Vue 2/3 and React runtime lifecycle during SSR

As you can see, Vue 2 and React, at the time of rendering the component <Component /> create VDOM first, and only then the renderToString function converts the viral nodes into drains (Not sure about Vue 2).
On the other side we have Vue 3, the rendering process of the components of which is includes normalization of dynamic attributes and string concatenation.

I suggest you take a visual look at how it looks in Vue 3:

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  props: {
    alt: String,
    png: String,
    webp: String,
  },
})
</script>

<template>
  <picture class="picture">
    <source :srcset="$props.webp" type="image/webp">
    <source :srcset="$props.png" type="image/png">
    <img
      :src="$props.png"
      :alt="$props.alt"
      draggable="false"
      loading="lazy"
    >
  </picture>
</template>

<style>
.picture > img {
  width: 100%;
}
</style>
Enter fullscreen mode Exit fullscreen mode

What do you think this template will look like after compilation?
You can learn more about the work of the Vue SFC Compiler here in SFC Playground (click open the "SSR" tab).

/* Analyzed bindings: {} */

import { defineComponent } from 'vue'

const __sfc__ = defineComponent({
  props: {
    alt: String,
    png: String,
    webp: String,
  },
})

import { mergeProps as _mergeProps } from "vue"
import { ssrRenderAttr as _ssrRenderAttr, ssrRenderAttrs as _ssrRenderAttrs } from "vue/server-renderer"
function ssrRender(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
  _push(`<picture${
    _ssrRenderAttrs(_mergeProps({ class: "picture" }, _attrs))
  }><source${
    _ssrRenderAttr("srcset", _ctx.$props.webp)
  } type="image/webp"><source${
    _ssrRenderAttr("srcset", _ctx.$props.png)
  } type="image/png"><img${
    _ssrRenderAttr("src", _ctx.$props.png)
  }${
    _ssrRenderAttr("alt", _ctx.$props.alt)
  } draggable="false" loading="lazy"></picture>`)
}
__sfc__.ssrRender = ssrRender
__sfc__.__file = "App.vue"
export default __sfc__
Enter fullscreen mode Exit fullscreen mode

And what can we see here? An experienced developer familiar with React would be surprised to see this for the first time.
Since the runtime process of the Vue 3 component in the server rendering process is string concatenation, this is an insanely fast comparison operation with creating a VNode for each element as in React.

Let's start writing code

Bundling SSR applications using Vite

For bundling, we will use Vite for several reasons:

  • Vite is the de facto standard for Vue 3, yes, Vue 3 can be assembled by Webpack, but first of all, the custom Webpack config will have to be constantly updated as updates, and this is not such an easy task in itself. And it is incredibly difficult to find manuals for Vue 3 at least at the time of the middle of 2021, and in contrast we have a collection with the simplest possible config file (finally πŸ₯Ή) sharpened to a greater extent for Vue.
  • The speed of work, with the right setup, the same Vita setup will be faster than the Webpack in everything except the build speed, the speed of the Hot Module Replacement (HMR) is especially pleasing, this is the thing that makes you reload CSS, Vue files and so on without reloading the page.
  • Openness and simplicity, you can find a discussion of the new Vita features in issues/RFC, etc. and to study the code base of the Vita is much easier in my opinion than to understand how Webpack works. For example, in this article, I sometimes openly refer to some points in the sources of the same vitejs/plugin-vue, and it is not as difficult to understand it as it seems.

I recommend that instead of manually initializing the project, I recommend to clone the official SSR-example or my demo project for this article (I recommend it).

And so let's move on to setting up the Vite config:

/* vite.config.js */
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  css: {
    modules: {
      generateScopedName: '[hash:base64:5]',
      hashPrefix: ' ',
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

The main thing we need to do is to connect vitejs/plugin-vue.
You can also pay attention that I use css.modules.generateScopedName in the value [hash:base64:5], you don't have to do this, but if you use CSS modules, this parameter will allow you to get CSS class names like vwGJw, this is very concise, and allows you to slightly reduce the size of CSS files, JS files, as well as reduce the size of the HTML document by saving on the length of CSS classes.

In package.json add the following scripts:

{
  "scripts": {
    "dev": "node server.js",
    "build": "vue-tsc --noEmit && yarn run build:client && yarn run build:server",
    "build:client": "vite build --ssrManifest --outDir dist/client",
    "build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
    "serve": "cross-env NODE_ENV=production node server.js"
  },
}
Enter fullscreen mode Exit fullscreen mode

And create a file index.html, and server.js at the root of the project.
You can create a server.ts, and use replace the dev script with node --experimental-specifier-resolution=node --loader ts-node/esm server.ts, but this has one big disadvantage, the start speed of the application, especially for production it is critical, so I I recommend not using TypeScript for server.js file, but this is the only JavaScript file that we will have.

Here are the minimum files we need to create for our SSR to work:

Files

What does each of them mean?

  • App.vue - the input component of our application
  • main.ts - function to initialize our application, there we will call createSSRApp, createRouter, createPinia, createHead, etc.
  • entry-server.ts - here will be stored the same render function responsible for rendering on the server side that we connect to server.js.
  • entry-client.ts - well, by analogy, we will have an input file for the client bundle, which will not run on the server, but serves for the hydration process of our application.

And let's start with the content server.js:

// In the polyfills file.js you can connect polyfills for fetch calls in the environment Node.js
// https://github.com/fyapy/vue3-vite-custom-ssr-example/blob/master/src/server/polyfills.ts
require('./server/polyfills')
const fs = require('fs')
const path = require('path')
const express = require('express')

const resolve = p => path.resolve(__dirname, p)

async function createServer(
  root = process.cwd(),
  isProd = process.env.NODE_ENV === 'production'
) {
  const indexProd = isProd
    ? fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
    : ''

  const manifest = isProd
    ? require('./dist/client/ssr-manifest.json')
    : {}

  const app = express()

  /**
   * @type {import('vite').ViteDevServer}
   */
  let vite
  if (!isProd) {
    vite = await require('vite').createServer({
      root,
      logLevel: 'info',
      server: {
        middlewareMode: 'ssr',
        watch: {
          usePolling: true,
          interval: 100,
        },
      },
    })

    app.use(vite.middlewares)
  } else {
    app.use(require('compression')())
    app.use(
      require('serve-static')(resolve('dist/client'), {
        index: false,
      })
    )
  }

  app.use('*', async (req, res) => {
    // handler...
  })

  return {
    app,
    vite,
  }
}

exports.createServer = createServer
Enter fullscreen mode Exit fullscreen mode

This is almost a basic setup of the Vite server programmatically.
But the most interesting thing is where all the formation of the server response body to our user takes place, all the work with caching, etc.

app.use('*', async (req, res) => {
  try {
    let template, render
    if (!isProd) {
      // In dev mode, we load on the fly index.html
      template = fs.readFileSync(resolve('index.html'), 'utf-8')
      template = await vite.transformIndexHtml(req.originalUrl, template)
      // And in the same way we load the render function
      render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
    } else {
      template = indexProd
      // In the case of production, we already have a ready bundle that we will connect
      render = require('./dist/server/entry-server.js').render
    }

    const {
      html: appHtml,
      preloadLinks,
      headTags,
    } = await render({
      url: req.originalUrl,
      req,
      res,
      manifest,
    })

    const html = template
      .replace('<!--head-tags-->', headTags)
      .replace('<!--preload-links-->', preloadLinks)
      .replace('<!--app-html-->', appHtml)

    res.set({ 'Content-Type': 'text/html' }).end(html)
  } catch (e) {
    vite && vite.ssrFixStacktrace(e)
    console.log(e.stack)
    res.status(500).end(e.stack)
  }
})
Enter fullscreen mode Exit fullscreen mode

The contents of the file App.vue, the most standard, inside which there is a <router-view /> in which we will display the top level of the pages of our pages. Here is the router config:

import {
  createMemoryHistory,
  createRouter as _createRouter,
  createWebHistory,
  type RouteRecordRaw,
} from 'vue-router'
import Resources from './pages/Resources/Resources.vue'
import Resource from './pages/Resources/Resource.vue'


const routes: RouteRecordRaw[] = [

  {
    path: '/',
    component: Resources,
    children: [
      {
        path: 'resource/:id',
        component: Resource,
        children: [
          {
            path: 'transactions',
            component: () => import('./pages/Resources/Transactions.vue'),
          },
          {
            path: 'requisites',
            component: () => import('./pages/Resources/Requisites.vue'),
          },
        ],
      },
    ],
  },
  {
    path: '/home',
    component: () => import('./pages/Home.vue'),
  },
  {
    path: '/:pathMatch(.*)*',
    component: () => import('./ui/pages/NotFound.vue'),
  },
]

export const createRouter = () => _createRouter({
  history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
  routes,
  scrollBehavior() {
    return {
      top: 0,
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

The most important thing here is that the creation of a router instance is a function, in normal SPA it's just a hanging global variable, but because the concept of server-side application rewriting implies that for each request we create a completely new application instance, including routing.
And don't miss this condition:

import.meta.env.SSR ? createMemoryHistory() : createWebHistory()
Enter fullscreen mode Exit fullscreen mode

Because the History API is not available on our server, we create a MemoryHistory, which is a mock History instance in the browser.
And import.meta.env is the ECMAScript version of `process.env'.

The 'main.ts` file is responsible for initializing the Vue instance of the application.

import App from './App.vue'
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter } from './router'

export const createApp = () => {
  const app = createSSRApp(App)
  const router = createRouter()
  // we will create a store instance right away for one thing
  // we'll need it very soon
  const pinia = createPinia()

  app.use(router)
  app.use(pinia)

  return {
    app,
    pinia,
    router,
  }
}
Enter fullscreen mode Exit fullscreen mode

File server-entry.ts:

import type { Request, Response } from 'express'
import { renderToString, SSRContext } from 'vue/server-renderer'
import serialize from 'serialize-javascript'
import { createApp } from './main'


interface AppContext {
  req: Request | null
  res: Response | null
  pinia: Pinia
  router: Router
  query: LocationQuery
  params: Record<string, string>
}

export type Manifest = Record<string, string[]>


export const render = async ({
  url,
  req,
  res,
  manifest,
}: {
  url: string
  manifest: Manifest
  req: Request
  res: Response
}) => {
  const { app, router, pinia } = createApp()

  router.push(url)
  await router.isReady()

  const ctx: SSRContext = {}
  const html = await renderToString(app, ctx)

  const preloadLinks = renderPreloadLinks(ctx.modules, manifest)

  const initialState = serialize(pinia.state.value)

  return {
    html,
    initialState,
    preloadLinks,
  }
}

function renderPreloadLinks(modules: string[], manifest: Manifest) {
  let links = ''
  const seen = new Set()
  modules.forEach((id) => {
    const files = manifest[id]
    if (files) {
      files.forEach((file: string) => {
        if (!seen.has(file)) {
          seen.add(file)
          const filename = basename(file)
          if (manifest[filename]) {
            for (const depFile of manifest[filename]) {
              links += renderPreloadLink(depFile)
              seen.add(depFile)
            }
          }
          links += renderPreloadLink(file)
        }
      })
    }
  })
  return links
}
function renderPreloadLink(file: string) {
  if (file.endsWith('.js')) {
    return `<link rel="modulepreload" crossorigin href="${file}">`
  } else if (file.endsWith('.css')) {
    return `<link rel="stylesheet" href="${file}">`
  } else if (file.endsWith('.woff')) {
    return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
  } else if (file.endsWith('.woff2')) {
    return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
  } else if (file.endsWith('.gif')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/gif">`
  } else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`
  } else if (file.endsWith('.png')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/png">`
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we are interested in several mements:

  • renderPreloadLinks and renderPreloadLink are functions responsible for rendering CSS/JS resources that we load on the client side, it is fully compatible with the Lazy-loading mechanism of Vue Router, they are automatically registered during page rendering in Vue Vite Plugin here.
  • serialize is an important part that provides protection from XSS attacks, by escaping the contents of the store serialize(pinia.state.value).

And now let's write client-entry.ts:

import { createApp } from './main'

const { app, router, pinia } = createApp()

router.isReady().then(() => app.mount('#app'))
Enter fullscreen mode Exit fullscreen mode

And this setup will allow us to launch the application already, and test how everything works.

Context

Now I propose to introduce a new concept of Context, I think many are familiar with it from Nuxt/Next frameworks.
So that's exactly what we will transfer to our middlewares and async Data page functions.

Context

It consists of:

  • req/res - available only on the server side, I do not recommend using them directly.
  • pinia is an instance of our store, you will need it to use the store, because on the client side Pinia refers to a global object, but on the server side you cannot do this, because in the process of rendering our application on the server, we can simultaneously open different pages with several requests that cause their own actions, and as a result, the entire rendering process will refer to the same instance of the store, and we need to isolate the rendering process for each request. Here you can see how when calling defineStore Pinia tries to get an "active" instance.
  • query - query from the router
  • params - params from the router
  • router - router instance

And so when we have formed the Context, we can proceed to the work of middlewares and async Data.

Data fetching, middlewares and Context implementation

For data fetching we choose asyncData fetching strategy, because it is faster in case with nested routes.
And we have one limitation, all page level components that use asyncData need to use defineComponent API.

Your project should have a declaration file vue.d.ts, and in it you need to add new parameters asyncData, and middleware to Vue ComponentCustomOptions:

import type { AppContext as SSRAppContext } from './ssr'

// Typing for middleware
type RedirectLocation = {
  path: string
  status: number
}
type RedirectTo = void | string | RedirectLocation
type Middleware = (ctx: AppContext) => RedirectTo | PromiseLike<RedirectTo>


declare module '@vue/runtime-core' {
  interface ComponentCustomOptions {
    asyncData?(ctx: SSRAppContext): void
    middleware?: Middleware | Middleware[]
  }
}

export {}
Enter fullscreen mode Exit fullscreen mode

We will use the ComponentCustomOptions data at the page component level.

  • asyncData - option for uploading data
  • middleware - serves as an analogue of Navigation Guards from Vue Router, but unlike it will work both on the client side and on the server. You can call it guard if you/your team is more used to it.

And now let me show you clearly what it will look like:

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  middleware: () => '/redirected-to', // You can write a separate file for middleware, and import it here
})
</script>
Enter fullscreen mode Exit fullscreen mode

It's very similar to Navigation Guards from Vue Router?
Isn't it? That's just where the Context of our application is available to you, and you can contact Pinia for example to verify authorization

const authMiddleware: Middleware = ({ pinia }) => {
  // !!! Important !!!
  // tell all the stores you are contacting
  // Pinia instance, otherwise you will have problems because Pinia will access the global object
  // https://github.com/vuejs/pinia/blob/8626aac0049243de231401a01fe20092eeaf279c/packages/pinia/src/store.ts#L870
  if (!authStore(pinia).isAuth) {
    return {
      path:'/login',
      status: 401,
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And here is an example of using asyncData:

<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useStore } from '../../store'

export default defineComponent({
  asyncData: ({ pinia, params }) => useStore(pinia).fetchRequisites(params.id),
  setup() {
    const store = useStore()

    const requisites = computed(() => store.requisites)

    return {
      requisites,
    }
  },
})
</script>
Enter fullscreen mode Exit fullscreen mode

And at the same time there are two important points:

  • At the time of calling the store in asyncData, pass pinia there from the application context, otherwise there will be problems when rendering on the server side
  • In case of calling several actions that do not depend on each other, use Promise.all to run them in parallel, this is one of the most common mistakes of beginners when working with SSR, which has a very negative effect on the opening time of the page, do this:
export default defineComponent({
  asyncData: ({ pinia, params }) => Promise.all([
    useStore(pinia).fetchRequisites(params.id),
    useStore(pinia).fetchHome(),
  ]),
})
Enter fullscreen mode Exit fullscreen mode

And now let's finally put the work of data ham and middle vars into action. And since middleware will have the ability to redirect the user, which should work both on the client and on the server, let's add a class with the error RedirectError:

export class RedirectError extends Error {
  redirectTo: string
  status: number
  _isRedirect = true

  constructor(redirectTo: string, status = 302) {
    super()

    this.redirectTo = redirectTo
    this.status = status
  }
}
Enter fullscreen mode Exit fullscreen mode

In which we will pass the url for the redirect and the status of the redirect code, which by default is 302.
Many may say that a redirect by throwing an error is not the "right" thing, but it is firstly used only inside service functions, and does not affect business logic, and secondly it simplifies the mechanism of redirects.
And now let's make our middlewares work:

import { DefineComponent } from 'vue'

export const fireMiddlewares = async (
  components: DefineComponent[],
  context: AppContext
) => {
  // We take out the CustomOption middleware from the components
  const middles = components
    .map(c => c.middleware)
    .filter(Boolean)
    .flat() as Middleware[]

  if (middles.length !== 0) {
    const redirects = (
      await Promise.all(middles.map(m => m(context)))
    ).filter(Boolean)

    if (redirects.length !== 0) {
      for (const to of redirects) {
        if (typeof to === 'string') {
          throw new RedirectError(to)
        } else if (typeof to === 'object') {
          throw new RedirectError(to.path, to.status)
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We will pass the application context and page components to the fireMiddlewares function, and I will tell you how to get them later.
In the meantime, let's write the same function for fetching data:

import { DefineComponent } from 'vue'

export const fireAsyncData = async (
  components: DefineComponent[],
  context: AppContext
) => {
  // We take it out of the components CustomOption asyncData
  const asyncs = components.map(c => c.asyncData).filter(Boolean) as Exclude<ComponentCustomOptions['asyncData'], undefined>[]

  if (asyncs.length !== 0) {
    await Promise.all(asyncs.map(a => a(context)))
  }
}
Enter fullscreen mode Exit fullscreen mode

And after we have the opportunity to run async Data and middleware CunstomOptions, we will proceed to calling them in entry-client.ts:

import { createApp } from './main'
import { AppContext, fireAsyncData, fireMiddlewares, matchedComponents, RedirectError } from './ssr'

const { app, router, pinia } = createApp()

router.isReady().then(() => {
  pinia.state.value = window.__pinia

  router.beforeResolve(async (to, _from, next) => {
    try {
      const context: AppContext = {
        req: null,
        res: null,
        pinia,
        router,
        query: to.query,
        params: to.params as AppContext['params'],
      }

      const components = matchedComponents(to.matched)

      await fireMiddlewares(components, context)
      await fireAsyncData(components, context)

      next()
    } catch (e) {
      if (e instanceof RedirectError) {
        return next(e.redirectTo)
      }

      throw e
    }
  })

  app.mount('#app')
})
Enter fullscreen mode Exit fullscreen mode

And I propose to sort out how everything works:

  • pines.state.value = window.__pinia - filling the store with serialized data
  • context - We form the application context on the client side.
  • router.beforeResolve - Vue Router has the most convenient hook that is called after Lazy-components are loaded, but not yet shown to the user, it can be used to work middlewares and asyncData on the client.
  • matchedComponents - Vue Router in the to.matched parameter, passes all the components of the pages, it is them that we normalize in the matchedComponents function, its implementation will be below.
  • RedirectError - in the catch block, we process errors, and immediately we can intercept all RedirectError errors and redirect on the client side.

Here is the implementation of matchedComponents:

import type { RouteRecordNormalized } from 'vue-router'
import type { DefineComponent } from 'vue'

export const matchedComponents = (matched: RouteRecordNormalized[]) =>
  matched.map((m) => Object.values(m.components)).flat() as DefineComponent[]
Enter fullscreen mode Exit fullscreen mode

And now you can update server-entry.ts with peace of mind:

export const render = async ({
  url,
  req,
  res,
  manifest,
}: {
  url: string
  manifest: Manifest
  req: Request
  res: Response
}) => {
  const { app, router, pinia } = createApp()

  router.push(url)
  await router.isReady()

  const _route = router.currentRoute.value

  const context: AppContext = {
    req,
    res,
    pinia,
    router,
    query: _route.query,
    params: _route.params as AppContext['params'],
  }

  const components = matchedComponents(_route.matched)

  await fireMiddlewares(components, context)
  await fireAsyncData(components, context)

  const ctx: SSRContext = {}
  const html = await renderToString(app, ctx)

  const preloadLinks = renderPreloadLinks(ctx.modules, manifest)

  const initialState = serialize(pinia.state.value)

  return {
    html,
    initialState,
    preloadLinks,
  }
}
Enter fullscreen mode Exit fullscreen mode

And also in server.js in the catch block, you need to add redirects to third-party servers to work:

if (e._isRedirect) {
  res.status(e.status)
  res.redirect(e.redirectTo)

  return
}
Enter fullscreen mode Exit fullscreen mode

And now we have a fully working mechanism for restricting access to pages and a date ham πŸ₯³
But there is still a lot of work to do with the integration of Pinia and Vue Router, and work with Head.

Integration router redirects, HTTP Status codes with Pinia

So, I think many people are not familiar with the concept of integrating a router into the Store, but in fact they most likely met the Connected React Router library, it allows, for example, with successful authentication directly in the action, to redirect the user to the /profile or /dashboard page, this allows the authentication logic do not spread beyond the action.
To begin with, we will write timings for the state of this store.

type HistoryState = {
  _router: Router | null
  _redirectUrl: string | null
  status: number
}
Enter fullscreen mode Exit fullscreen mode

Let's look at what we have here and why:

  • _router is a link instance of our Vue Router, in order to have access to calling the push, replace method, etc., so that redirects work on the client side.
  • _redirectUrl - in case of a redirect, there will be a url to which we need to redirect the user. Used only on the server side.
  • status - HTTP Status code of the server response, whether it is 200, 302, or 404.

And now we will write the very implementation of the store that will be responsible for this integration:

export const useHistory = defineStore('history', {
  state: (): HistoryState => ({
    _router: null,
    _redirectUrl: null,
    status: 200,
  }),
  actions: {
    setStatus(status: number) {
      this.status = status
    },
    _setRouter(_router: HistoryState['_router']) {
      (this._router as unknown as HistoryState['_router']) = _router
    },
    push(path: string, status = 302) {
      this.status = status
      this._redirectUrl = path
      this._router?.push(path)
    },
    replace(path: string, status = 302) {
      this.status = status
      this._redirectUrl = path
      this._router?.replace(path)
    },
    go(delta: number) {
      this._router?.go(delta)
    },
    back() {
      this._router?.go(-1)
    },
    forward() {
      this._router?.go(1)
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

And so, let's go through the methods:

  • push, replace, back, go, forward - their purpose is simply to proxy calls to the router, and in case of a redirect, write its HTTP Code, and redirectUrl itself.
  • _setRouter - since we cannot directly access global objects in the case of server rendering, we need a method in order to bind/unlink the router instance to the store.
  • setStatus - the ability to manually set the status of the response code, for example, when the API returns a 404 code to us in order to let search engines understand that the page they are looking for has not been found.

And now the example of using this integration is clear, let's imagine that we have a Pinia store with the fetchRubric action:

async fetchRubric(rubricSlug: string, citySlug: string) {
  try {
    const {
      city,
      rubric,
      reviews,
    } = await http.get(`/rubrics/page/${rubricSlug}/${citySlug}`)

    this.reviews = reviews
    this.data = {
      city,
      rubric,
    }
  } catch (e: any) {
    this.error = e
    useHistory(this._p).setStatus(e.status === 404
      ? 404
      : 500)
  }
},
Enter fullscreen mode Exit fullscreen mode

We can see here how in the case of an API response with a 404 error, we say that the user should also receive a 404 code.

  • You may have noticed that the call to the store useHistory(this._p) is accompanied by the parameter this._p, this is a mandatory parameter that passes the Pinia instance to the store, by analogy as we did in CustomOption asyncData and middleware. Or here for example is an example of an authentication action:
async login(values: LoginValues) {
  this.isSending = true

  try {
    const { accessToken } = await http.post('/auth/login', values)

    setAccessToken(accessToken)
    useHistory(this._p).push('/profile')
  } catch (e) {
    if (e instanceof Response) {
      return (await e.json()).message
    }
  } finally {
    this.isSending = false
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case, in case of a successful response, we redirect the user to the profile url.

And now let's make server-entry.ts and client-entry.ts be able to work with this integration.
I suggest starting with server-entry.ts:

export const render = async ({
  url,
  req,
  res,
  manifest,
}: {
  url: string
  manifest: Manifest
  req: Request
  res: Response
}) => {
  // ...code

  const context: AppContext = {
    req,
    res,
    pinia,
    router,
    query: _route.query,
    params: _route.params as AppContext['params'],
  }

  const historyStore = useHistory(pinia)
  useHistory(pinia)._setRouter(router)

  const components = matchedComponents(_route.matched)

  await fireMiddlewares(components, context)
  await fireAsyncData(components, context)

  if (historyStore._redirectUrl) {
    throw new RedirectError(historyStore._redirectUrl, historyStore.status)
  }
  if (historyStore.status !== 200) {
    res.status(historyStore.status)
  }

  const ctx: SSRContext = {}
  const html = await renderToString(app, ctx)

  const preloadLinks = renderPreloadLinks(ctx.modules ?? [], manifest)

  useHistory(pinia)._setRouter(null)
  const initialState = serialize(pinia.state.value)

  return {
    html,
    initialState,
    preloadLinks,
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, after creating the context, we have added the following steps:

  • Let's create an instance of our store with the name historyStore.
  • Binding to the historyStore instance of the router useHistory(pinia)._setRouter(router).
  • Checking if (historyStore._redirectUrl) for a redirect.
  • Checking if (historyStore.status !== 200) to change the response code.
  • And before the serialization stage of the store, we untie the router instance useHistory(pinia)._setRouter(null) because it is not needed during the hydration of the serialized store, d and in general on the server side will no longer be needed.

Everything is fine with the server.
Next in line is client-entry.ts:

router.isReady().then(() => {
  pinia.state.value = window.__pinia
  useHistory(pinia)._setRouter(router)

  router.beforeResolve(async (to, _from, next) => {
    // no changes
  })

  app.mount('#app')
})
Enter fullscreen mode Exit fullscreen mode

Here we just need to link the router instance to the store, and that's it, the integration will work correctly on the client side.
Now we can store all the logic inside our actions 😍

Head and SEO tags

I think it's no secret that Server Rendering is usually resorted to if search engines need correct indexing.
To work with head, Vue 3 already has the vueuse/head library, while it supports SSR. You can find all the documentation on working with it in their repository, and I'll show you a simple example and help you introduce it to server rendering.
And so in the application code, you just need to call useHead at any level of component nesting:

<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useHead } from '@vueuse/head'

export default defineComponent({
  setup() {
    const rubricStore = useRubricStore()

    // code...

    useHead({
      title: computed(() => rubricStore.data!.rubric.title),
      meta: [
        {
          name: 'description',
          content: computed(() => rubricStore.data!.rubric.metaDescription),
        },
      ],
    })

    // code...
  }
})
</script>
Enter fullscreen mode Exit fullscreen mode

Update the main.ts file, add the initialization of the VueHead instance there:

import App from './App.vue'
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createHead } from '@vueuse/head'
import { createRouter } from './router'

export const createApp = () => {
  const app = createSSRApp(App)
  const router = createRouter()
  const pinia = createPinia()
  const head = createHead()

  app.use(router)
  app.use(pinia)
  app.use(head)

  return {
    app,
    head,
    pinia,
    router,
  }
}
Enter fullscreen mode Exit fullscreen mode

And in the file server-entry.ts we will add the following:

import { renderHeadToString } from '@vueuse/head'

export const render = async ({
  url,
  req,
  res,
  manifest,
}: {
  url: string
  manifest: Manifest
  req: Request
  res: Response
}) => {
  const { app, router, pinia, head } = createApp()

  // code...

  const ctx: SSRContext = {}
  const html = await renderToString(app, ctx)

  const { headTags, htmlAttrs, bodyAttrs } = renderHeadToString(head)

  const preloadLinks = renderPreloadLinks(ctx.modules ?? [], manifest)

  // code...

  return {
    html,
    initialState,
    preloadLinks,
    headTags,
    htmlAttrs,
    bodyAttrs,
  }
}
Enter fullscreen mode Exit fullscreen mode

In index.html add a comment <!--head-tags--> at the end of the <head /> tag, and I also recommend adding comments for the <html /> and <body /> tags to insert attributes <html${htmlAttrs}> and <body${bodyAttrs}> accordingly, they will be needed in order to replace them with real tags formed by vueuse/head. Read more in my home repository.

And in server.js it's enough to add the following to the handler:

app.use('*', async (req, res) => {
  try {
    // code...

    const {
      html: appHtml,
      preloadLinks,
      headTags,
      htmlAttrs,
      bodyAttrs,
      initialState,
    } = await render({
      url: req.originalUrl,
      req,
      res,
      manifest,
    })

    const html = template
      .replace('<!--head-tags-->', headTags)
      .replace(' ${htmlAttrs}', htmlAttrs)
      .replace(' ${bodyAttrs}', bodyAttrs)
      .replace('<!--preload-links-->', preloadLinks)
      .replace('<!--app-html-->', appHtml)
      .replace('window.__pinia = {};', `window.__pinia = ${initialState};`)

    res.set({ 'Content-Type': 'text/html' }).end(html)
  } catch (e) {
    // code...
  }
})
Enter fullscreen mode Exit fullscreen mode

Here we are most interested in using replace on our template, it is this that allows us to throw data into index.html .
And now you have the opportunity to display any SEO tags you like.
But there is still one more important topic in the SSR case.

Caching

Server rendering solves some SPA problems, but it has one significant disadvantage, it is INSANELY EXPENSIVE.
Just think about it, you need to initialize an entire JS application every request, despite the fact that Vue is much faster than React in this case, it is not easy to withstand a sharply increased flow of users in the case of a successful advertising company. πŸ˜…
Also DDOS attacks will quickly put your server on the shoulder blades, given that SSR is about heavy synchronous computing, which is the weak point Node.js. And caching the server response in the case of SSR helps a lot, and has a positive effect on such a metric as "Time to first byte".

And so in Vue 2 we had such a cool feature as serverCacheKey, this is the thing that allowed caching at the component level.
But there is no such chip in Vue 3, here are issues. However, considering how much the SFC Compiler was optimized, the benefit from serverCacheKey would not be so great.

Therefore, the most convenient way of caching will be to use Redis. There are two ways:

  • Cache using Redis at the Nginx level by writing a LUA script.
  • Or use Redis at the Node.js level of the process.

The first option should be faster, but the second is easier to implement.
However, there remains the problem of resetting the cache. Here I will not show a specific implementation, but I will give a couple of tips, including how to reset the cache:

  • Caching the server response of authorized users does not make much sense.
  • For "guest" sessions, you can use the url as the cache key.
  • When restarting Node.the js of the process responsible for SSR should reset the cache. Also, in the case of restarting the Backend, it is also worth resetting the cache, you can simply send a message to Redis Pub/Sub, and Node.the js process responsible for the SSR will already listen to it, and reset the cache itself, so that the Backend does not need to know how the cache works.
  • But on a large project to reset the cache, you may need to make a more complex cache reset scheme when updating data on the Backend, and send to the front the entity that has been changed, and the front itself will decide which keys it needs to disable.

Well, don't forget about multithreading, it can be covered with Worker threads and the Cluster module, PM2, Docker.

Conclusion

And in the end, let me present a production application that uses Vue 3 and Vite:

Vue 3 lighthouse

A good reason to choose Vue for SSR is it's a very fast hydration process.

Achieving a 90+ performance score on the Lighthouse mobile test is not as difficult as it is with React.
Yeah, Vue Team did a great job πŸ‘

You can find all the code from the article and an example of the project here.

Top comments (0)