โœจ Strapi MCP is now Generally Available - let your agents manage your Strapi content โœจ

Ecosystemโ—13 min read

Build production-ready APIs with Fastify and Strapi 5

โ—November 3, 2025โ—Updated on September 29, 2026
APIs with Fastify

If your Node.js application programming interface (API) is spending central processing unit (CPU) cycles on framework overhead, reducing that overhead is a direct place to start before scaling hardware. In the official Fastify benchmark suite run on Node.js 24, Fastify 5.10.0 handled 59,603 requests per second (req/s) while Express 5.2.1 handled 34,096, roughly 75% more throughput on identical hardware.

In brief

  • Fastify 5 outperforms Express 5 by about 75% in the official benchmark suite, 59,603 against 34,096 req/s, though Express 5 on Node 24 has narrowed the gap considerably from Express 4.
  • Fastify compiles JSON Schemas at startup with Ajv and fast-json-stringify, so malformed requests get rejected before they touch your business logic.
  • Fastify 5 requires Node.js 20+, an object-based listen() signature, and full JSON Schemas; shorthand schemas from v4-era tutorials fail at route registration, per the v5 migration guide.
  • Strapi 5 returns a flattened response format and uses documentId for lookups, so any gateway code written against the v4 data.attributes shape is broken.

Why Fastify outperforms other Node.js frameworks

Fastify is a Node.js web framework built around a schema-based approach that compiles validation and serialization work ahead of request handling. Four mechanisms help explain its performance.

How Fastify reduces framework overhead

  • Fastify's router, find-my-way, stores routes in a compact prefix tree and matches one path segment at a time, with static routes taking priority over parametric and wildcard ones. Regular expression (RegExp) routes are best kept off hot paths, because the router's own documentation flags them as expensive.
  • Fastify compiles your route schemas into functions using Ajv v8 for request validation and fast-json-stringify for responses. fast-json-stringify benchmarks at 7.3M operations per second (ops/sec) on objects against 4.6M for JSON.stringify() on Node 22, though its advantage shrinks as payloads grow.
  • Every register() call opens a new plugin scope. Decorators and hooks apply only to descendants, so the plugin hierarchy forms a directed acyclic graph rather than a global middleware chain. A hook you register inside your admin routes never runs on public traffic.
  • Built-in Pino logging ships inside Fastify core, no plugin needed, and its own benchmarks show it over 5x faster than alternatives in many cases: 114.8 ms against Winston's 270.2 ms in the basic suite.

Together those four mechanisms shift work from request time to startup time, which is where most of the throughput difference comes from. The Express.js fundamentals guide covers the other side of this framework comparison, and the Bun vs. Node.js benchmarks examine the runtime layer beneath both.

Fastify vs Express: what current benchmarks actually show

Express 5, tagged latest in March 2025, plus newer Node runtimes closed much of the old gap: the Express performance working group reported throughput rising from roughly 9k to roughly 27k req/s by moving from Node 20 to Node 24. The old "Fastify is 3-5x faster" framing came from Express 4 on older Node and no longer holds.

Current official numbers, using autocannon, 100 connections, Node 24, and four virtual CPUs (vCPUs):

FrameworkVersionRequests/sAverage latency
Fastify5.10.059,60316.27 ms
Express5.2.134,09628.82 ms

Synthetic benchmark limitations apply: this is a hello-world test measuring framework overhead only. Real workloads add application, database, and network costs. Benchmark your own routes before estimating the production difference.

Express still makes sense for quick prototypes and legacy codebases, especially when teams depend on middleware nobody has ported. Fastify is a stronger fit when framework overhead is a meaningful share of your request budget, including high-traffic public APIs and microservices. It also suits gateway layers in front of a content management system (CMS).

Set up a Fastify 5 project

Fastify 5 requires Node.js 20+, and v4 long-term support (LTS) ended mid-2025, so new projects should start on v5.

npm init -y
npm install fastify
// server.js
import Fastify from 'fastify'

const fastify = Fastify({ logger: true })

fastify.get('/', async () => ({ status: 'ok' }))

try {
  // v5 requires the object signature; fastify.listen(3000) throws
  await fastify.listen({ port: 3000, host: '0.0.0.0' })
} catch (err) {
  fastify.log.error(err)
  process.exit(1)
}

Three v5 changes can trip you up if you're following older tutorials:

  1. listen() takes an options object. The positional listen(3000, '0.0.0.0') form was removed.
  2. Schemas must be full JSON Schema. The shorthand syntax is gone; every querystring, params, body, and response schema needs an explicit type.
  3. Custom logger instances use loggerInstance, not logger. Plain logger: true or logger: { level: 'info' } still works for the built-in Pino.

Pino is part of Fastify core and starts at instantiation. There is no @fastify/pino-logger package on npm, and Pino is never enabled via register(). For pretty-printed development logs, you can install pino-pretty separately and pass it as a transport target.

Build structured APIs with Fastify: validation, plugins, and error handling

Raw speed matters less than what happens six months in, when the codebase has 80 routes and three people maintaining it. Use this directory layout:

src/
โ”œโ”€โ”€ plugins/          # reusable Fastify plugins
โ”œโ”€โ”€ routes/
โ”‚   โ”œโ”€โ”€ user/
โ”‚   โ””โ”€โ”€ article/
โ”œโ”€โ”€ schemas/          # shared JSON Schemas
โ”œโ”€โ”€ services/         # business logic
โ””โ”€โ”€ app.js            # exported factory, no listen() call

Export a factory so tests can boot the app in-process:

// src/app.js
import Fastify from 'fastify'
import userRoutes from './routes/user/index.js'

export default function build(opts = {}) {
  const app = Fastify(opts)
  app.register(userRoutes, { prefix: '/users' })
  return app
}

Validate requests with JSON Schema

When you attach a schema, Fastify rejects bad input with a 400 before your handler runs:

// src/schemas/pagination.js
export const paginationQuery = {
  type: 'object',
  properties: {
    page: { type: 'integer', minimum: 1, default: 1 },
    limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }
  }
}
// src/routes/article/index.js
fastify.get('/articles', {
  schema: { querystring: paginationQuery }
}, async (req) => {
  const { page, limit } = req.query
  return fastify.articleService.paginate(page, limit)
})

Skipping response schemas is one of the documented performance mistakes, since serialization falls back to plain JSON.stringify() without them. The same guide cautions against allErrors: true in Ajv on endpoints that receive untrusted input.

For broader endpoint design, the RESTful API design guide covers representational state transfer (REST) naming, pagination, and versioning conventions that pair well with schema-first validation.

Centralize error handling

// src/plugins/error-handler.js
fastify.setErrorHandler((err, _req, reply) => {
  fastify.log.error(err)
  const status = err.validation ? 400 : (err.statusCode || 500)
  reply.code(status).send({ status, message: err.message })
})

Every thrown error funnels through one function, which is where you can attach correlation IDs, unique identifiers used to trace requests, and redact stack traces before they reach clients.

Secure your Fastify API with JWT and core plugins

The core security plugins all support Fastify 5: helmet, rate-limit, CORS, and JWT. You can register them once at the top of your plugin tree:

// src/app.js
await fastify.register(import('@fastify/helmet'))
await fastify.register(import('@fastify/rate-limit'), { max: 100, timeWindow: '1 minute' })
await fastify.register(import('@fastify/cors'), {
  origin: ['https://yourfront.app'],
  methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']
})
await fastify.register(import('@fastify/jwt'), { secret: process.env.JWT_SECRET })

Set methods explicitly for Cross-Origin Resource Sharing (CORS). The v11 CORS defaults changed from GET,HEAD,PUT,PATCH,POST,DELETE to GET,HEAD,POST, so gateways relying on the old default will return unexpected errors on writes.

@fastify/jwt decorates the instance with sign and verify, and adds request.jwtVerify():

// src/plugins/auth.js
fastify.decorate('verifyJWT', async (req, reply) => {
  await req.jwtVerify()
  if (req.user.role !== 'editor') reply.code(403).send({ error: 'forbidden' })
})

// protect a route
fastify.get('/admin/stats', { preHandler: fastify.verifyJWT }, statsHandler)

If Strapi sits behind this gateway, consider applying limits at both layers. The Strapi rate limiting guide covers the CMS side, and Strapi's own auth endpoints ship with a default limit of 10 requests per 60 seconds per the Users and Permissions docs.

Use Fastify as an API gateway for Strapi 5

Strapi handles content modeling and its free Draft and Publish feature when active for the relevant Content-Type. It can also enforce role-based permissions, available by default once roles and permissions are configured.

Fastify handles browser-facing traffic and response reshaping, with aggregation and caching added at the gateway. This design treats the CMS as one service among several behind a gateway, consistent with a headless CMS architecture.

Two Strapi 5 changes break most older gateway code:

  1. The response format is flattened. Attributes sit directly on the data object; there is no data.attributes nesting. The old article.attributes.title pattern returns undefined against Strapi 5.
  2. Documents are addressed by documentId, a string like znrlzntu9ei5onjvwfaalu2v, not the numeric id. Route lookups like /api/articles/1 fail; see the documentId migration notes.

A corrected gateway route:

// src/routes/content/index.js
fastify.get('/content/articles', async () => {
  const res = await fetch('http://localhost:1337/api/articles', {
    headers: { Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}` }
  })
  const { data } = await res.json()
  // Strapi 5: attributes are top-level, no .attributes nesting
  return data.map((article) => ({
    documentId: article.documentId,
    title: article.title,
    summary: article.excerpt
  }))
})

Native fetch via Undici replaces axios here. For straight pass-through proxying, @fastify/reply-from and @fastify/http-proxy both support Fastify 5.

The right credential depends on the call

Server-to-server requests, like the gateway caching public content, should use a Strapi API token, free, available by default, and sent as a Bearer header. User-specific requests should forward the user's JWT from Strapi's POST /api/auth/local login endpoint.

Strapi's Users and Permissions plugin then enforces the roles and permissions configured in Strapi. The Strapi authentication guide walks through JWTs, API tokens, and role-based access control (RBAC) in Strapi 5 in depth.

Strapi's CORS should be restricted

When Fastify is the only browser-facing entry point, you can restrict strapi::cors in config/middlewares.js to the gateway's origin instead of the default '*', per the middlewares configuration docs.

Caching needs an invalidation strategy

A Redis cache-aside pattern, where the gateway manages a cache alongside Strapi, can be paired with a built-in Strapi webhook. The webhook sends entry.publish and entry.update events to the Fastify route. It also sends entry.delete events so the route can evict affected keys. Webhook-driven invalidation begins only after both the webhook and the Fastify receiver are configured.

On the CMS side, the community Strapi REST Cache plugin now supports Strapi 5 and auto-invalidates on writes. Note it's a community plugin, not built-in. For production, the @fastify/caching default store is a poor fit because its docs cap the in-memory store at 100,000 items and explicitly warn against it.

The GraphQL vs. REST comparison for Strapi 5 covers the trade-offs for the gateway-to-Strapi hop. GraphQL requires installing the @strapi/plugin-graphql package, and its docs strongly recommend disabling introspection and setting a depthLimit in production.

Artificial intelligence (AI) agents in the stack can use the Model Context Protocol (MCP) through the Strapi MCP Server, now generally available on all plans: an opt-in, disabled-by-default endpoint that lets agents perform create, read, update, and delete (CRUD) and publish operations on your content via Admin API tokens. The AI agents resource explains MCP in more detail.

Test Fastify routes with inject() and node:test

The fastify.inject() method feeds a mocked request straight into the router without network latency, because it opens no sockets or ports. The method still exercises the full plugin pipeline, including validation and hooks. The official testing guide builds its examples on Node's built-in node:test runner, which gives you parallel execution and coverage without extra dependencies. Vitest and Jest also work.

// test/user.test.js
import { test } from 'node:test'
import assert from 'node:assert'
import build from '../src/app.js'

test('rejects invalid payloads via schema validation', async (t) => {
  const app = build()
  t.after(() => app.close())

  const res = await app.inject({
    method: 'POST',
    url: '/users',
    payload: { name: 'Amara' } // age is required
  })
  assert.strictEqual(res.statusCode, 400)
})

test('creates a user when the body matches the schema', async (t) => {
  const app = build()
  t.after(() => app.close())

  const res = await app.inject({
    method: 'POST',
    url: '/users',
    payload: { name: 'Amara', age: 30 }
  })
  assert.strictEqual(res.statusCode, 200)
  assert.deepStrictEqual(res.json(), { message: 'User Amara created!' })
})

You can run this with node --test. Two habits are worth adopting: inject() waits for all plugins to boot before dispatching, so you don't need a manual ready() call, and closing the app after each test is highly recommended so connections to external services don't leak. Injection response fields include statusCode, headers, payload, body, cookies, and a json() helper.

Deploy Fastify APIs to production

Binding to the right host matters

Fastify defaults to localhost, which containers don't expose. The server docs advise listening on 0.0.0.0 in Docker and similar environments. Kubernetes probes, Cloud Run, and the Railway troubleshooting guide all fail to reach a service bound to 127.0.0.1.

Graceful shutdown prevents lingering work

The fastify.close() method flags the server as closing, sends Connection: close on new requests, runs preClose hooks, drains connections, and fires onClose hooks after in-flight requests finish. You can wire it to SIGTERM, the process termination signal.

Because installing a signal listener removes Node's default exit behavior, the process exits after shutdown only when no active handles remain. WebSocket connections aren't tracked by the Hypertext Transfer Protocol (HTTP) server, so you'll need to close them explicitly in a preClose hook or close() never resolves.

Blanket clustering claims deserve skepticism

Node's worker_threads documentation says workers are useful for CPU-intensive JavaScript operations but do not help much with input/output (I/O)-intensive work. The Node.js cluster docs also note operating system (OS) scheduling can dump over 70% of connections onto two of eight workers.

For a typical I/O-bound API, horizontal container replicas behind a load balancer are usually the better scaling unit. That matches recommendations for Amazon Web Services (AWS) Elastic Container Service (ECS) in the AWS ECS guidance to run one application process per container.

A reverse proxy belongs in front

The Fastify production recommendations consider exposing a Node app directly to the internet an anti-pattern and recommend HAProxy or Nginx for Transport Layer Security (TLS) termination and load balancing.

When Fastify sits behind one, the same recommendations guide advises setting trustProxy to the proxy's address rather than true, to avoid header spoofing. Strapi needs proxy.koa: true set in its server configuration so it trusts forwarded headers from the gateway.

Split health checks provide clearer signals

A shallow liveness probe asks whether the process is responsive, while a dependency-aware readiness probe checks whether it can reach Strapi and Redis. Kubernetes stops routing traffic on readiness failure without restarting the container, which is what you want during a dependency blip. Liveness should not depend on external services, or a database hiccup restarts your whole fleet.

Caching expectations need the right scope

Reverse-proxy microcaching can be dramatic on the right workload. An NGINX microcaching test on a traditional CMS cut latency from roughly 1,809 ms to roughly 16.6 ms with a one-second cache, but that's one environment with expensive page generation. Benchmark against your own cacheable, non-personalized endpoints before promising numbers.

For the CMS side of the stack, the Strapi performance practices guide covers query optimization and common pitfalls, and the Strapi deployment options overview compares Strapi Cloud and self-hosted approaches for the backend your gateway fronts.

Next steps: pair Fastify with Strapi 5

Begin with the factory-pattern app above. Point one route at your Strapi instance's /api endpoints using the flattened v5 response format. Add schema validation and an API token before anything else. From there, you can layer in Redis caching with webhook invalidation and split health probes as traffic justifies them. The Strapi REST API documentation is the reference to keep open while you wire up the gateway.

Paul BratslavskyDeveloper Advocate

Related Posts

Astro Actions With Vanilla JavaScript and Strapi 5
Tutorialsยท17 min read

Astro Actions With Vanilla JavaScript and Strapi 5

This tutorial covers setting up Astro Actions, integrating with Strapi, and handling form submissions with Vanilla JavaScript.

ยทJuly 10, 2024
Blogยท14 min read

Build an Editorial Website with vanilla JavaScript and Strapi

In this tutorial, you will learn how to create an editorial website using Strapi and vanilla JavaScript.

ยทNovember 22, 2021
Handle Form Input With Vanilla Javascript
Ecosystemยท15 min read

How To Handle Form Input With Vanilla Javascript (No Framework Required)

Master form validation, submission, and user interactions with vanilla JavaScript. Build dynamic forms without frameworks using native APIs and best practices.

ยทAugust 17, 2025