Exploring React SSR with Fastify
Table of contents
- Tutorial: React SSR Hello World
- Configuring Fastify
- Configuring Vite
- Configuring the React application shell
- The route context initialization file
- Consuming the route context
- Data fetching via getData()
- Head management via getMeta()
- Building and serving in production mode
- Movie Quotes App: React rewrite
- Rewriting the boilerplate
- Loading .env files
- Rewriting the layout component
- Rewriting the ‘like’ action component
- Rewriting the movie quotes listing
- Wrapping up
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 toprocess.stdout.isTTY
so that it continues to produce regular JSON logs for production.The
@fastify/vite plugin
registration needs to beawaited on
for proper function. Before starting the server, it also requires you toawait on
thevite.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 theroutes
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
Subscribe to my newsletter
Read articles from Matteo Collina directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by