Exploring React SSR with Fastify

Exploring React SSR with Fastify

In this article, we'll be looking at the state of native React 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: React SSR Hello World

Even though @fastify/vite allows you to simply serve a static Vite application as a SPA, its real value is 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/react package, which wraps a renderer for @fastify/vite. It's built on the latest versions of React and React 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/react-base#dev hello-world
cd hello-world
npm i

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

{
  "name": "react-base",
  "description": "The react-base starter template for @fastify/react",
  "type": "module",
  "scripts": {
    "dev": "node server.js --dev",
    "start": "node server.js",
    "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",
    "lint": "eslint . --ext .js,.jsx --fix"
  },
  "dependencies": {
    "@fastify/one-line-logger": "^1.2.0",
    "@fastify/vite": "^6.0.5",
    "@fastify/react": "^0.6.0",
    "fastify": "^4.24.3",
    "history": "^5.3.0",
    "minipass": "^7.0.4",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.20.0",
    "unihead": "^0.0.6",
    "valtio": "^1.12.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.2.0",
    "postcss": "^8.4.31",
    "postcss-nesting": "^12.0.2",
    "postcss-preset-env": "^7.7.1",
    "tailwindcss": "^3.4.1",
    "vite": "^5.0.2"
  }
}

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 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/react',
})

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

A few things of note here:

  • The template uses @fastify/one-line-logger for human readable logs during development, but you'll want to condition that to process.stdout.isTTY so that 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/react',
})
  • It could also use an environment variable for the same purpose.

  • @fastify/react is essentially a set of configuration options and hooks for @fastify/vite controlling how React route modules become Fastify routes.

You can replace parts of it with your own should it 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: {
    ...FastifyReact,
    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-react'
import viteFastifyReact from '@fastify/react/plugin'

const path = fileURLToPath(import.meta.url)

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

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 + React configuration.

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

Note: One example of a smart import is /:core.jsx. The /: prefix tells @fastify/react/plugin to provide the internal core.jsx file, part of the package itself, but only in case it doesn't find a core.jsx 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/react implements a simple Next.js-like React application shell using these files, but also like in Next.js, these files don't have to 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/react here.

Configuring the React 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– for example, if you want to remove the ability to support fragments as Readable instances in a scenario where you have no use for this feature.

See @fastify/react'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/react's internal implementation below:

export async function createRenderFunction({ routes, create }) {
  // create is exported by client/index.js
  return (req) => {
    // Create convenience-access routeMap
    const routeMap = Object.fromEntries(
      routes.toJSON().map((route) => {
        return [route.path, route]
      }),
    )
    // Creates main React component with all the SSR context it needs
    const app =
      !req.route.clientOnly &&
      create({
        routes,
        routeMap,
        ctxHydration: req.route,
        url: req.url,
      })
    // 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: app }
  }
}

Like createHtmlFunction(), you'll rarely need to provide your own version of it. It's displayed above just 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 create from '/:create.jsx'
import routes from '/:routes.js'

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

It needs to provide access to both the React page routes and the main React component create function. Every object in the routes array needs to contain a path and a component property, among other settings, but that's 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/react 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.

The route context initialization file

One might say the context.js file is a peculiar feature of @fastify/react that has no clear equivalent in Next.js, 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 Valtio 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, meaning that it is not executed again in subsquent client-side navigation.

Consuming the route context

In our react-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.jsx template to consume state from the route context. The useRouteContext() is a hook provided by @fastify/react's internal core.jsx virtual module and can be used in every component.

import { useRouteContext } from '/:core.js'
import logo from '/assets/logo.svg'

export function getMeta () {
  return {
    title: 'Welcome to @fastify/react!'
  }
}

export default function Index () {
  const { state } = useRouteContext()
  const message = 'Welcome to @fastify/react!'
  return (
    <>
      <p>{message}</p>
      <img style={{width: '100%'}} src={logo} />
      {state.authenticated && <p>
        You're authenticated!
      </p>}
    </>
  )
}

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

You can still override create.jsx to define another type of store.

Data fetching via getData()

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

Route modules can export a getData() function that 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/react 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.jsx to include it:

import { Link } from 'react-router-dom'
import { useRouteContext } from '/:core.js'
import logo from '/assets/logo.svg'

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

export default function Index() {
  const { state, data } = useRouteContext()
  const message = 'Welcome to @fastify/vue!'
  return (
    <>
      <h1>{message}</h1>
      <p><img src={logo} /></p>
      {state.authenticated && <p>
        You're authenticated!
      </p>}
      <h2>{data.title}</h2>
      <p>{data.body}</p>
      <footer>
        <p><Link to="/other">Go to /other page</Link></p>
      </footer>
    </>
  )
}

The key thing to understand in this example is that by the time the 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 React context manager handler registered by @fastify/react.

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.

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

It'll work seamlessly in SSR and client-side navigation.

Building and serving in production mode

To run it in production mode, just remove the --dev flag. This will also require you to run vite build beforehand so there's a production bundle @fastify/vite can load.

The catch here is that you need vite build both the client and server bundles.

  • The client bundle is what is made available to the client via index.html.

  • The server bundle is what gets exposed to Fastify on the server, namely client/index.js, which 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: React 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].

  • 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 certain functionalities, 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 React version, making the directory structure now look like the following:

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

Next, in movie-quotes-frontend-react 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/react',
})

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

And a vite.config.js file to import @fastify/reacts 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 viteFastifyReact from '@fastify/react/plugin'

export default {
  root: join(dirname(fileURLToPath(import.meta.url)), 'client'),
  plugins: [
    viteFastifyReact()
  ],
  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

You’ll also need to 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.

Note- In the end, this is not going to be required at all since we're not ever running code that relies on the single environment variable of this application on the client, however it's still useful to know how to do so should the need arise.

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.jsx reads:

import { Link } from 'react-router-dom'
import { useRouteContext } from '/:core.jsx'

export default ({ children }) => {
  const navActiveClasses = 'font-bold bg-yellow-400 no-underline'
  const { page } = useRouteContext().data

  return (
    <>
      <header className="prose mx-auto mb-6">
        <h1>🎬 Movie Quotes</h1>
      </header>
      <nav className="prose mx-auto mb-6 border-y border-gray-200 flex">
        <Link
          to={{ pathname: '/', search: '?sort=createdAt' }}
          className={`p-3 ${page === 'listing-createdAt' && navActiveClasses}`}
        >
          Latest quotes
        </Link>
        <Link
          to={{ pathname: '/', search: '?sort=likes' }}
          className={`p-3 ${page === 'listing-likes' && navActiveClasses}`}
        >
          Top quotes
        </Link>
        <Link to="/add" className={`p-3 ${page === 'add' && navActiveClasses}`}>
          Add a quote
        </Link>
      </nav>
      <section className="prose mx-auto">{children}</section>
    </>
  )
}

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 in 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. A very simple POST endpoint, runs a mutation and returns the updated value. Now we can use it in our new QuoteActionLike.jsx component to do the update:

import { useState } from 'react'
import styles from './QuoteActionLike.module.css'

interface Props {
  id: string
  likes: number
}

export default ({ id, likes: initialLikes }: Props) => {
  const [likes, setLikes] = useState(Number(initialLikes))
  async function likeQuote() {
    const req = await fetch(`/api/like-movie-quote/${id}`, { method: 'POST' })
    const newLikes = Number(await req.text())
    if (newLikes !== likes) {
      setLikes(newLikes)
    }
  }
  return (
    <span
      onClick={likeQuote}
      className={`
        ${styles.likeQuote}
        ${likes === 0 ? 'cursor-pointer' : ''}
        ${likes > 0 ? styles.liked : ''}
        mr-5 flex items-center
      `}
    >
      <svg
        className={`${styles.likeIcon} w-6 h-6 mr-2 text-red-600`}
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
        strokeWidth="1.5"
        stroke="currentColor"
      >
        <path
          strokeLinecap="round"
          strokeLinejoin="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 className="w-8">{likes}</span>
    </span>
  )
}

Rewriting the movie quotes listing

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

import { useRouteContext } from '/:core.jsx'

import QuoteActionDelete from '/components/QuoteActionDelete.tsx'
import QuoteActionEdit from '/components/QuoteActionEdit.tsx'
import QuoteActionLike from '/components/QuoteActionLike.tsx'

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',
  }
}

You can then use a getMeta() function exporting to define the page's <title>.

Note that the getData() function is responsible for returning page, which also becomes accessible in the layouts/default.jsx component.

Next we have the component function consuming the data:

export default () => {
  const { page, quotes } = useRouteContext().data
  return (
    <main>
      {quotes.length > 0 ? (
        quotes.map((quote) => (
          <div key={`quote-${quote.id}`} className="border-b mb-6 quote">
            <blockquote className="text-2xl mb-0">
              <p className="mb-4">{quote.quote}</p>
            </blockquote>
            <p className="text-xl mt-0 mb-8 text-gray-400">
              — {quote.saidBy}, {quote.movie?.name}
            </p>
            <div className="flex flex-col mb-6 text-gray-400">
              <span className="flex items-center">
                <QuoteActionLike id={quote.id} likes={quote.likes} />
                <QuoteActionEdit id={quote.id} />
                <QuoteActionDelete id={quote.id} />
              </span>
              <span className="mt-4 text-gray-400 italic">
                Added {new Date(quote.createdAt).toUTCString()}
              </span>
            </div>
          </div>
        ))
      ) : (
        <p>No movie quotes have been added.</p>
      )}
    </main>
  )
}

Wrapping up

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

Note- There are many portions of the rewrite not directly covered in this article, only because the sections that were covered capture the essence of the changes involved. Make sure to see full source code here