Exploring Vue SSR with Fastify

Exploring Vue SSR with Fastify

In this article, we'll look at the state of native Vue integration in the Fastify framework.

Vite has quickly become the industry standard for bundling modern applications, with frameworks like Nuxt, SvelteKit and SolidStart all based on it. Vite uses native ESM to quickly deliver files in development mode, but still produces a bundle for production. Check out the motivation page in Vite's documentation to learn more.

Fastify has a mature and well-maintained core plugin for Vite integration. It allows you to load any Vite application as a module in your Fastify server scope, both in production and development modes, among many other things.

Tutorial: Vue SSR Hello World

Even though @fastify/vite allows you to simply serve a static Vite application as a SPA, its real value lies in making it possible to build hybrid SPA and SSR applications, i.e., where you can perform Server-Side Rendering (SSR), but once pages are loaded (first render), they start working as a SPA and no further full page requests are necessary.

This basic hybrid routing pattern is fully available out of the box via the @fastify/vue package, which wraps a renderer for @fastify/vite. It's built on the latest versions of Vue and Vue Router and supports the hybrid SSR and CSR (Client-Side Rendered) navigation architecture employed by Nuxt and Next.js.

Let's get started with it by building a simple Hello World application, and examine closely what's going on behind the scenes every step of the way.

The documentation recommends using one of the starter templates to get started, as they're already packed with a package.json file containing not only the dependencies, but also the build scripts and base ESLint configuration.

First, install giget if you haven't already:

npm i giget -g

Next, pull a copy of the vue-base starter template and install all dependencies:

giget gh:fastify/fastify-vite/starters/vue-base#dev hello-world
cd hello-world
npm i

For reference, this is what package.json looks like (ESLint omitted):

{
  "name": "vue-base",
  "description": "The vue-base starter template for @fastify/vue",
  "type": "module",
  "scripts": {
    "dev": "node server.js --dev",
    "build": "npm run build:client && npm run build:server",
    "start": "node server.js",
    "build:client": "vite build --outDir dist/client --ssrManifest",
    "build:server": "vite build --outDir dist/server --ssr /index.js",
    "lint": "eslint . --ext .js,.jsx --fix"
  },
  "dependencies": {
    "@fastify/one-line-logger": "latest",
    "@fastify/vite": "^6.0.3",
    "@fastify/vue": "^0.5.0",
    "fastify": "^4.24.3",
    "unihead": "^0.0.6",
    "vue": "^3.4.19",
    "vue-router": "^4.2.5"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.5.0",
    "vite": "^5.0.2",
    "postcss": "^8.4.31",
    "postcss-nesting": "^12.0.2",
    "postcss-preset-env": "^7.7.1",
    "tailwindcss": "^3.4.1"
  }
}

There's very little beyond Fastify, Vite, @fastify/vite, PostCSS and Tailwind in this boilerplate. It also includes PostCSS Preset Env and CSS Nesting out of the box.

Before we dive into it, start it with npm run dev and navigate to http://localhost:3000 to make sure you're all set:

Configuring Fastify

The starter template comes with the following server.js:

import Fastify from 'fastify'
import FastifyVite from '@fastify/vite'

const server = Fastify({
  logger: {
    transport: {
      target: '@fastify/one-line-logger'
    }
  }
})

await server.register(FastifyVite, { 
  root: import.meta.url, 
  renderer: '@fastify/vue',
})

await server.vite.ready()
await server.listen({ port: 3000 })

A few things of note here:

  • The server is using @fastify/one-line-logger for human readable logs during development, but you'll want to condition that to process.stdout.isTTY so it continues to produce regular JSON logs for production.

  • The @fastify/vite plugin registration needs to be awaited on for proper function. Before starting the server, it also requires you to await on the vite.ready() method. In development mode, this ensures Vite's development server middleware has been started. In production, it simply loads your Vite production bundle.

  • @fastify/vite's development mode is enabled automatically by detecting the presence of the --dev flag in the arguments passed to the Node.js process running your Fastify server. This default behavior can be changed as follows:

await server.register(FastifyVite, { 
  dev: process.argv.includes('--custom-dev-flag'),
  root: import.meta.url, 
  renderer: '@fastify/vue',
})

It could also use an environment variable for the same purpose.

You can replace parts of it with your own should the need ever present itself:

import FastifyVue from '@fastify/vue'

await server.register(FastifyVite, { 
  dev: process.argv.includes('--custom-dev-flag'),
  root: import.meta.url, 
  renderer: {
    ...FastifyVue,
    createRoute () {
      // Your custom createRoute implementation
    }
  }
})

Configuring Vite

The starter template comes with the following vite.config.js:

import { join, dirname } from 'path'
import { fileURLToPath } from 'url'

import viteVue from '@vitejs/plugin-vue'
import viteFastifyVue from '@fastify/vue/plugin'

const path = fileURLToPath(import.meta.url)

export default {
  root: join(dirname(path), 'client'),
  plugins: [
    viteVue(), 
    viteFastifyVue(),
  ],
}

Notice that the Vite application code is kept in a client subfolder, as indicated by the root setting in the config. This convention is recommended by´ @fastify/vite to maintain a clear separation of client and server code

Other than that, it packs standard Vite + Vue 3 configuration.

There's only the addition of @fastify/vue/plugin, the Vite plugin included in @fastify/vue to make smart imports available.

Note: One example of a smart import is /:root.vue. The /: prefix tells @fastify/vue/plugin to provide the internal root.vue file, part of the package itself, but only in case it doesn't find a root.vue file in your project root.

This is useful because most applications don't require any changes, for instance, to the mount.js script that mounts the Vue application. @fastify/vue implements a simple Nuxt-like Vue application shell using these files, but also like in Nuxt, these files don't exist in your project for the application to load.

If you do want to customize how it works, you can shadow any of these internal modules by making your Vite project root contain a file with the same name. See the full list of files provided via smart imports by @fastify/vue here.

Configuring the Vue application shell

The starter template comes with the minimum amount of files needed to get you started, including client/index.html, used by Vite to resolve your dependencies, and client/index.js, used by @fastify/vite to gain access to your Vite application code on the server-side.

Let's begin by looking at client/index.html:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/base.css">
<!-- head -->
</head>
<body>
<div id="root"><!-- element --></div>
</body>
<!-- hydration -->
<script type="module" src="/:mount.js"></script>
</html>

Notice the HTML comment placeholders for head, element and hydration. When @fastify/vite loads your Vite application, it'll load index.html and compile it into a templating function and register it as the reply.html() method.

You can use your own implementation of a templating function by providing your custom createHtmlTemplateFunction(). The default in implementation accepts the dot notation in placeholders, and also allows for any fragment to be a Node.js Readable instance. You can copy and tweak it to your needs, e.g., you might want to remove the ability to support fragments as Readable instances if you have no use for this feature.

See @fastify/vue's implementation of the createHtmlFunction() hook here. You'll very rarely need to override this, but it's conveniently simple to do so if you ever need to.

As for SSR itself, it's implemented via createRenderFunction(), another configuration hook for @fastify/vite. See @fastify/vue's internal implementation below:

export async function createRenderFunction ({ routes, create }) {
  // Create convenience-access routeMap
  const routeMap = Object.fromEntries(routes.toJSON().map((route) => {
    return [route.path, route]
  }))
  // create is exported by client/index.js
  return async function (req) {
    let stream = null
    let body = null
    // Creates main Vue component with all the SSR context it needs
    if (!req.route.clientOnly) {
      const app = await create({
        routes,
        routeMap,
        ctxHydration: req.route,
        url: req.url,
      })
      if (req.route.streaming) {
        stream = renderToNodeStream(app.instance, app.ctx)
      } else {
        body = await renderToString(app.instance, app.ctx)
      }
    }
    // Perform SSR, i.e., turn app.instance into an HTML fragment
    // The SSR context data is passed along so it can be inlined for hydration
    return { routes, context: req.route, body, stream }
  }
}

Like createHtmlFunction(), you'll rarely need to provide your own version of it. It's displayed above to demonstrate its simplicity.

Both hooks covered above rely on @fastify/vite's ability to load the Vite application as a module. For this to work, it relies on having client/index.js defined as follows:

import routes from '/:routes.js'
import create from '/:create.js'

export default { 
  context: import('/:context.js'), 
  routes,
  create,
}

It needs to provide access to both the Vue page routes and the main Vue component create function. Every object in the routes array needs to contain a path and a component property, among other settings. This is automatically handled by the internal virtual module routes.js, imported here via the /:routes.js smart import.

It also needs to provide the route context initialization file, used by @fastify/vue to populate the return value of the useRouteContext() function, also provided internally via the /:core.js smart import. In this case, if you don't provide a context.js file, an empty one is provided automatically via the smart import.

Injecting Vue global properties

We'll cover the context.js file shortly, but first, let's address injecting global properties into your Vue application. Nuxt users will typically use a plugin for this purpose.

With this, you can place a root.vue file at the root of your Vite project, shadowing the one provided internally by @fastify/vue, and export a configure() function:

<script>
import Helper from '/helper.js'

export { default } from '/:router.vue'

export function configure({ app }) {
  app.config.globalProperties.$helper = new Helper()
}

The route context initialization file

One might say the context.js file is a peculiar feature of @fastify/vue, but Nuxt users will find it somewhat similar to using the Nuxt v2 store/ folder.

The context.js file exists mainly for two main special exports, shown below:

export function state () {
  return {
    user: {
      authenticated: false,
    },
  }
}

export default (ctx) => {
  if (ctx.req?.user) {
    ctx.state.authenticated = true
  }
}
  • The state() function export is used as a reactive() store initializer. It has special processing for adequate serialization and hydration.

  • The default export function runs exactly once on the server and once on the client during first render. This means that it is not executed again in subsequent client-side navigation.

Consuming the route context

In our vue-base starter template, let's change server.js to include a req.user decoration if a certain magic query parameter is set:

server.decorate('user', false)
server.addHook('onRequest', (req, _, done) => {
  if (req.query.magic === '1') {
    req.user = true
  }
  done()
})

Next, we'll add a context.js file defined exactly as the one listed in the previous section, and update the pages/index.vue template to consume state from the route context. The useRouteContext() is a hook provided by @fastify/vue's internal core.js virtual module and can be used in every component.

<script setup>
import { useRouteContext } from '/:core.js'
import logo from '/assets/logo.svg'

const { state } = useRouteContext()
const message = 'Welcome to @fastify/vue!'
</script>

<template>
  <h1>{{ message }}</h1>
  <p><img :src="logo" /></p>
  <p v-if="state.authenticated">
    You're authenticated!
  </p>
</template>

Run it with node server.js --dev and navigate to http://localhost:3000/?magic=1:

Note that state is simply a reactive() instance.

You can still use root.vue to define another type of store, or even provide your own versions of core.js and create.js to do so.

Data fetching via getData()

@fastify/vue implements the getServerSideProps()-style of data fetching.

Route modules can export a getData() function. This always runs only on the server, regardless of whether it's executed for SSR or triggered by an API request during client-side navigation.

@fastify/vue takes care of automatically registering an internal API endpoint for it and calling it every time there's client-side navigation, very much like Next.js.

Let's expand pages/index.vue to include this:

<script>
export async function getData () {
  const request = await fetch('https://jsonplaceholder.typicode.com/posts/1')
  return await request.json()
}
</script>

<script setup>
import { useRouteContext } from '/:core.js'
import logo from '/assets/logo.svg'

const { state, data } = useRouteContext()
const message = 'Welcome to @fastify/vue!'
</script>

<template>
  <h1>{{ message }}</h1>
  <p><img :src="logo" /></p>
  <p v-if="state.authenticated">
    <p>You're authenticated!</p>
  </p>
  <h2>{{ data.title }}</h2>
  <p>{{ data.body }}</p>
  <footer>
    <p><router-link to="/other">Go to /other page</router-link></p>
  </footer>
</template>

The key thing to understand in this example is that by the time useRouteContext() runs, the getData() function will already have been executed. In case it's triggered by client-side navigation, an API request is automatically fired and managed via an internal Vue Router beforeEach() handler registered by @fastify/vue.

Head management via getMeta()

Similarly, you can export getMeta() from route modules to determine the shape of your <head> document section, namely, provide the value for the <title> tag and additional <link> and <script> tags.

<script>
export function getMeta () {
  return {
    title: 'Welcome to @fastify/vue!'
  }
}
</script>

Building and serving in production mode

To run it in production mode, just remove the --dev flag. This also requires you to run vite build beforehand so the production bundle is available when @fastify/vite runs.

The catch here is that you need run vite build twice:

  • vite build --outDir dist/client --ssrManifest

    • This produces the client bundle, which is loaded by the client via index.html.
  • vite build --outDir dist/server --ssr /index.js

    • This produces the server bundle, which is what gets exposed to Fastify on the server, namely client/index.js, which in turns exports the routes array and anything else that might be needed on the server, accessible via @fastify/vite's hooks as seen before.

Here's how you need to set up your build scripts in package.json:

{
  "scripts": {
    "build": "npm run build:client && npm run build:server",
    "build:client": "vite build --outDir dist/client --ssrManifest",
    "build:server": "vite build --outDir dist/server --ssr /index.js",
  }
}

Notice that for the client build, we also tell Vite to produce a SSR manifest file. This is a JSON file mapping all client modules and their imported modules to bundle asset URLs. It's loaded automatically by @fastify/vite and made available in runtime in production mode.

Movie Quotes App: Vue rewrite

For this part, we're using a fork of the Movie Quotes App tutorial source code. This app actually contains two services, one for the API, and another for serving the frontend.

└── apps/
    ├── movie-quotes-api
    └── movie-quotes-frontend

The API is served from movie-quotes-api, a Fastify application running via the Platformatic DB CLI. The frontend is an Astro SPA which makes requests to the API retrieving data only. The frontend is served in this example via Astro's own development server, but its bundle could be served from a variety of servers.

It's a simple app with four routes:

  • the index page, pages/index.astro.

  • the add movie quote page, pages/add.astro.

  • the edit quote page, pages/edit/[id.astro].

  • and the delete quote page, pages/delete/[id.astro].

All movie quote associated actions have their own components as well. There are also some vanilla JavaScript libraries responsible for a few bits of functionality, such as talking to the Platformatic DB GraphQL API and confirming actions before running them.

Below is the full source code structure for the original movie-quotes-frontend service.

.
└── movie-quotes-frontend-htmx/
    ├── components/
    │   ├── QuoteActionDelete.astro
    │   ├── QuoteActionEdit.astro
    │   ├── QuoteActionLike.astro
    │   └── QuoteForm.astro
    ├── layouts/
    │   └── Layout.astro
    ├── lib/
    │   ├── quotes-api.js
    │   └── request-utils.js
    ├── pages/
    │   ├── delete/
    │   │   └── [id].astro
    │   ├── edit/
    │   │   └── [id].astro
    │   ├── add.astro
    │   └── index.astro
    └── scripts/
        └── quote-actions.js

Rewriting the boilerplate

We'll start by creating a exact copy of movie-quotes-frontend for the Vue version, making the directory structure now look like the following:

└── apps/
    ├── movie-quotes-api
    ├── movie-quotes-frontend
    └── movie-quotes-frontend-vue

Next, in movie-quotes-frontend-vue we'll add server.js to replace the Astro server:

import Fastify from 'fastify'
import FastifyVite from '@fastify/vite'
import FastifyFormBody from '@fastify/formbody'

const server = Fastify({
  logger: {
    transport: {
      target: '@fastify/one-line-logger'
    }
  }
})

await server.register(FastifyFormBody)
await server.register(FastifyVite, { 
  root: import.meta.url, 
  renderer: '@fastify/vue',
})

await server.vite.ready()
await server.listen({ port: 3000 })

And a vite.config.js file to import @fastify/vues accompanying Vite plugin and set up CSS modules to use the camel case convention for class names:

import { join, dirname } from 'path'
import { fileURLToPath } from 'url'

import inject from '@rollup/plugin-inject'
import viteFastifyVue from '@fastify/vue/plugin'

export default {
  root: join(dirname(fileURLToPath(import.meta.url)), 'client'),
  plugins: [
    viteFastifyVue()
  ],
  css: {
    modules: {
      localsConvention: 'camelCase'
    }
  }
}

Loading .env files

Astro handles .env files automatically, and so does Vite. In fact, Astro's support for .env files comes from Vite's own built-in support. But we also need to make .env files available to Fastify. In this example we'll use fluent-env, a convenient wrapper around env-schema and fluent-json-schema.

All we need to do is add the following import at the top of server.js:

import `fluent-env/auto`

And change the PUBLIC_GRAPHQL_API_ENDPOINT variable to VITE_GRAPHQL_API_ENDPOINT to match Vite's default standard. Only environment variables prefixed with VITE_ are allowed to make it into the bundle.

In the end, this is not going to be required since we're not ever running code that relies on the single environment variable of this application on the client, but it's still good to know.

Rewriting the layout component

The main difference from the Astro version is that the main HTML shell needs to live in an index.html file, which is just the body rendered by the main layout component.

Here's how layouts/default.vue reads:

<script setup>
import { useRouteContext } from '/:core.js'

const context = useRouteContext()
const { page } = context.data

const navActiveClasses = 'font-bold bg-yellow-400 no-underline'
</script>

<template>
  <header class="prose mx-auto mb-6">
    <h1>🎬 Movie Quotes</h1>
  </header>
  <nav class="prose mx-auto mb-6 border-y border-gray-200 flex">
    <a href="/?sort=createdAt" :class="['p-3', page === 'listing-createdAt' ? navActiveClasses : '']">Latest quotes</a>
    <a href="/?sort=likes" :class="['p-3', page === 'listing-likes' ? navActiveClasses : '']">Top quotes</a>
    <a href="/add" :class="['p-3', page === 'add' ? navActiveClasses : '']">Add a quote</a>
  </nav>
  <section class="prose mx-auto">
    <slot />
  </section>
</template>

Rewriting the ‘like’ action component

In the original Astro version, the heart SVG that serves to perform a ‘like’ action on a movie quote is controlled by client-side code, triggering client-side requests to the GraphQL API. That means importing @urql/core on the client bundle, a 20kb+ library.

Let's avoid that by going with a server-side endpoint.

First we'll add an endpoint to return the number of likes for a movie quote, in plain-text:

server.post('/api/like-movie-quote/:id', async (req, reply) => {
  const id = Number(req.params.id)
  const liked = await req.quotes.graphql({
    query: `
      mutation($id: ID!) {
        likeQuote(id: $id)
      }
    `,
    variables: { id },
  })
  reply.type('text/plain')
  reply.send(liked.toString())
})

Note that we're already using Platformatic's auto-generated GraphQL client in this snippet. This is a very simple POST endpoint which runs a mutation and returns the updated value. Now, we can use it in our new QuoteActionLike.vue component to do the update:

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

const props = defineProps<{
  id: string
  likes: number
}>()

const likes = ref(props.likes)

async function likeQuote() {
  const req = await fetch(`/api/like-movie-quote/${props.id}`, { method: 'POST' })
  const newLikes = Number(await req.text())
  if (newLikes !== likes) {
    likes.value = newLikes
  }
}
</script>

<template>
  <span 
    @click.once="likeQuote"
    class="like-quote mr-5 flex items-center"
    :class="{'cursor-pointer': likes === 0, liked: likes > 0}">
    <svg 
      class="like-icon w-6 h-6 mr-2 text-red-600"
      xmlns="http://www.w3.org/2000/svg" 
      fill="none" 
      viewBox="0 0 24 24" 
      stroke-width="1.5" 
      stroke="currentColor" >
        <path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
      </svg>
      <span class="likes-count" w-8>{{ likes }}</span>
  </span>
</template>

<style scoped>
  .like-quote:hover .like-icon,
  .like-quote.liked .like-icon {
    fill: currentColor;
  }
</style>

Like Astro, Vue allows you to inline <style> tags in a single file component and have them automatically scoped to your component.

Rewriting the movie quotes listing

The new pages/index.vue template starts with a getData() definition to retrieve the movie quote list on the server, using the Platformatic DB client:

<script lang="ts">
import QuoteActionEdit from '../components/QuoteActionEdit.vue'
import QuoteActionDelete from '../components/QuoteActionDelete.vue'
import QuoteActionLike from '../components/QuoteActionLike.vue'

export async function getData ({ req }) {
  const allowedSortFields = ['createdAt', 'likes']
  const searchParamSort = req.query.sort
  const sort = allowedSortFields.includes(searchParamSort)
    ? searchParamSort
    : 'createdAt'

  const quotes = await req.quotes.graphql({
    query: `
      query {
        quotes(orderBy: {field: ${sort}, direction: DESC}) {
          id
          quote
          saidBy
          likes
          createdAt
          movie {
            id
            name
          }
        }
      }
    `,
  })

  return {
    quotes,
    page: `listing-${sort}`,
  }
}

export function getMeta () {
  return {
    title: 'All quotes'
  }
}
</script>

Note that the getMeta() function defines the page's <title>, and the getData() function is responsible for returning page, which also becomes accessible in the layouts/default.vue component.

Next we have the <script setup> section consuming the data:

<script lang="ts" setup>
import { useRouteContext } from '/:core.js'
const { page, quotes } = useRouteContext().data
</script>

We also have the new Vue template:

<template>
  <template v-if="quotes.length > 0">
    <div v-for="quote in quotes" class="border-b mb-6">
      <blockquote class="text-2xl mb-0">
        <p class="mb-4">{{ quote.quote }}</p>
      </blockquote>
      <p class="text-xl mt-0 mb-8 text-gray-400">
        — {{ quote.saidBy }}, {{ quote.movie?.name }}
      </p>
      <div class="flex flex-col mb-6 text-gray-400">
        <span class="flex items-center">
          <QuoteActionLike :id="quote.id" :likes="quote.likes" />
          <QuoteActionEdit :id="quote.id" />
          <QuoteActionDelete :id="quote.id" />
        </span>
        <span class="mt-4 text-gray-400 italic">Added {{
          new Date(Number(quote.createdAt)).toUTCString()
        }}</span>
      </div>
    </div>
  </template>
  <p v-else>No movie quotes have been added.</p>
</template>

Wrapping up

In this blog, we demonstrated how to integrate Vue.js with Fastify for SSR, covering setup, data fetching, routing, and production deployment, while also providing a practical example of migrating an Astro app.

There are several parts of the rewrite not directly covered in this article, only because the sections we explored in this blog capture the essence of the changes involved. Make sure to see full source code here.

The JavaScript web framework ecosystem has had many interesting innovations in the past few years, with frameworks like Next.js, Nuxt and Astro offering a truly astonishing feature set. There are those of us however who continuously seek understanding of the underlying technology and continuously seek to abstract it the simplest and most concise way possible. As Johannes Kepler once said:

Nature uses as little as possible of anything.

With @fastify/vite, Fastify provides just that: the absolute minimum amount of moving parts to perform SSR and integrate a Vite frontend into your application.