Как создать REST API с помощью Fastify
Создайте REST API на Fastify v5 с Prisma, валидацией JSON Schema, плагинами, декораторами, хуками и типизацией TypeBox для CRUD posts.
Fastify — это высокопроизводительный Node.js веб-фреймворк с минимальными накладными расходами, чьей ключевой особенностью является валидация и сериализация на основе JSON Schema: вы объявляете структуру каждого запроса и ответа, а Fastify компилирует эти схемы в быстрые функции при запуске. По данным мейнтейнеров, Fastify является одним из самых быстрых веб-фреймворков и, в зависимости от сложности кода, способен обрабатывать свыше 76 тысяч запросов в секунду. В этом руководстве мы создадим полноценный CRUD REST API для ресурса posts на базе Fastify v5 с Prisma в качестве слоя данных, сосредоточившись на четырёх примитивах, которые отличают Fastify от Express: инкапсулированные плагины, декораторы, схемная валидация/сериализация и хуки жизненного цикла.
Ключевые выводы
- Fastify v5 требует Node.js v20 или новее; v4 достиг конца жизненного цикла 30 июня 2025 года, а v3 и более ранние версии не поддерживаются, поэтому новые API следует разрабатывать на ветке v5.x.
- Схема
responseв Fastify выполняет двойную функцию: ускоряет сериализацию, компилируя специализированный стрингификатор, и отфильтровывает любое свойство, не объявленное в схеме, — таким образом внутренние поля никогда не попадают в ответы. - Начиная с v5 сокращённый синтаксис схем упразднён: каждая схема
querystring,params,bodyиresponseдолжна быть полноценной JSON Schema, включающей свойствоtype. - Для совместного использования клиента базы данных во всех маршрутах оберните плагин в
fastify-plugin, намеренно нарушив инкапсуляцию, и подключите клиент черезfastify.decorate('prisma', client). - С провайдером типов TypeBox вы объявляете каждую схему один раз, а Fastify автоматически выводит типы запроса и ответа из неё, устраняя расхождение между правилами валидации и типами TypeScript.
Почему Fastify: четыре примитива, которых нет в Express
Те же CRUD-эндпоинты можно написать на Express. Однако воспроизвести архитектурную модель Fastify не так просто — она построена на четырёх примитивах: инкапсулированных плагинах, декораторах, схемной валидации и сериализации, а также хуках жизненного цикла. Мейнтейнеры отмечают, что Fastify полностью расширяем через хуки, плагины и декораторы. Это не удобства, добавленные поверх роутера, — это сама архитектура.
| Задача | Express (типичный подход) | Fastify (идиоматический подход) |
|---|---|---|
| Валидация входных данных | Вручную или через middleware (express-validator) | JSON Schema, объявленная на маршруте |
| Сериализация ответов | JSON.stringify | Скомпилированный fast-json-stringify из схемы ответа |
| Совместное использование ресурсов | Глобальный middleware на общем объекте req | Инкапсулированные плагины + декораторы |
| Жизненный цикл запроса | Линейная цепочка middleware | Типизированные хуки (onRequest, preHandler, onError, …) |
Заявление о производительности имеет механическое, а не маркетинговое обоснование. Хотя это и не обязательно, Fastify рекомендует использовать JSON Schema для валидации маршрутов и сериализации выходных данных; внутри фреймворк компилирует схему в высокопроизводительную функцию. Схема ответа при запуске преобразуется в специализированный сериализатор, поэтому генерация JSON минует стандартный путь через JSON.stringify. Официальные показатели следует воспринимать такими, какие они есть: эти бенчмарки получены на синтетическом тесте «hello world», цель которого — оценить накладные расходы фреймворка. Перед тем как приводить конкретные цифры, проведите бенчмарк собственного приложения.
Настройка проекта и минимальный сервер на v5
Discover how at OpenReplay.com.
Начнём с ESM, поскольку и Fastify, и Prisma v7 ориентированы на ESM в первую очередь. Fastify v5 поддерживает только Node.js v20+; если вы используете более старую версию Node.js, её необходимо обновить для работы с Fastify v5.
mkdir fastify-posts-api && cd fastify-posts-api
npm init -y
npm pkg set type="module"
npm i fastify@5
Текущий стабильный релиз — 5.9.0, опубликованный 28 июня 2026 года. Создайте файл server.js:
import Fastify from 'fastify'
const app = Fastify({ logger: true })
app.get('/health', async () => ({ status: 'ok' }))
try {
await app.listen({ port: 3000 })
} catch (err) {
app.log.error(err)
process.exit(1)
}
Обратите внимание на вызов listen с объектом в качестве аргумента. В v5 вариативная сигнатура метода .listen() была удалена, поэтому вызывать .listen() с переменным числом аргументов больше нельзя — всегда передавайте объект с параметрами, например { port: 3000 }. Запустите сервер командой node --watch server.js и обратитесь к http://localhost:3000/health.
Маршрутизация в стиле Fastify: плагины и инкапсуляция
В Fastify маршруты размещаются внутри плагинов, и каждый плагин выполняется в собственном контексте инкапсуляции. Декоратор, хук или маршрут, зарегистрированный внутри плагина, виден этому плагину и его дочерним элементам, но невидим для соседних плагинов — в отличие от middleware в Express, где всё использует один общий объект запроса. Модель области видимости описана в справочнике по плагинам.
Плагин — это просто асинхронная функция, получающая инкапсулированный экземпляр:
// routes/posts.js
export default async function postsRoutes(app, opts) {
app.get('/', async (request, reply) => {
return [] // обработчик списка добавим позже
})
}
Зарегистрируйте его с префиксом в server.js:
import postsRoutes from './routes/posts.js'
app.register(postsRoutes, { prefix: '/api/posts' })
Придерживайтесь единообразного использования async для функций плагинов. В руководстве по миграции на v5 явно указано, что все устаревшие возможности v4 были удалены и после обновления работать не будут; смешение стилей callback и promise внутри одного плагина, допускавшееся в v4, больше не разрешено. Выбирайте async/await и придерживайтесь этого подхода.
Схемная валидация и сериализация
Добавьте объект schema к любому маршруту, и Fastify будет валидировать входящие body, params, querystring и headers, а затем сериализовывать исходящую полезную нагрузку согласно схеме response. Это фирменная особенность фреймворка, описанная в разделе Validation and Serialization.
В v5 действует одно правило для всех схем: сокращённый синтаксис упразднён. Начиная с v5 для querystring, params, body и response необходимо указывать полноценную JSON Schema, включающую свойство type, иначе маршрут не пройдёт валидацию — руководство по миграции на v5 фиксирует удаление опции jsonShortHand.
Определите схемы для ресурса posts:
// schemas/posts.js
export const postResponse = {
type: 'object',
properties: {
id: { type: 'string' },
title: { type: 'string' },
content: { type: 'string' },
published: { type: 'boolean' },
createdAt: { type: 'string' }
}
}
export const createPostBody = {
type: 'object',
required: ['title', 'content'],
properties: {
title: { type: 'string', minLength: 1 },
content: { type: 'string', minLength: 1 },
published: { type: 'boolean', default: false }
}
}
export const idParams = {
type: 'object',
required: ['id'],
properties: { id: { type: 'string' } }
}
Схема response — это та часть, которую большинство руководств пропускает, хотя она приносит двойную пользу. Во-первых, она компилируется в специализированный сериализатор, работающий быстрее обобщённой стрингификации. Во-вторых, она действует как список разрешённых свойств: любое свойство, которое возвращает обработчик, но не объявлено в схеме, отбрасывается до отправки клиенту. passwordHash или внутренний userId, случайно попавший в результат запроса, никогда не покинет сервер — сериализатор просто не умеет его выводить.
Путь на TypeScript с TypeBox
При работе с TypeScript основная сложность заключается в поддержании синхронизации между схемами валидации и определениями типов. Провайдер типов TypeBox объединяет их в одно объявление. Установите typebox как обязательную peer-зависимость и @fastify/type-provider-typebox:
npm i typebox @fastify/type-provider-typebox
Зарегистрируйте провайдер и объявляйте каждую схему один раз с помощью Type:
import Fastify from 'fastify'
import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>()
app.post('/api/posts', {
schema: {
body: Type.Object({
title: Type.String({ minLength: 1 }),
content: Type.String({ minLength: 1 })
})
}
}, async (request) => {
// request.body типизирован как { title: string; content: string }
const { title, content } = request.body
return { title, content }
})
Импортируйте Type и TypeBoxTypeProvider из @fastify/type-provider-typebox и вызовите Fastify().withTypeProvider<TypeBoxTypeProvider>(); типы полей тела запроса будут выведены автоматически. Используйте пакет с областью видимости — устаревший импорт из @sinclair/typebox, упоминаемый в старых руководствах, соответствует названию до v5. Важный нюанс v5: в руководстве по миграции указано, что провайдеры типов разделены на два отдельных типа — ValidatorSchema и SerializerSchema, — поэтому обновляйте пакет провайдера одновременно с Fastify. Также учтите, что типы провайдера не распространяются глобально; при инкапсулированном использовании контекст можно переопределить для одного или нескольких провайдеров, что означает необходимость повторного объявления провайдера в каждом плагине, которому он нужен.
Слой базы данных как плагин-декоратор
Подключите базу данных к экземпляру Fastify через декоратор, предоставляемый плагином, который намеренно нарушает инкапсуляцию. Чтобы использовать один ресурс — клиент базы данных — во всех маршрутах, оберните плагин в fastify-plugin, чтобы декоратор вышел за пределы своей области видимости, и подключите его через fastify.decorate('prisma', client). Этот паттерн описан в справочнике по декораторам.
В данном руководстве используется Prisma (v7, актуальная версия 7.8.x) — без зависимостей от Rust, только ESM, что идеально подходит для описанной выше ESM-конфигурации. Установите пакеты, определите схему и сгенерируйте клиент. Prisma 7 также требует адаптера драйвера для создания клиента, поэтому установите SQLite-адаптер вместе с основными пакетами:
npm i @prisma/client@7 @prisma/adapter-better-sqlite3
npm i -D prisma@7
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Post {
id String @id @default(uuid())
title String
content String
published Boolean @default(false)
createdAt DateTime @default(now())
}
Prisma 7 настраивает свой CLI через файл prisma.config.ts и генерирует клиент по указанному пути output; выполните npx prisma migrate dev, чтобы создать базу данных и клиент. Затем подключите клиент как декоратор. Обратите внимание, что в Prisma 7 вызов new PrismaClient() без аргументов был удалён — клиент необходимо создавать с адаптером драйвера:
// plugins/prisma.js
import fp from 'fastify-plugin'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { PrismaClient } from '../generated/prisma/client.js'
export default fp(async (app) => {
const adapter = new PrismaBetterSqlite3({ url: 'file:./dev.db' })
const prisma = new PrismaClient({ adapter })
await prisma.$connect()
app.decorate('prisma', prisma)
app.addHook('onClose', async (instance) => {
await instance.prisma.$disconnect()
})
})
Поскольку плагин обёрнут в fp, app.prisma теперь доступен из любого маршрута приложения.
Предпочитаете Postgres с TypeORM? Паттерн с декоратором идентичен: подключите DataSource, затем выполните app.decorate('db', dataSource). Изменится только слой схемы.
CRUD-обработчики с корректными статус-кодами
Реализуйте пять операций в виде маршрутов внутри плагина posts, каждый со своей схемой. Возвращайте правильный статус-код для каждого метода:
201с созданным ресурсом для POST;200для операций чтения;204без тела ответа для DELETE;404при отсутствии запрошенного ресурса.
Fastify автоматически устанавливает Content-Type и выполняет сериализацию согласно схеме ответа — подробнее см. в Reply API.
// routes/posts.js
import { postResponse, createPostBody, idParams } from '../schemas/posts.js'
export default async function postsRoutes(app) {
// CREATE
app.post('/', {
schema: { body: createPostBody, response: { 201: postResponse } }
}, async (request, reply) => {
const post = await app.prisma.post.create({ data: request.body })
return reply.code(201).send(post)
})
// READ all
app.get('/', {
schema: { response: { 200: { type: 'array', items: postResponse } } }
}, async () => {
return app.prisma.post.findMany({ orderBy: { createdAt: 'desc' } })
})
// READ one
app.get('/:id', {
schema: { params: idParams, response: { 200: postResponse } }
}, async (request, reply) => {
const post = await app.prisma.post.findUnique({ where: { id: request.params.id } })
if (!post) return reply.code(404).send({ message: 'Post not found' })
return post
})
// UPDATE
app.put('/:id', {
schema: { params: idParams, body: createPostBody, response: { 200: postResponse } }
}, async (request, reply) => {
const exists = await app.prisma.post.findUnique({ where: { id: request.params.id } })
if (!exists) return reply.code(404).send({ message: 'Post not found' })
return app.prisma.post.update({ where: { id: request.params.id }, data: request.body })
})
// DELETE
app.delete('/:id', {
schema: { params: idParams }
}, async (request, reply) => {
const exists = await app.prisma.post.findUnique({ where: { id: request.params.id } })
if (!exists) return reply.code(404).send({ message: 'Post not found' })
await app.prisma.post.delete({ where: { id: request.params.id } })
return reply.code(204).send()
})
}
Важный нюанс v5, связанный с DELETE: в v4 Fastify допускал DELETE-запросы с заголовком Content-Type: application/json и пустым телом; в v5 это больше не разрешено. Не отправляйте заголовок Content-Type, если тело запроса отсутствует.
Обработка ошибок, хуки и подготовка к продакшену
Централизуйте обработку ошибок с помощью setErrorHandler и перехватывайте жизненный цикл запроса через хуки — два механизма расширения, заменяющих цепочку middleware в Express. В справочнике по хукам описан порядок выполнения в рамках запроса: onRequest → preParsing → preValidation → preHandler → handler → preSerialization → onSend → onResponse, при этом onError срабатывает при возникновении исключения.
class NotFoundError extends Error {
constructor (message) { super(message); this.statusCode = 404 }
}
app.setErrorHandler((error, request, reply) => {
request.log.error(error)
const status = error.statusCode ?? 500
reply.code(status).send({
error: error.name,
message: status === 500 ? 'Internal Server Error' : error.message
})
})
// Пример хука preHandler: проверка перед выполнением обработчика
app.addHook('preHandler', async (request) => {
request.log.info({ url: request.url }, 'incoming request')
})
Ошибка валидации прерывает выполнение до вызова обработчика, автоматически возвращая 400 — одна из причин, по которым схемы сокращают код обработчиков. Для замера времени внутри хука используйте reply.elapsedTime; метод reply.getResponseTime() был удалён в v5, вместо него следует использовать reply.elapsedTime.
Далее следует чеклист для продакшена — установка плагинов, каждый из которых является пакетом с областью видимости @fastify/* и регистрируется так же, как ваш плагин маршрутов:
@fastify/jwt— для аутентификации;@fastify/swagger— для генерации OpenAPI-документации непосредственно из схем маршрутов;@fastify/rate-limit— для ограничения частоты запросов;@fastify/cors— для управления доступом из других источников.
Подробные сценарии аутентификации и деплоя выходят за рамки данного руководства — для них существуют отдельные материалы. Если вы хотите наблюдать за поведением этих эндпоинтов под реальной нагрузкой, подключите к фронтенду, потребляющему API, инструменты мониторинга ошибок Node.js.
Теперь у вас есть работающий CRUD API на Fastify v5, где схемы валидируют входные данные, сериализуют выходные и документируют себя сами, а база данных используется совместно через декоратор вместо глобального импорта. Следующий конкретный шаг: добавьте @fastify/swagger, укажите ему на уже написанные схемы и наблюдайте, как спецификация OpenAPI генерируется автоматически — наглядное подтверждение того, что в Fastify схема является единственным источником истины, а не второстепенным дополнением.
Часто задаваемые вопросы
В чём разница между fastify.register и fastify.decorate?
Метод register монтирует плагин в собственном контексте инкапсуляции, поэтому маршруты, хуки и декораторы, добавленные внутри него, остаются в области видимости этого плагина и его дочерних элементов. Метод decorate прикрепляет переиспользуемое свойство или метод непосредственно к экземпляру Fastify, объекту запроса или ответа. Как правило, decorate используется для предоставления общих ресурсов, например клиента базы данных, а сам плагин оборачивается в fastify-plugin, чтобы декоратор вышел за пределы инкапсуляции и стал доступен во всём приложении.
Насколько Fastify быстрее Express?
Собственный бенчмарк Fastify показывает обработку свыше 76 тысяч запросов в секунду, однако мейнтейнеры оговариваются, что это синтетический тест «hello world», измеряющий накладные расходы фреймворка, а не реальную пропускную способность. Механическое преимущество вполне конкретно: Fastify компилирует JSON Schema в специализированные функции валидации и сериализации при запуске, поэтому генерация ответа минует накладные расходы обобщённого JSON.stringify. Всегда проводите бенчмарк собственного приложения, прежде чем приводить какие-либо цифры.
Почему схема маршрута в Fastify v5 не проходит валидацию, хотя в v4 всё работало?
Начиная с v5 сокращённый синтаксис схем и опция jsonShortHand были удалены, поэтому каждая схема querystring, params, body и response должна быть полноценной JSON Schema с явно указанным свойством type. Схема, объявляющая только свойства без type на верхнем уровне, больше не будет проходить валидацию. Добавьте type 'object' к каждому объекту схемы. Руководство по миграции на v5 фиксирует это как намеренное несовместимое изменение по сравнению с сокращённым синтаксисом v4.
Можно ли использовать Fastify с TypeScript без двойного объявления типов?
Да. Установите typebox как peer-зависимость и пакет @fastify/type-provider-typebox, затем вызовите Fastify().withTypeProvider с обобщённым типом TypeBoxTypeProvider. Вы объявляете каждую схему один раз с помощью Type, а Fastify автоматически выводит типы запроса и ответа из неё, устраняя расхождение между правилами валидации и интерфейсами TypeScript. Импортируйте Type из @fastify/type-provider-typebox, а не из устаревшего @sinclair/typebox, упоминаемого в старых руководствах, и повторно объявляйте провайдер в каждом плагине, которому он необходим.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k