Rewriting Nuxt Hacker News with Fastify, Vite and Vue 3
Jonas Galvez | March 23rd, 2021 | 10 min read
It's hard to keep up with JavaScript. Year after year, someone has a great idea, that great idea turns into a GitHub repository, and before you know it, it's gone mainstream, there's a community, there's a job board, there are conferences about it.
We have all seen this happen with Vue, and I've personally seen it happen with Nuxt. After 50 contributions to Nuxt's core and a few modules and articles written about it, not to mention the couple dozen companies I've helped debug and scale of their Nuxt apps, I have developed a love-hate relationship with it.
Table of contents:
A Swiss Army Knife
Nuxt is the swiss army knife of all Vue web frameworks -- it goes above and beyond in trying to make your life easier. To start with, you don't have to care about setting up server code for your app. All you need is a single file under the pages
folder and you have a running app. Don't need to set up a router, your routes are automatically inferred from the file system. Server side rendering is built-in, client-side data hydration (post-SSR) is built-in. You'll also find modules for everything.
Nearly every commonly used middleware or server plugin has a Nuxt configuration equivalent, e.g., need to set Content-Security-Policy
headers? No problem, just configure it through nuxt.config.js
and you're done. You also don't need to care about webpack configuration, as it includes sensible defaults that work fine 90% of the time, while letting you easily extend it if needed. I could use several other paragraphs telling you about all the wonderful things Nuxt does for you, but that's not what this article is about.
Opening The Black Box
All that comes with a cost. Nuxt is kind of a black box to a lot of people, and by that I mean, a lot of people just don't care about the .nuxt
folder. The .nuxt
folder is one big part of the magic. Nuxt will auto generate this core application for you, based on your nuxt.config.js
settings and the files from your project. You can see the .nuxt
folder has an App.js
, a client.js
, a router.js
and a server.js
, among others.
The rationale for accepting this amount of complexity that comes bundled with your Nuxt app is that, sooner or later, you're gonna need one of those features, so it's a whole lot more convenient to just use whatever is the Nuxt standardized approach for it.
In App.js
we see code for handling the loading bar, layouts, asyncData
, fetch
. In client.js
we see a lot more of the same: layouts, middleware, asyncData
, fetch handling. Ditto for client.js
. It's really doing a lot under the hood, and it's really hard to make sense of it without, well, basically reverse engineering it.
Trouble in Paradise
If you look at the generated .nuxt/index.js
file of a Nuxt app, you'll quickly realize it's loading all plugins sequentially, and they're loaded for every rendering, whether they're really needed or not. One can assume there's some overhead to this, but I think there's a deeper problem.
In my consulting engagements, I've routinely seen Nuxt apps with over twenty plugins. The more plugins you have, the higher the likelyhood of things interfering with each other, with Nuxt modules also being able to register plugins of their own. This can sometimes lead to ordering bugs that have cost me hours to figure out.
Even in a moderately complex app, you're likely to need multiple modules, sometimes custom modules, adding up time to the build process modifying or adding things to the .nuxt
folder.
Recently I've become more judicious about my use of Nuxt plugins, opting for sometimes adding things directly to the routes where they're needed, and making sure to group things that can really take advantage of Promise.all()
.
Why you should care about Fastify
Nuxt uses an Express-like Node server library called Connect.
You're probably familiar with the idiom:
app.use((req, res, next) => {
res.set('X-Hello', 'true')
next()
})
app.use((req, res) => {
res.end(`Hello from ${req.url}`)
})
That works great for a small number of routes, but when you have dozens of subservices with different but interconnected middleware needs, managing your backend functionality solely with the middleware paradigm, so to speak, becomes troublesome. Surely you can use subapps, but if you really need granular control over the execution of all routes, you'll end up with lots of tiny subapps all still going through the same routing pipeline.
Fastify introduces its own concept of plugins, which use an extremely fast library called avvio. In Fastify, everything you want to add is added via plugins, Plugins can register routes, hooks, other plugins and they can also decorate your app, Request and Reply objects. Plugins have proper encapsulation. Fastify uses a radix tree for routing, which, coupled with meticulous attention to Node best practices and mindfulness about the event loop, delivers enviable performance that has repeatedly beaten other frameworks in benchmarks. Fastify is built for speed and stability and is extremely well maintained.
So what does the code above look like in Fastify?
app.addHook('onRequest', (_, reply, done) => {
reply.header('X-Hello', 'true')
done()
})
app.addHook('onRequest', (req, reply, done) => {
reply.send(`Hello from ${req.raw.url}`)
done()
})
Well, not really. The closest thing to an Express-like middleware in Fastify is an onRequest hook. But you can also use preHandler, preValidation, preSerialization, onSend, onResponse. You can add steps to any point in a request's file cycle. This, coupled with its encapsulation rules, allows for an unprecedent level of modularization and composability.
What you'd probably really want to write is:
function useXHello (req, reply, done) {
reply.header('X-Hello', 'true')
done()
}
app.get('/*', { onRequest: [useXHello] }, (req, reply) => {
reply.send(`Hello from ${req.raw.url}`)
})
Check out Fastify's ecosystem page to see all available core and community plugins.
Already at its 3.x release line, I doubt you'll miss anything by chosing Fastify.
Fastify and Vite's vision
Fastify and Vite share something in common: an obsession with speed. The Fastify authors go to great lengths to ensure the core framework code runs as fast as possible, preventing all potential promise and event loop bottlenecks bugs, and taking advantage of all possible v8 optimizations, like reusing objects, leveraging shapes and inline-cache etc.
Vite leverages modern browser support for ES modules and esbuild to enable the fastest and most convenient experience possible building and developing JavaScript apps. Its hot module replacement system is blazing fast and it even takes care of finding and adding missing dependencies to your app, among several other things.
So how do you abandon Nuxt's conventions with Fastify and Vite?
Unlike Nuxt, Vite refuses to add a core app (.nuxt folder) for you. You can use npm init @vitejs/app
which will scaffold an app for you using any of the templates available here. Vite is, after all, a generic build tool and development server on top of Rollup, which you can use with not only Vue but pretty much any other framework out there.
That being said, among Vite's numerous features is the ability to easily perform glob imports. In the official ssr-vue
example, you can see it being used to mimic a simple Nuxt-like pages/ folder. If that is too simple for you and really want file system based routing, there are already a number of Vite plugins to choose from.
In a Fastify + Vite setup, you have to provide the server boot code yourself, so say goodbye to the convenience of nuxt.config.js
. Also, things like Nuxt's asyncData()
and fetch()
need to be reimplemented. As you'll see in the next sections though, this might not be so bad after all.
Diving into the rewrite
Let's start with shamelessly copying the original nuxt/hackernews code. For some background, Nuxt Hacker News was originally based on Vue Hacker News, created by Evan You. The Nuxt version was created by Sebastien Chopin, Alex Chopin, Pooya Parsa and Daniel Roe, who recently reimplemented its internal API using what appears to be a hint at the upcoming Nuxt modules supporting Nuxt 3.
Preparing the Fastify server
I'm gonna proceed with Marie Kondo's strategy — does it spark joy? Well, for me, TypeScript does not spark joy. And neither did looking at that new h3 server library, to be honest — mainly because it does not appear to care about the same things Fastify does. Fastify is a Node-focused web server, while h3 seems to be a hybrid, minimal approach enabling cross-environment deployments. This can be awesome in itself, so kudos to the Nuxt team for it.
So, in the spirit of sparking joy, I've started my rewrite by replacing that TypeScript/h3-based Nuxt serverMiddleware with a Fastify server, that uses the fastify-api plugin to register API routes as reusable methods.
Notice how we're using CommonJS for the server/
folder. For Node-level files, that run with the Fastify server, it's probably still safer to just use CommonJS, like Fastify itself does. You'll still be able to use ESM for the JavaScript on the Vite side of things.
Here's the entirety of the code from server/main.js
, the code that boots the Fastify server, registers API routes, registers Vite application routes and everything else it might need.
const fastify = require('fastify')()
const fastifyVite = require('fastify-vite')
const fastifyApi = require('fastify-api')
const { feeds } = require('./feeds')
const { fetchFeed, fetchItem, fetchItemWithComments, fetchUser } = require('./methods')
async function main () {
await fastify.register(fastifyApi)
await fastify.register(fastifyVite, {
api: true,
clientEntryPath: '/entry/client.js',
serverEntryPath: '/entry/server.js'
})
fastify.get('/', (_, reply) => reply.redirect('/top'))
fastify.get('/favicon.ico', (_, reply) => {
reply.code(404)
reply.send('')
})
fastify.setErrorHandler((err, _, reply) => reply.send(err))
fastify.api.get('/api/hn/item/:id', fetchItem)
fastify.api.get('/api/hn/item/:id/full', fetchItemWithComments)
fastify.api.get('/api/hn/user/:id', fetchUser)
fastify.api.get('/api/hn/:feed/:page', fetchFeed)
fastify.vite.global = {
feeds: Object.keys(feeds)
}
fastify.vite.get('/user/:id')
fastify.vite.get('/item/:id')
fastify.vite.get('/:feed/:page')
fastify.vite.get('/:feed')
await fastify.listen(4000)
console.log('Listening at http://localhost:4000')
}
main()
First you have the plugin registrations, for fastify-api and fastify-vite. Then some top-level route handlers and redirects, then all API routes which are automatically mapped to fastify.api.client
on the server (based on their function names), allowing direct calls from other routes, a piece of Vite global data to be made available to the client, and finally, all possible routes that can reach your Vite app defined with fastify.vite.get()
. After so much time letting the Nuxt framework do all this for me, it's nice to be able to put it together in such a concise, straightforward boot sequence.
Note that you don't actually need to declare all app routes with fastify.vite.get
, this would also work:
...
fastify.vite.get('/*')
...
But I declared them anyway to emphasize the fact you can attach different Fastify route options to different routes in your Vite app. In the example above, we're not passing any options as second parameter, but you could.
From pages/ to views/
Next it was time to replace the pages/
folder with views/
. Instead of a nested multi-folder setup so Nuxt can infer the shape of my routes, I decided to simply define views that can cleanly operate on a parameters. I'm just kidding, there was nothing simple about it given that it was hardly my second week messing around with Vue 3. In Vue 3, you no longer have things like this.$route
, for instance. You use the useRoute()
hook from vue-router
. The Vue 3 composition API encourages you organize your code in such a manner that it becomes easy to dillute it into independent, composable units. At least that's what I got from it. And that's what I tried to do with that third commit.
So for views/user.vue
, I ended up with:
<script>
import { useRouteAndAPI } from '../logic/hooks'
import { timeAgo } from '../logic/filters'
export default {
async setup () {
const [route, api] = useRouteAndAPI()
const id = route.params.id
const { json: user } = await api.fetchUser({ id })
return { user }
},
methods: { timeAgo }
}
</script>
From store/ to logic/
This was written with having a logic/
folder in mind, where I could put replacements for the Vuex store, actions, mutations and other custom hooks. This is what you can see in the fourth commit.
Instead of a full blown Vuex store, with state, actions and mutations, I opted for a simple reactive()
object from Vue 3 with some carefully crafted helper functions. Also taking advantage of useServerAPI()
andd userServerData()
provided by the fastify-vite plugin. These are used as our asyncData()
and fetch()
replacements.
async function updateFeedPage (api, feed, page) {
const { items, ids } = await useServerData(async () => {
const { json } = await api.fetchFeed({ feed, page })
return json
})
if (!state.feeds[feed]) {
state.feeds[feed] = {}
}
state.feeds[feed][page] = ids
for (const item of items) {
state.items[item.id] = item
}
}
The callback passed to useServerData()
is only ran on the server for the first render, is automatically rehydrated the next time you call it on the client, and stays working on the client for subsequent requests. So for views/feed.vue
, which uses useFeedPage()
directly, it's able to keep firing the same request from the client, automatically mapping to the API routes that back them. Snippet from useFeedPage()
:
const feed = computed(() => route.params.feed)
const page = computed(() => Number(route.params.page || 1))
await updateFeedPage(api, feed.value, page.value)
if (!import.meta.env.SSR) {
watchEffect(async () => {
if (!feed.value) {
return
}
if (previousFeed.value && previousFeed.value !== feed.value) {
updateFeedPage(api, feed.value, page.value)
}
...
When loading the feed view, we call updateFeedPage()
immediately with the feed
and page
parameters provided. If the route was navigated to client-side, that will be a native fetch()
request. If it runs on the server, its result will be serialized and sent to the client automatically for hydration. All this is provided by two small files from the fastify-vite
plugin: hooks.js
and hydrate.js
. It was inspired by Nuxt 3's upcoming useAsyncData
idiom prototype by Sebastien Chopin and Pooya Parsa.
I'm not sure I got everything right with this Vue 3 implementation, I know for sure it's missing request cancellation taking advantage of watchEffect()
's onInvalidate
. Well, it's missing a lot of things from the original example. But my focus was really on the API side of things, how to structure it, and how to reproduce Nuxt's utilities.
Wrapping up
To wrap it up, it's time to add the basic Vite + Vue 3 entry point boilerplate. For this project I copied it straight from fastify-vite's example app folder. The only difference is that I grouped nearly all files in the entry/
folder, the exception being index.html
which is needed by Vite. And finally, update configuration files, dropping nuxt.config.js
in favor of vite.config.js
.
And that's it. No magically added .nuxt
folder, you have one entry
folder with all entry points for a Vue 3 app, an index.html
, Vite's configuration file and a server.js
file with minimal Fastify code for booting the app.
https://github.com/galvez/fastify-vite-vue-hackernews/
Closing thoughts
Nuxt is not going anywhere. If I ran a shop that has to build a website a week, Nuxt is my first choice. There's hardly anything that makes developers as productive as the Nuxt framework and ecosystem. That being said, it needs to embrace Vite and probably rethink some of its internals to a more composable future, so to speak. I'm eager to see what Nuxt 3 will bring in terms of facilitating those patterns, and adding transparency to the black box. I'd love some sort of nuxt eject
command that turns .nuxt
into a clean boilerplate.
But for apps that I can have the luxury of spending a little more engineering time on, apps that need a higher focus on speed and future maintainability, I'm more and more convinced a minimal intersection of Fastify and Vite is now the best route.
Other references
- vite-ssr: Simple yet powerlful SSR for Vite 2 in Node.js
- vite-plugin-ssr: Simple full-fledged SSR Vite plugin
- vue-hackernews-3.0: HN clone built with Vite, Vue 3, VueRouter and Vuex
Click to load comments...