Since Evan You has been actively working on Vue 3's revamped SSR support, and Nuxt just got 2 million in funding, I must warn you that what follows can hardly be truly called ultimate.

Surely there's plenty cool stuff coming our way after Vue 3 and Nuxt 3 have seen the light of day.

Still, I have to say, I can finally breathe a sigh of relief for my current and near future Nuxt API needs.

I've revised my API setup for Nuxt apps maybe a dozen of times. I've integrated API methods into Vuex, I have tried autogenerating API methods from YAML (which worked well in an app or two), but most of the time I ended up manually maintaining API clients.

In JavaScript, there's this inevitable urge to rethink things, just because the language and associated web APIs are so flexible. We default to using the best validated ideas available, packaged in the form of frameworks and framework ecosystems, like Nuxt. But there's always this lingering feeling that I'm missing something.

What should I try differently this time?

I have always advocated using Nuxt's own connect-based server for deploying APIs. My rationale for that was that, for one thing, you could still do upstream scaling of processes responsible for API endpoints separately if you want, and stacking things as serverMiddleware under nuxt.config.js is maintainable enough for most projects.

You never know what is enough unless you know what is more than enough. -- William Blake

Well, that pretty much summarizes refactoring.

I look at the code I wrote in the past few years and see them as a sine wave of added and reduced complexity. It's tricky to find the right balance, but perhaps a good measurement is to determine if adding complexity eliminates more problems than it introduces. For this you have to be skeptical of your own code, and eliminate all previous assumptions first. Remove the framework from of your mind.

After engaging in the massive challenge of migrating a large API from PHP to Node while integrating Nuxt and Fastify, it was time to rethink things.

Is Time To Drop Nuxt Server?

I think nuxt start is perhaps Nuxt's most controversial feature, inside my head at least. On one side, you have the convenience of not having to worry about choosing a full blown server, just use Nuxt and you're good.

And it does get the job done for most kinds of projects. That's why we started nuxt/metal — having a lean connect-like server will be a Good Thing.

On the other side, things can get really messy and hard to maintain if your Nuxt app amounts to any level of considerable complexity.

As a way of exploring some ideas, mainly thinking outside the Nuxt serverMiddleware paradigm, I decided to try Fastify to host a unified codebase containing both low-level, backend API routes and also serve the Nuxt application.

Nuxt on Fastify

Not that I haven't done this before, mind you, I did initially use Nuxt programmaticaly with Koa.js, but Koa.js didn't offer many benefits over Nuxt's serverMiddleware aside from better async support.

With Fastify, well, there are benefits. And I'm not only talking about its meticulous attention to performance. I'm really just talking about its plugin system, hooks, decorators, built-in validators and serializers. It feels complete, yet minimalist in essence. It gives me a lot of liberty to organize and load code whichever manner I see fit.

Fastify's plugin system feels similar to Nuxt's module system in functionality. You can use it to initialize external connections, register routes based on settings and well, even generate code that runs before boot time, like Nuxt does, and like I have done for generating client methods.

API handlers and client methods

It always annoyed me that I had to write API client methods matching API handlers in the same repository, that is, this always felt like something that should be automated. Things like swagger-codegen are awesome but I wanted a simpler, more Nuxt-focused solution.

So I started by writing a custom API route loader for Fastify, that would make it easier to retrieve metadata about available methods. The result was fastify-esm-loader.

From the README:

fastify-esm-loader will recursively look for index.js files use them to register routes, at any depth.

export default function ({ fastify, self, env }) {
  fastify.get('/users/all', self.listUsers)
  fastify.get('/users/:id', self.getUser)
  fastify.post('/users/', self.createUser)
}

In the snippet above, which needs to be located at <baseDir>/<service>/index.js, we have access to all files in that same directory, which are preloaded and made available at self. This is just a convenience for clean, contextual mapping of route handlers to their API endpoints.

The current version of fastify-esm-loader does require you to structure API route handlers in full directories with an index.js file. That is to say, you must have <service>/index.js and one file per handler in the same directory.

Support for a single file (<service>.js) registering multiple handlers without relying on external files is planned.

The boilerplate

Get it here. The rundown:

The Nuxt plugin

Nuxt's rendering middleware expects vanilla IncommingMessage and ServerResponse objects, which are available in Fastify via req.raw and reply.res, so we have:

const nuxt = new Nuxt({ dev: process.dev, ...nuxtConfig })
await nuxt.ready()

fastify.get('/*', (req, reply) => {
  nuxt.render(req.raw, reply.res)
})

We also need to set up the build process in dev mode:

if (process.dev) {
  process.buildNuxt = () => {
    return new Builder(nuxt).build()
      .catch((buildError) => {
        consola.fatal(buildError)
        process.exit(1)
      })
  }
}

We don't immediately build Nuxt because we want to do it after the loader plugin has had a chance to add an autogenerated file to the build containing all API client methods. This way we can call process.buildNuxt() at the appropriate time.

The almighty API loader

In loader.js, there's a wrapper to fastify-esm-loader that will collect data about routes being registered, and use that data to codegen associated API client methods both for SSR and client-side consumers.

We start by collecting said data with onRoute:

const api = {}
const handlers = {}
fastify.addHook('onRoute', (route) => {
  const name = route.handler[methodPathSymbol]
  if (name) {
    const routeData = [route.method.toString(), route.url]
    setPath(api, name, routeData)
    setPath(handlers, name, route.handler)
  }
})
await FastifyESMLoader(fastify, options, done)
await fastify.ready()

Now armed with api and handlers, we can use the functions in gen.js to automatically build these client boilerplates:

const clientMethods = generateClientAPIMethods(api)
const apiClientPath = resolve(__dirname, join('..', 'client', 'api.js'))
await writeFile(apiClientPath, clientMethods)

const serverMethods = generateServerAPIMethods(api)
const apiServerPath = resolve(__dirname, join('api.js'))
await writeFile(apiServerPath, serverMethods)

And once built, in the very same code block:

const getServerAPI = await import(apiServerPath).then(m => m.default)

Generate code and live import it! The next part is offering an axios-like interface to Fastify route handlers. I managed to get it to an usable state with translateRequest and translateRequestWithPayload, also available in gen.js. For SSR, we make that object available directly in process.$api:

process.$api = getServerAPI({
  handlers,
  translateRequest,
  translateRequestWithPayload
})

And finally, trigger the Nuxt build:

if (process.buildNuxt) {
  await process.buildNuxt()
}

Wait, what?

So you're probably wondering what the fork translateRequest() and its taller brother do. They act like adapters to Fastify route handlers, as if we were mocking live HTTP requests to them, but not really. Here's a simplified snippet:

export function translateRequest (handler, params, url, options = {}) {
  return new Promise((resolve, reject) => {
    handler(
      {
        url,
        params,
        query: options.params,
        headers: options.headers,
      },
      {
        send: (data) => {
          resolve({ data })
        },
      },
    )
  })
}

Of course the real deal has a bit more juice to it.

Why go through all this trouble, you may ask. So we don't have to make live HTTP requests for API calls during SSR!

We're also almost done — let's get all this to Nuxt.

Making $api available in Nuxt

First we need to use a plugin to inject $api everywhere. If the request is running entirely on the server (SSR), we assign process.$api directly to ctx.$api, which will provide working methods that directly run Fastify route handlers.

If the app's already loaded on the client though, we use the function (here I just import it as getClientAPI) that was automatically generated and placed in client/api.js:

import getClientAPI from '../api'

export default (ctx, inject) => {
  if (process.server) {
    ctx.$api = process.$api
  } else {
    ctx.$api = getClientAPI(ctx.$axios)
  }
  inject('api', ctx.$api)
}

Assuming we have this in server/routes/hello/msg.js:

export default (req, reply) => {
  reply.send({ message: 'Hello from API' })
}

We can now do an asyncData like this in our Nuxt pages:

<template>
  <main>
    <h1>{​{ message }}</h1>
  </main>
</template>

<script>
export default {
  async asyncData ({ $api }) {
    const { data } = await $api.hello.msg()
    return data
  }
}
</script>

So $api.hello.msg(), we didn't have to write. It was already there, automatically generated from the contents of the server/routes directory. A Nuxt Eye to the Fastify Guy!

Bonus: dependency injection

You'll notice this boilerplate comes with an example on how to do dependency injection into route handlers. If fastify-esm-loader detects the default function exported by <service>/<handler>.js has one argument only, it will use it to pass injections (from exports in routes/index.js) before returning the final handler function. That means you can do things like:

export default ({ injection }) => (req, reply) => {
  reply.send({ injection })
}

Conclusion

I'm someone with unpopular opinions.

I like boring techonology. I think TypeScript and GraphQL go the opposite direction of minimalism. I understand their value and usefulness, but I'd rather see TypeScript code in very select, hand-picked, foundational libraries rather than in everyday code. Vue 3's code is really inspiring, but I'd still not use TypeScript in my everyday code.

Not that this has anything to do with this article, I just like to vent about TypeScript 😈

API automation does relate to GraphQL and, as complex as this Fastify and Nuxt API boilerplate may seem, the sum of all of its code still stays far below that of any GraphQL-based solution, with no HTTP requests happening in SSR.

As far as speed goes, while serving Nuxt via Fastify yields roughly the same performance as serving Nuxt from its built-in, connect-based server, avoiding HTTP requests for SSR API calls can really make a difference in high SSR load. With some work you can probably adapt Pim's awesome nuxt-lambda to serve Nuxt requests from Fastify.

And most importantly, being able to organize code using Fastify's plugins seems to make for an easier, more maintainable setup than a huge stack of serverMiddleware.