API Integration

TIP

Given the density of this section, it was taken out of the framework-specifc guides and all usage examples displayed here Vue-oriented. However the same principles apply to any framework. You'll notice the data functions look exactly the same in the vue-api and react-api boilerplate flavors. The only differences are in what is required by each framework, and the return value of useHydration, which can vary by framework.

As the reader is probably aware, most modern web applications have the backend layer (data) decoupled from the frontend layer (UI) completely. You're probably no stranger to this setup:

API server

https://<domain>/api/

Responsible for the data exchange between client application and backend.

Used by both the server during SSR and by the client upon user interaction.

Application server

https://<domain>/

Responsible for the server-side rendering (SSR) of your client application.

That'd be the Fastify app you're running with fastify-vite enabled.

Application client

All markup, assets and code sent to the browser that actually cause the UI to render.

That'd be the Vite app (Vue, React etc) you deliver through Fastify.

TIP

SSR is probably a misnoner, if you consider the flow: server runs JavaScript, creates markup, sends markup to the browser, browser runs JavaScript without having to recreate[1] the same DOM elements present in the prerendered markup from the server. So in that sense, SSR should really be named SSP, or, server-side prerendering, because the client still needs to render the markup pregenerated by the server.

[1] This process is called client hydration and fastify-vite has its own minimal, standardized way of doing it.

For SSR applications, this is especially relevant because data exchange can happen in at least two settings — one on the server, while prerendering markup (SSR phase), and the other on the client, after the application has already loaded, upon user interaction.

Using an HTTP client

If you can only consume an API externally, via HTTP, all you need is an isomorphic HTTP client, i.e., that can run the same way both on the server (Node.js) and on the browser. This is a big decision when it comes to laying out the foundation for you application — which HTTP client to use.

Probably the most popular solution for this to date is the combination of the node-fetch and whatwg-fetch packages, providing isomorphic fetch(). Other popular options are axios and ky-universal.

2021 saw the maturing of undici, a fast HTTP/1.1 client for Node.js, built by Node.js core maintainers. For more background, check out Matteo Colina's Can we double HTTP client throughput? from NodeConf 2020. Undici also includes a mature fetch() implementation that benefits from its speed. If you consider the adoption rate of modern browsers and the fact that nearly everyone dropped support for IE11, you can probably safely replace the combination of node-fetch and whatwg-fetch with just fetch-undici and native fetch() on the browser.

Using the sample JSONPlaceholder API to illustrate — here's an isomorphic fetch() call to its /posts resource — stored in an api.js file in a fastify-vite project:


├─ entry/
│  ├─ client.js
│  └─ server.js
├─ views/
│  └─ index.vue
├─ index.html
├─ base.vue
├─ routes.js
├─ main.js
├─ api.js
└─ server.js

import { fetch } from 'fetch-undici'
const baseUrl = 'https://jsonplaceholder.typicode.com'

export default {
  async getPosts () {
    const response = await fetch(`${baseUrl}/posts`)
    return response.json()
  }
}

This file is then simply imported by a Vue view component, that can be executed both on the server (SSR phase) or on the browser as the user navigates locally through the History API. No matter if it's executed on the server or on the browser, it'll work the same, performing the request as expected.


├─ entry/
│  ├─ client.js
│  └─ server.js
├─ views/
│  └─ index.vue
├─ index.html
├─ base.vue
├─ routes.js
├─ main.js
├─ api.js
└─ server.js

<template>
  <div id="posts">
    <div v-for="post in posts" class="post">
      <h2>{{ post.title }}</h2>
      {{ post.body }}
    </div>
  </div>
</template>

<script>
import { ref } from 'vue'
import api from '../api.js'

export default {
  async setup () {
    const posts = await api.getPosts()
    return { posts: ref(posts) }
  }
}
</script>

This api.js file is of course a very minimal example. Check out Alexander Lichter's Organize and decouple your API calls in Nuxt.js — albeit focused on Nuxt.js, it's an excellent article covering code organization ideas that are relevant just about the same for fastify-vite apps.

Using an integrated API

As popularized by Nuxt.js with serverMiddleware and Next.js with API routes, the monolithic setup of serving your API routes from the same server as the rest of your client application routes offers a great deal of maintenance convenience.

TIP

There have been lengthy debates on the merits of monoliths and microservices. As notoriously reported by Uber, Segment and InVision, microservices might not necessarily be the best way to go. Don't miss Kelsey Hightower's, Vasco Figueira's and Martin Fowler's takes on the subject.

In a fastify-vite setup, this is just a matter of adding those routes to your Fastify app, e.g.:

app.get('/api/posts', async (req, reply) => {
  const posts = await app.db.query('posts')
  reply.send({ data: posts })
})

In your Fastify + fastify-vite app, you can have the routes that lead to your Vite client application — and any other routes you like. The key thing to understand, if not clear by now, is that with fastify-vite, you start with Fastify, the application server itself. You then have access to all Fastify constructs, like decorators and hooks, with fastify-vite still operating, conceptually, as just a plugin.

In this monolithic setup, where you host your Vite app and backend API in the same Fastify instance, you're still able to use the external approach described in the previous section. That is, when you're always using an HTTP client to talk to the API.

Using API routes as internal methods

If your client application is statically generated, you will only ever need to reach your API methods by firing HTTP requests to them from the browser. If however your client application is server-side rendered, and you're using the monolothic API setup, it makes sense to execute server functions directly in the SSR phase instead of firing redundant HTTP requests.

Picking up on the previous /api/posts example, suppose we wanted to make it possible to execute the app.db.query() call to retrieve posts outside of that specific route handler.


app.decorate('api', {
  getPosts () {
    return app.db.query('posts')
  }
})
app.get('/api/posts', async (req, reply) => {
  reply.send({ data: await app.api.getPosts() })
})

Now, we can execute that function in two ways:

  • doing a GET to /api/posts (from the browser)
  • just running fastify.api.getPosts() (during SSR)

This way, in index.vue we could conditionally run the server's fastify.api.getPosts() method — which talks directly to the database — during SSR, and fire an HTTP request to the associated API endpoint when running from the browser.









 

 





import { ref } from 'vue'
import { useFastify, isServer } from 'fastify-vite-vue/client'
import api from '../api.js'

export default {
  async setup () {
    const fastify = await useFastify()
    const posts = isServer
      // If server-side rendering, just use fastify.api.getPosts() directly
      ? await fastify.api.getPosts()
      // If running from the browser, fire GET /api/posts via the client method in api.js
      : await api.getPosts()
    return { posts: ref(posts) }
  }
}

The result is a bit verbose though — it's a lot of boilerplate to repeat for every API method if you want to avoid firing redundant HTTP requests on the server.

If only there was a way to automate making API routes reusable as internal methods while also generating API client methods for the browser.

Automating with fastify-api and manifetch

You can start with fastify-api — a Fastify plugin that automates exposing route handlers as internal methods, combined with manifetch, a module that dynamically generates API client methods based on a manifest describing them. Below is an example from the README:

 



 



fastify.get('/1/method', { exposeAs: 'method' }, (_, reply) => {
  reply.send('Hello from /1/method')
})
fastify.get('/invoke/1/method', async (_, reply) => {
  const result = await fastify.api.client.method()
  reply.send(result)
})

The fastify.api.client.method() call is actually a dynamically generated façade to fastify.inject(), which fakes an HTTP request to the live server without going through a network socket. These client methods are generated by manifetch from the API manifest provided by fastify-api. Although initially conceived as a testing convenience, fastify.inject() is actually very fast, making fastify-lambda-aws possible, for example.

TIP

Under the hood, fastify-api collects metadata on the routes being defined in your Fastify application — their associated HTTP method, route pattern and method name — and the generates a manifest with this metadata that can be used to dynamically generate API client methods.

For instance, given the following route definitions:

app.post('/echo/:msg', { exposeAs: 'echo' }, (req, reply) => {})
app.get('/other', { exposeAs: 'nested.other' }, (req, reply) => {})

The following API manifest is automatically generated:

{
  echo: ['POST', '/echo/:msg'],
  nested: {
    other: ['GET', '/other'],
  },
}

Which then gets fed to manifetch under the hood, which uses it to build and automatically makes available the API client methods for these calls — decorated under api in the current Fastify encapsulation context:

app.api.echo({ msg: 'Hello' })
app.api.nested.other()

Notice how the properties passed in the first parameter are mapped to route params, if there are any. If there are none, then the first parameter of the wrapper function is just the options object.

The ability to easily reuse Fastify routes as internally available API methods has merits of its own and you may find an use for fastify-api in any type of project, but it becomes especially worthwhile when used with fastify-vite, which integrates with it out of the box.

To enable it, just set the api configuration option to true:




 


await app.register(require('fastify-vite'), {
  root: resolve(__dirname),
  renderer: require('fastify-vite-vue'),
  api: true,
})

Now, the API manifest for the API routes defined in the same encapsulation context as your fastify-vite routes gets delivered to the client as part of its hydration payload.

Once the browser loads the API manifest, it will too create an API client based on these definitions, with the exact same calling signature as its server-side counterparts.

With this setup, the earlier getPosts() example can be reduced to:









 

 



import { ref } from 'vue'
import { useHydration } from 'fastify-vite-vue/client'
import api from '../api.js'

export default {
  async setup () {
    const ctx = await useHydration()
    // If server-side rendering, automatically use fastify.api.getPosts() directly
    // If running from the browser, automatically fire GET /api/posts request
    const posts = await ctx.$api.getPosts()
    return { posts: ref(posts) }
  }
}

Note how we no longer need to import isServer, useFastify or api.js, just useHydration — which will automatically provide isomorphic access to ctx.$api. On the server, ctx.$api will contain methods that execute the route handlers directly, on the client, ctx.$api will contain methods that trigger HTTP requests to the endpoints associated with each API method.