This tutorial shows you how to build a feature-complete API using Node.js, Fastify, and TypeScript that lets clients perform data operations on resources. Fastify is a high-performance Node.js web framework focused on developer experience and speed. Using TypeScript with Fastify gives you access to optional static type-checking along with robust tooling for large apps and the latest ECMAScript features.
Learn also how to define data models using Prisma, validate data with Zod, handle errors gracefully using
and centralized handlers, implement dependency injection, use modern ID strategies (UUIDv7), and quickly build modular endpoints. As an option, you can also learn how to secure the API using Auth0 (covered in follow-up resources).neverthrow
What You Will Build
You'll learn how to create and secure a feature-complete Fastify API through hands-on practice. This API will be part of a simple document management full-stack application where users can create and share documents with others. This tutorial will focus on building the API. In contrast, the next tutorial will focus on integrating Auth0 for user authentication to secure access to the client and server applications.
You'll use Prisma with an SQLite database for data persistence, providing a more realistic setup than an in-memory store.
Ensure you have a recent version of Node.js and npm installed; targeting a current LTS release like
v20.x
or newer is recommended. If you need to install them, follow the instructions provided by the Node.js Foundation for your operating system.This tutorial uses recent versions of Fastify, Prisma, and other libraries. Always check the official documentation for the latest features and potential breaking changes.
Create a Fastify Project
The Fastify CLI is a tool that lets us create a Fastify project that supports TypeScript easily and quickly with sensible defaults and a well-organized project structure.
Run the following command to generate a Fastify project anywhere in your system:
npx fastify-cli generate content-api --lang=typescript
If you see the following prompt, type
y
and press <Enter>
or <Return>
to proceed:Need to install the following packages: fastify-cli@7.4.0 Ok to proceed? (y)
Once the Fastify CLI completes generating your project, you'll see a list of all the generated files in the terminal, along with commands that you can run to use your Fastify project:
generated README.md generated .gitignore generated tsconfig.json generated src/app.ts generated test/helper.ts generated test/tsconfig.json generated src/plugins/README.md generated src/routes/README.md generated test/plugins/support.test.ts generated test/routes/root.test.ts generated src/plugins/sensible.ts generated src/plugins/support.ts generated src/routes/root.ts generated test/routes/example.test.ts generated src/routes/example/index.ts --> reading package.json in content-api edited package.json, saving saved package.json --> project content-api generated successfully run 'npm install' to install the dependencies run 'npm start' to start the application run 'npm build:ts' to compile the typescript application run 'npm run dev' to start the application with pino-pretty pretty logging (not suitable for production) run 'npm test' to execute the unit tests
Make the newly generated project directory
content-api
your current directory:cd content-api
Install the project dependencies:
npm install
Open the project in your IDE or code editor of choice, and let's take a quick tour of the value and features that this Fastify project gives us out of the box.
Understanding the Fastify Project Structure
The Fastify CLI generates a project that provides a structured starting point that prioritizes clean code, modularity, and ease of maintenance, especially as your application grows in complexity. It provides a solid foundation for building web applications and APIs that favor convention over configuration, largely reducing boilerplate code. Let's break down the core components and their roles.
Core Directories and Files
Root directory:
: The heart of your Node.js project.package.json
- Lists dependencies (the libraries your project needs to run, like
).fastify
- Lists devDependencies (libraries needed for development, like
,typescript
for testing).tap
- Defines scripts (
,npm start
,npm run dev
) for common tasks.npm test
- Lists dependencies (the libraries your project needs to run, like
: Configures the TypeScript compiler. Using TypeScript adds type safety, which helps catch errors early and improves code clarity.tsconfig.json
: Tells Git version control which files/folders to ignore (e.g.,.gitignore
, compiled code,node_modules/
files). Essential for keeping your repository clean..env
: Basic documentation for your project (how to install, run, etc.).README.md
directory (source code):src/
- This is where your application code will live.
: The module serves as the application entry point.app.ts
- It sets up your Fastify server instance and configures its behavior, which you can extend as you build up your application.
- It uses the
plugin to automatically discover and register your routes and plugins based on an opinionated directory structure, eliminating lots of manual@fastify/autoload
andimport
calls.register
: This directory contains reusable code modules known as Fastify plugins.plugins/
- The purpose of Fastify plugins is to encapsulate specific functionalities shared across your application, such as connecting to a database, setting up authentication, adding utility functions to the Fastify server instance, or integrating third-party services.
- The generated project includes two plugins:
: This plugin adds useful utilities to your Fastify instance for HTTP error handling and responses.@fastify/sensible
- A plugin that serves as an example of how to create your own reusable plugin.
- The
plugin used inautoload
finds and registers the plugins that the files in this directory export.app.ts
: This directory defines your application API endpoints.routes/
- It organizes the different resources and actions your API exposes using an opinionated file structure:
- You can create files like
to define a route.users.ts
- You can also create subdirectories like
to hold any files related to the business logic of your route. However, you must have a file namedusers
that exports a function where you define routes usingindex.ts
,fastify.get()
, etc.fastify.post()
- You can create files like
finds the files in this directory and registers all the defined routes.autoload
- The generated project includes an example file,
, that defines the route for the base path (root.ts
) along with an/
directory with a sample route.example/
- It organizes the different resources and actions your API exposes using an opinionated file structure:
directory:test/
- This directory holds automated tests for your application that help verify that your code works correctly and detect regressions (when a change breaks existing functionality).
- The Fastify CLI uses the
test running by default along withnode:test
for code coverage that uses Node.js' built-in functionality.c8
- It includes example tests for the default routes and plugins to show you how to write your own.
The Fastify workflow
Let's cover how all these modules come together to give you an efficient and delightful developer experience.
- Start: You run
ornpm run dev
.npm start
- Initialize:
runs, creating the Fastify server instance.src/app.ts
- Load Plugins:
tellsapp.ts
to scan the@fastify/autoload
directory. Autoload finds and registers all valid plugins, potentially adding new features or methods to Fastify.src/plugins
- Load Routes:
tellsapp.ts
to scan the@fastify/autoload
directory. Autoload finds all route definition files and registers every endpoint defined within them.src/routes
- Ready: Fastify finishes setting up the routes and starts the HTTP server, listening for incoming requests.
As such, run the following command to get your Fastify server up and running in development mode:
npm run dev
Load Environment Variables
Instead of using hard-coded configuration variables within files throughout your project, you can define them in a central location and load them into modules that need them. Developers working with Node.js commonly define this central location as a hidden file named
.env
, which you can create as follows:touch .env
Populate the
.env
hidden file with the following variable that defines the port your server can use to listen for requests and the allowed origins for CORS as an example:PORT=8080 # Example: Allow a client running on these URLs to access your API CORS_ALLOWED_ORIGINS=http://localhost:3000
⚠️ Immediately, add the following entry at the end of the
file to prevent committing the .gitignore
file to version control:.env
.env*
Let's use the
Fastify plugin to check environment variables and make them type-safe.@fastify/env
Install the package:
npm i @fastify/env
Update the
src/app.ts
file as follows to import and use the @fastify/env
plugin:import { join } from "node:path"; import AutoLoad, { AutoloadPluginOptions } from "@fastify/autoload"; import { FastifyPluginAsync, FastifyServerOptions } from "fastify"; import Env from "@fastify/env"; type Envs = { PORT: number; CORS_ALLOWED_ORIGINS: string; }; const envOptions = { schema: { type: "object", required: ["PORT", "CORS_ALLOWED_ORIGINS"], properties: { PORT: { type: "number", }, CORS_ALLOWED_ORIGINS: { type: "string", }, }, }, }; export interface AppOptions extends FastifyServerOptions, Partial<AutoloadPluginOptions> {} // Pass --options via CLI arguments in command to enable these options. const options: AppOptions = {}; const app: FastifyPluginAsync<AppOptions> = async ( fastify, opts ): Promise<void> => { // Place here your custom code! await fastify.register(Env, envOptions); const { CORS_ALLOWED_ORIGINS } = fastify.getEnvs<Envs>(); fastify.log.info(CORS_ALLOWED_ORIGINS); // Do not touch the following lines... }; export default app; export { app, options };
As we are using TypeScript, we must define an
Envs
type for the shape of our .env
file. We also need to define a schema within an envOptions
object that @fastify/env
can use to validate the values of our env vars at startup time.We then use the
register
API to add the @fastify/env
plugin to our Fastify instance, passing the envOptions
object as its second argument to customize its behavior.The
register
API follows that pattern, which you'll use throughout this tutorial:fastify.register(plugin, [options]);
The
@fastify/env
plugin exposes the environment variable in two ways:- It adds a
property to theconfig
instance, which contains the name and values of the content of thefastify
file..env
- The default name of this property is
. However, you can add aconfig
property to theconfKey
object to customize its name. Whatever the value ofenvOptions
is will be the name of this property.confKey
- The default name of this property is
- It adds a
method to thegetEnvs()
instances that returns an object with the content of thefastify
file..env
The
getEnvs()
approach is the easiest method to use with TypeScript as you can pass it a generic type to add type to the object it returns. Using the config
approach requires using declaration merging to type the fastify.config
object properly, which involves more lines of code than the simple Envs
type definition.The
fastify.log.info(CORS_ALLOWED_ORIGINS);
line allows you to see the value from the .env
file logged in the console, validating that the @fastify/env
plugin is working correctly.Adding Fastify Security Plugins
When building web applications, you often have a frontend running in the user's browser that needs to communicate with your backend API, potentially running on another domain. For security reasons, browsers enforce the Same-Origin Policy, which restricts webpages from making requests to a different origin (domain, protocol, or port) than their own. This default behavior presents a problem: it blocks legitimate requests from your frontend to your API. To enable this communication, your API server must utilize Cross-Origin Resource Sharing (CORS) headers to explicitly tell the browser which external origins can access its resources, effectively creating a controlled exception to the Same-Origin Policy.
Furthermore, web applications are targets for various common attacks. Malicious actors might try to inject harmful scripts into your pages (Cross-Site Scripting or XSS) or trick users by embedding your site within a malicious one (clickjacking). To help mitigate these threats, servers can send specific HTTP security headers in their responses. These headers act as instructions for the browser, enabling built-in security features like enforcing HTTPS connections (
Strict-Transport-Security
) or preventing browsers from guessing content types (X-Content-Type-Options: nosniff
). The problem is that manually configuring the right set of these security headers can be complex and easy to get wrong. Therefore, you need a reliable method to apply these essential security headers consistently across your API responses, strengthening its defenses against common vulnerabilities.Let's use two Fastify plugins to improve the security posture of this application:
: Fastify plugin to enable and configure Cross-Origin Resource Sharing (CORS).@fastify/cors
: Fastify plugin to secure your app by setting various HTTP headers, mitigating common attack vectors.@fastify/helmet
Runt the following command to install those plugins:
npm i @fastify/cors @fastify/helmet
Once installed, update the imports section in the
src/app.ts
file to import those two plugins as Cors
and Helmet
:import { join } from "node:path"; import AutoLoad, { AutoloadPluginOptions } from "@fastify/autoload"; import { FastifyPluginAsync, FastifyServerOptions } from "fastify"; import Env from "@fastify/env"; import Cors from "@fastify/cors"; import Helmet from "@fastify/helmet";
Next, update the
app
function in the src/app.ts
file to register those plugins with their corresponding options:const app: FastifyPluginAsync<AppOptions> = async ( fastify, opts ): Promise<void> => { // Place here your custom code! await fastify.register(Env, envOptions); const { CORS_ALLOWED_ORIGINS } = fastify.getEnvs<Envs>(); await fastify.register(Cors, { origin: CORS_ALLOWED_ORIGINS.length > 0 ? CORS_ALLOWED_ORIGINS : false, methods: ["GET", "POST", "PUT", "DELETE"], }); await fastify.register(Helmet); // Do not touch the following lines... };
The
Cors
plugin uses the parsed CORS_ALLOWED_ORIGINS
value loaded by the Env
plugin to restrict access to only the origins defined there. If that value happens to be blank, the default value for origin
is then false
, disabling CORS.The
@fastify/helmet
plugin is a port from the Express.js helmet
middleware, a collection of 14 small functions that set HTTP response headers. Adding helmet()
to your Fastify application doesn't include all of these functions but provides you with sensible defaults such as DNS Prefetch Control, Frameguard, Hide Powered-By, HSTS, IE No Open, Don't Sniff Mimetype, and XSS Filter.Initialize Prisma
Install
, which is the auto-generated and type-safe database client for Prisma ORM:@prisma/client
npm i @prisma/client
Also, install
, which is the Prisma command-line interface (CLI) tool used for managing database schema migrations, introspection, and generating the Prisma Client:prisma
npm i -D prisma
Now, initialize Prisma in your project. This will create a
prisma
directory containing your schema definition file and set up the database connection:npx prisma init --datasource-provider sqlite
This command does two things:
- Creates a
directory with aprisma
file. This is where you define your database models.schema.prisma
- Creates or updates a
file to include a.env
variable pointing to a default SQLite database file, which in this case isDATABASE_URL
.file:./dev.db
Model Data with Prisma
Before creating any other plugins, let's define the structure of the data for this simple document management application.
Users can read, create, update, and delete documents. Users can also share their documents with others by adding other users as collaborators. As such, we need the following models:
model: Represents the users of your application.User
- It stores essential user information, such as user ID, username, and timestamps on when the user record was created and last updated.
- As you'll use Auth0 later on to secure access to the application, this model also stores the Auth0 ID of the user, which will help you link the internal database record with an external user identity record in the Auth0 layer.
model: Represents the documents created within the application.Document
- It stores the document information such as title, content, timestamps on when the user record was created and last updated, who created it, and who can access it as a collaborator.
model: Acts as a join table to manage the many-to-many relationship betweenDocumentCollaborator
andUsers
for collaboration purposes.Documents
- It specifies which users have access to which documents, even if they don't own them, and their access level.
- Collaborators can have different access levels defined via an
enum:AccessLevel
andVIEWER
.EDITOR
You'll define the structure of these resources using Prisma and Zod.
Define the Prisma Schema
Open
prisma/schema.prisma
. This file defines your database schema using the Prisma Schema Language. Modify the file to include a Document
model:generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } enum AccessLevel { VIEWER EDITOR } model User { id String @id // App-generated UUID v7 primary key auth0Id String @unique // Auth0 'sub', unique and optional username String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt documentsAsOwner Document[] @relation("Owner") documentsAsCollaborator DocumentCollaborator[] } model Document { id String @id // App-generated UUID v7 primary key title String content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt ownerId String // Foreign key to User.id (app-generated UUID) owner User @relation("Owner", fields: [ownerId], references: [auth0Id], onDelete: Cascade) collaborators DocumentCollaborator[] @@index([ownerId]) } model DocumentCollaborator { documentId String document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) userId String // Foreign key to User.id (app-generated UUID) user User @relation(fields: [userId], references: [auth0Id], onDelete: Cascade) accessLevel AccessLevel assignedAt DateTime @default(now()) @@id([documentId, userId]) }
Your application code will generate the IDs for models using UUIDv7 values. Prisma supports various ID strategies, such as using autoincrement to create unique IDs. However, generating UUIDs v7 in your application code gives IDs that are time-sortable, have global uniqueness across distributed systems due to precise timestamping, and afford you optimized indexing. Learn more about the benefits of UUIDv7.
Time-sortability is useful for efficiently querying records based on creation order (like recent documents), often without needing a separate index on a
createdAt
column, unlike UUIDv4, which is random. The global uniqueness ensures that IDs generated across different servers or database shards won't collide, which is critical for scalable, distributed architectures, unlike traditional auto-incrementing IDs, which are only unique within a single database instance.Let's cover how data will flow in the application and how these models relate.
User identification and creation
When a user signs up, your application creates a corresponding
User
record in the database. The application generates its own unique id
(a UUID v7) for this user record.For this tutorial part, you'll seed the database with user data that you can use from a client application to test your implementation without any access control or user authentication mechanism. However, once authentication is implemented, you'll obtain a unique identifier from Auth0 (the
sub
claim, such as auth0|xxxxxxxx
) that you'll store in the auth0Id
field to quickly look up your users and their data upon login.Document ownership
Every document must have an owner. The
Document.ownerId
field stores the auth0Id
of the User
who owns the document. You'll use the auth0Id
instead of the id
for faster lookups from the client application after users log in. The Document.owner
relation links the Document.ownerId
field back to the User.auth0Id
field. This explicitly tells Prisma: "The value in ownerId
corresponds to the auth0Id
of a user."This setup creates a one-to-many relationship: One
User
(identified by auth0Id
) can own many Documents
.Document collaboration
A
User
can collaborate on multiple Documents
, and a Document
can have multiple Users
collaborating on it. This is a many-to-many relationship.The
DocumentCollaborator
table manages this:- Each row represents a single collaboration grant.
links to theDocumentCollaborator.documentId
.Document.id
links to theDocumentCollaborator.userId
. So that the value stored inUser.auth0Id
must match theDocumentCollaborator.userId
of an existing user.auth0Id
specifies the permission level:DocumentCollaborator.accessLevel
orVIEWER
.EDITOR
- The
defines a composite primary key, ensuring that a specific user can only have one entry (one access level) per specific document in this table.@@id([documentId, userId])
Cascading Deletes (onDelete: Cascade
)
onDelete: Cascade
If a
User
is deleted, the onDelete: Cascade
on the relations triggers the following behavior:- Any
records they owned (Document
relation) will also be deleted.Document.owner
- Any
entries linking them to documents (DocumentCollaborator
relation) will also be deleted.DocumentCollaborator.user
On the other hand, if a Document is deleted, any
DocumentCollaborator
entries associated with that document (DocumentCollaborator.document
relation) will also be deleted.The
DocumentCollaborator
model is critical for adding and managing collaborators; however, that functionality is outside the scope of this specific tutorial part.Apply Schema Changes to Database
Now, synchronize your database schema with your Prisma schema definition. It's best practice to use Prisma Migrate to track schema changes over time, especially as your application evolves.
First, generate the Prisma Client based on your updated schema:
npx prisma generate
This updates the types available in
@prisma/client
based on your models.Next, create and apply a migration. This command will create an SQL migration file in
prisma/migrations
and apply it to your database. Choose a descriptive name (e.g., init
or add_data_model
). Suppose your database already exists, and the change isn't straightforward (like adding a required column to a table with data). In that case, Prisma will guide you, potentially suggesting a reset, which is often fine in early development or requires manual steps.npx prisma migrate dev --name init
If Prisma resets the database during
migrate dev
, or if it's the first time setting up, your database will be empty. We need to populate it with initial data.⚠️ Immediately, add the following entry at the end of the
file to prevent committing your local SQLite database to version control:.gitignore
prisma/dev.db
Seed the Database
Prisma offers seeding functionality that allows you to consistently re-create the same data in your database, which helps populate your database with data that is required for your application to start.
For the seeding functionality to work, you need to add a
prisma
property in your package.json
file that, in turn, has a seed
property with the command Prisma should run to initialize your database with data.Let's create the script to populate the database with initial documents and generate correct IDs. Let's also add a convenient way to run this script via npm.
First, create a
seed.ts
file under the prisma
directory:touch prisma/seed.ts
Then, populate
prisma/seed.ts
with the following content:import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); export const seedDatabase = async (): Promise<void> => { // Check if we already have users const userCount = await prisma.user.count(); if (userCount === 0) { console.log("Database is empty. Seeding initial user and documents..."); // 1. Create a sample user const userAuth0Id = `auth0|demo-user`; const user = await prisma.user.create({ data: { id: "0195ed4b-8d27-722f-a570-2ebd00cf9ce8", auth0Id: userAuth0Id, username: "demo-user", }, }); console.log(`Created user: ${user.username} (Auth0 ID: ${user.auth0Id})`); // 2. Create sample documents owned by the user await prisma.document.createMany({ data: [ { id: "0195ed4b-8d29-7588-ae8d-7305e542c305", title: "Building RAG Systems with LLMs", content: "An introductory guide to Retrieval-Augmented Generation (RAG) patterns for enhancing Large Language Model responses with external knowledge...", ownerId: userAuth0Id, }, { id: "0195ed4b-8d29-7588-ae8d-76aca6b915c7", title: "Fine-Tuning Models: Best Practices", content: "Key considerations and steps for effectively fine-tuning pre-trained models like Llama 3 or GPT-4 for specific tasks...", ownerId: userAuth0Id, }, { id: "0195ed4b-8d29-7588-ae8d-78361aa3e97d", title: "Evaluating Agent Performance", content: "Discussion notes on metrics and frameworks for evaluating the performance and reliability of autonomous AI agents...", ownerId: userAuth0Id, }, ], }); const documentCount = await prisma.document.count(); console.log(`Created ${documentCount} documents.`); console.log("Database seeded successfully."); } else { console.log("Database already contains users. Skipping seed."); } }; seedDatabase() .then(async () => { await prisma.$disconnect(); }) .catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });
Then, add the
prisma
section in your package.json
file:{ "prisma": { "seed": "ts-node prisma/seed.ts" } }
Using
ts-node
allows the seed script to be written in TypeScript.Finally, add a
db:seed
script to your package.json
to make it easier for you to run that command:{ "scripts": { // Other properties... "db:seed": "prisma db seed" } }
Now, run the seed script:
npm run db:seed
This command executes your
prisma/seed.ts
script, populating the Document
table if it's empty.Visualize Your Database with Prisma Studio
While developing, seeing the actual data in your database is often helpful. Prisma provides a web application called Prisma Studio to accomplish that task.
You can open Prisma Studio by running the following command in your terminal:
npx prisma studio
Your server doesn't have to be running for this; it connects directly to the DB based on your schema.
This will open a new tab in your web browser with an interface that lets you view and manipulate the data in your connected database: the
dev.db
SQLite file in the prisma
folder in this case. You can see your models, like Document
, browse rows, and create or edit data directly.This is incredibly useful for verifying that your API operations are working correctly or setting up test data quickly.
Add it as a script in
package.json
for easier access:{ "scripts": { // Other properties... "db:seed": "prisma db seed", "db:studio": "prisma studio" } }
Now, you can run
npm run db:studio
whenever you want to launch this data visualization tool.Create a Prisma Fastify plugin
As stated before, Fastify allows you to extend its functionality with plugins. You can create a Fastify Prisma plugin to instantiate and expose the Prisma Client instance to the rest of your application via the
fastify
server instance in src/app.ts
by defining a prismaPlugin
.Create a
prisma.ts
file under the src/plugins
subdirectory:touch src/plugins/prisma.ts
Add the following content to that file:
import fp from "fastify-plugin"; import { FastifyPluginAsync } from "fastify"; import { PrismaClient } from "@prisma/client"; declare module "fastify" { interface FastifyInstance { prisma: PrismaClient; } } const prismaPlugin: FastifyPluginAsync = fp( async (fastify, options) => { const prisma = new PrismaClient(); await prisma.$connect(); fastify.decorate("prisma", prisma); fastify.addHook("onClose", async (instance) => { await instance.prisma.$disconnect(); }); }, { name: "prismaPlugin", } ); export default prismaPlugin;
The Prisma plugin definition uses TypeScript module augmentation to define a
prisma
property in the Fastify instance interface and define its type as PrismaClient
.The plugin starts the database connection using
prisma.$connect()
and also terminates it using the event handler of the Fastify onClose
hook.Fastify offers a system of hooks that allow you to listen to specific events in the application or request-response lifecycle. You can register a hook using the
fastify.addHook
method.Fastify triggers
event when it invokes the onClose
fastify.close()
method to stop the server, after all in-flight HTTP requests have been completed. It's helpful and ideal to add the onClose
hook to a plugin that needs a "shutdown" event, for example, to close an open connection to a database, as is our case with this Prisma plugin.Fastify also offers a decorators API to customize core Fastify objects, such as the server instance and any request and reply objects used during the HTTP request-response lifecycle. You can use the
fastify.decorate()
method to attach any property to core objects, such as functions, plain objects, or native types. In this case, the plugin attaches the prisma
property to the Fastify server instance to make the Prisma Client available through the instance via fastify.prisma
.To register the Prisma plugin, do you need to update the
app
function in the src/app.ts
file? You don't. Thanks to the AutoLoad
plugin registered in that file, which loads all plugins defined in the plugins
subdirectory.Define Data Schemas with Zod
While Prisma defines our database structure, we need a way to define and validate the shape of data for our API, such as request bodies, parameters, and responses. We also want to ensure our TypeScript code uses accurate types for this data. Instead of maintaining separate interfaces and manual validation logic, we'll use Zod, a TypeScript-first schema declaration and validation library.
Zod allows us to define a schema as a single source of truth, from which we can:
- Infer TypeScript types.
- Generate validation logic.
- Generate JSON Schema for Fastify to optimize request validation and response serialization.
You need the Zod and its JSON Schema generator packages:
: A TypeScript-first schema declaration and validation library. We'll use it as our single source of truth for data shapes and validation rules.zod
: Utility to convert Zod schemas into JSON Schema format, which Fastify uses for optimized validation and serialization.zod-to-json-schema
Install them by running the following command:
npm install zod zod-to-json-schema
Now, create the file where our document-related Zod definitions will live:
# Ensure the documents directory exists mkdir -p src/routes/documents touch src/routes/documents/document.zod.ts
Open
src/routes/documents/document.zod.ts
and start by importing Zod and the JSON schema generator:import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema";
Let's follow a series of steps to gradually build up our schemas file.
Define the base input schema
Let's define the core properties and validation rules for data needed when creating or updating a document. For now, the client only needs to provide the
title
and content
.// ... imports ... // Base schema for properties common to create/update inputs const DocumentInputBaseSchema = z .object({ title: z.string().min(1, { message: "Title must not be empty" }), content: z.string().min(1).max(2000), }) .strict(); // Disallow extra properties
The
DocumentInputBaseSchema
definition defines the following validation rules:
: To define basic types..string()
,.min()
: For string length constraints..max()
: To ensure that any extra properties not defined in this schema will cause validation to fail, preventing unexpected data from entering our system. This is generally good practice as it prevents clients from sending unexpected or potentially malicious extra fields that might be inadvertently processed or stored by your application and helps catch typos in client-side code..strict()
Define the Create Schema
When creating a document, all base properties (
title
, content
) are required. The DocumentInputBaseSchema
already defines this, so you can simply alias it:// ... (imports and DocumentInputBaseSchema) // Schema for Creating a Document (requires all base properties) export const CreateDocumentSchema = DocumentInputBaseSchema;
Define the Update Schema
When updating a document, the
title
and content
fields are optional. You can achieve this by applying Zod's .partial()
method to the base schema:// ... (imports and other schemas) // Schema for Updating a Document (title and content optional) export const UpdateDocumentSchema = DocumentInputBaseSchema.partial();
Define the Output Schema
Now, let's define the shape of the data you want our API to return. This includes the document details along with information about its owner. Let's define a simple
UserOutputSchema
schema for nesting document owner data in the API response:// ... (imports and other schemas) const UserOutputSchema = z .object({ id: z.string().uuid(), // Assuming internal ID is UUID username: z.string(), }) .strict(); const DocumentOutputSchema = z .object({ id: z.string().uuid({ message: "Invalid UUID format" }), title: z.string(), content: z.string(), // Keep dates as strings for JSON serialization consistency createdAt: z .string() .datetime({ message: "Invalid createdAt ISO date format" }), updatedAt: z .string() .datetime({ message: "Invalid updatedAt ISO date format" }), owner: UserOutputSchema.optional(), // Include the nested owner object // TODO: Add collaborators schema later if needed }) .strict();
You use
z.string().uuid()
to validate the format of our internal ID. Once again, you use .strict()
to ensure only defined properties are sent out.Infer TypeScript Types
One of Zod's major benefits is inferring static TypeScript types directly from the schemas, replacing the need for manually defining interfaces:
// ... (imports and schemas) // Infer TypeScript types from Zod schemas export type CreateDocumentInput = z.infer<typeof CreateDocumentSchema>; export type UpdateDocumentInput = z.infer<typeof UpdateDocumentSchema>; export type DocumentOutput = z.infer<typeof DocumentOutputSchema>;
You can infer types for creating and updating documents, as well as for the API output. Now, you can import these types into our service and router layers, and they will always be synchronized with our validation schemas.
Generate JSON Schema for Fastify
Fastify performs best when validating requests and serializing responses using standard JSON Schema. We can generate this automatically from our Zod schemas using the
zod-to-json-schema
library.At the bottom of the
src/routes/documents/document.zod.ts
file, define the schemas needed for different route parts (such as params
, body
, and response
) and export them in a convenient object:// ... (imports, Zod schemas, inferred types) // Schema for route parameters expecting an ID const IdParamSchema = z.object({ id: z.string().min(1) }); // Exported object containing Fastify-compatible JSON Schemas export const jsonSchemas = { // Schema for GET / (response is an array of Documents) getAllDocuments: { response: { 200: zodToJsonSchema(z.array(DocumentOutputSchema), { name: "DocumentArrayResponse", errorMessages: true, }), }, }, // Schema for GET /:id (params has id, response is single Document) getDocument: { params: zodToJsonSchema(IdParamSchema, { name: "IdParams" }), response: { 200: zodToJsonSchema(DocumentOutputSchema, { name: "DocumentResponse" }), }, }, // Schema for POST / (body is CreateDocumentInput, the response is single Document) createDocument: { body: zodToJsonSchema(CreateDocumentSchema, { name: "CreateDocumentBody" }), response: { 201: zodToJsonSchema(DocumentOutputSchema, { name: "DocumentResponse" }), }, }, // Schema for PUT /:id (params has id, body is UpdateDocumentInput, response is single Document) updateDocument: { params: zodToJsonSchema(IdParamSchema, { name: "IdParams" }), body: zodToJsonSchema(UpdateDocumentSchema, { name: "UpdateDocumentBody" }), response: { 200: zodToJsonSchema(DocumentOutputSchema, { name: "DocumentResponse" }), }, }, // Schema for DELETE /:id (params has id, No body validation needed, response is empty 204) deleteDocument: { params: zodToJsonSchema(IdParamSchema, { name: "IdParams" }), response: { 204: { type: "null" }, }, }, };
We use
zodToJsonSchema()
to convert our Zod definitions into the format Fastify expects for its schema
option in route definitions. This enables automatic request validation and highly optimized response serialization.With
document.zod.ts
complete, we have a single, reliable source for defining, validating, and typing our Document
data throughout the application.Build the Service Layer
The service layer encapsulates the core business logic for interacting with the
Document
resources. It will handle database operations via Prisma and manage predictable error conditions using neverthrow
.Let's define a model to help us make error handling effective before defining the service's business logic.
Define strategy for error handling
In typical JavaScript or TypeScript development, handling operations that might fail often relies on
try...catch
blocks or functions returning potentially null
or undefined
values. This approach can introduce subtle bugs, as it's easy to forget to check for these error conditions, leading to unexpected runtime errors like "Cannot read property 'x' of undefined"
. The problem is that the possibility of failure isn't explicitly encoded in the function's return type, making error handling implicit and reliant on developer discipline or runtime checks.
addresses this by introducing a neverthrow
Result
type, similar to patterns found in languages like Rust. Instead of throwing exceptions or returning ambiguous values like null
, functions return either an Ok
value containing the successful result or an Err
value containing specific error information. This structure forces you, the developer, to explicitly handle success and failure paths wherever you use the result.neverthrow
makes error handling explicit, type-safe, and predictable, moving potential runtime errors to compile-time checks and leading to more robust and maintainable code by ensuring that all possible outcomes of an operation are properly considered.We'll use
neverthrow
to handle the errors of the service layer of this Fastify application. Run the following to install the dependency:npm install neverthrow
In the next sections, you'll see the value proposition of
neverthrow
in action.To decouple our service layer from HTTP details and provide clear, predictable error types, we'll define specific service error codes and a structure for service errors.
Create a
src/models
directory if it doesn't exist:mkdir -p src/models
Create the file
src/models/service-error.model.ts
:touch src/models/service-error.model.ts
Add the following enum and interface to define the structure for errors originating from the service layer:
export enum ServiceErrorCode { NotFound = "NOT_FOUND", Conflict = "CONFLICT", InternalError = "INTERNAL_ERROR", // Add more specific codes as needed (ValidationError, Unauthorized, etc.) } export interface ServiceErrorModel { message: string; code: ServiceErrorCode; }
The
ServiceErrorCode.Conflict
can represent the scenario where a unique constraint violation happens, which the router can communicate to the client using an HTTP 429
status code. The ServiceErrorCode.InternalError
is a catch-all value for unexpected issues, which can prompt the router to reply with an HTTP 500
status code. You can extend these service error codes to include values that communicate issues related to validation, authentication, and any other service layer tasks.The
ServiceErrorModel
interface defines the shape of errors returned by the service layer we'll build in the next section to represent predictable failures.Generating unique IDs
Let's use
to generate a unique identifier following the UUIDv7 format. Run the following command to install the package:uuid
npm i uuid
Build service to perform CRUD operations
Create the Service File and Factory Structure
First, create the service file within the
src/documents
directory:touch src/routes/documents/document.service.ts
You'll use a factory pattern. This is a function that takes dependencies (like a logger and Prisma client) and returns an object containing the actual service methods. This makes dependency injection and testing easier.
Add the basic factory structure to
src/routes/documents/document.service.ts
:import { FastifyBaseLogger } from "fastify/types/logger"; import { PrismaClient } from "@prisma/client"; type DocumentWithOwner = Prisma.DocumentGetPayload<{ include: { owner: true }; }>; interface CreateDocumentServiceParams { logger: FastifyBaseLogger; prisma: PrismaClient; } export const createDocumentService = (params: CreateDocumentServiceParams) => { const { logger, prisma } = params; const errorPrefix = "[Document Service Error]"; const infoPrefix = "[Document Service]"; // Service methods (findAll, find, create, update, remove) will be defined here // Expose public methods return { // Methods will be added here }; }; export type DocumentService = ReturnType<typeof createDocumentService>;
After bringing the required packages, you define a type
DocumentWithOwner
that includes the owner relation to ensure that when you return a document, it contains user metadata about its owner.Then, you define the structure of the service using a factory function,
createDocumentService
. This function takes as parameters an instance of the Fastify logger
object and the Prisma client to apply a dependency injection pattern that will make the service easier to test.You define an error and info prefix to namespace service logs and create some comments that can help you later understand where you need to add methods and values to the service.
Finally, you proactively create and export the type of the returned service object as
DocumentService
for type safety elsewhere.Add Necessary Imports
The service needs access to several other tools: Zod types for input/output, Prisma types and the client,
neverthrow
for error handling, the custom error model (ServiceErrorModel
, ServiceErrorCode
), UUID generator, and Fastify's logger type. Add these imports at the top of the src/routes/documents/document.service.ts
file:import { CreateDocumentInput, UpdateDocumentInput } from "./document.zod"; import { Prisma, PrismaClient } from "@prisma/client"; import { err, ok, Result } from "neverthrow"; import { FastifyBaseLogger } from "fastify/types/logger"; import { v7 as uuidv7 } from "uuid"; import { ServiceErrorCode, ServiceErrorModel, } from "../../models/service-error.model"; // ... (rest of the factory function structure)
Implement findAll
Let's add the method to retrieve all documents for a given user context. Inside the
createDocumentService
function, before the return
statement, add the findAll
function:export const createDocumentService = (params: CreateDocumentServiceParams) => { const { logger, prisma } = params; // ... errorPrefix, infoPrefix ... /** * Retrieves all documents from the database owned by a specific user. * @param ownerId - The ID of the user context (provided by the caller, e.g., router). * @returns Result containing an array of documents or a ServiceErrorModel. */ const findAll = async ( ownerId: string // Service expects the owner context ID ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => { try { const documents: DocumentWithOwner[] = await prisma.document.findMany({ where: { ownerId }, // Use the provided ownerId for filtering include: { owner: true, }, }); return ok(documents); } catch (error) { logger.error( { err: error, ownerId }, `${errorPrefix} Failed to fetch documents for owner` ); return err({ code: ServiceErrorCode.InternalError, message: "Failed to find documents", }); } }; // Expose public methods return { findAll }; };
The
findAll
method retrieves all documents along with their related owner information by calling the findMany()
method on the prisma.document
model to fetch all records from the Document
table. The include: { owner: true }
parameter tells Prisma to also fetch the related owner record for each document through their relationship. The result is stored in the documents
variable, an array of type DocumentWithOwner[]
containing all document objects with their properties plus the complete owner object nested inside each document.In the current setup, the
ownerId
passed into it originates from the simulated context in the router. Later on, that ownerId
should come from the token or session of an authenticated user.The method wraps the result (or caught error) in
neverthrow
's ok
or err
helpers. The return type Promise<Result<DocumentWithOwner[], ServiceErrorModel>>
indicates it returns either an array of the DocumentWithOwner
type on success or a ServiceErrorModel
on failure.You update the return object from the
createDocumentService
function to include findAll
.Implement
by IDfind
Next, add the method to find a single document by its unique ID, ensuring it belongs to the user context provided. Add the
find
function inside createDocumentService
before the return
. This handles the expected "not found" case as a predictable error.export const createDocumentService = (params: CreateDocumentServiceParams) => { // ... logger, prisma, prefixes ... const findAll = async ( ownerId: string ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => { /* ... */ }; /** * Retrieves a document by ID, ensuring it belongs to the specified user context. * @param id - The ID of the document to fetch. * @param ownerId - The ID of the user context (provided by the caller). * @returns Result containing the document or a ServiceErrorModel. */ const find = async ( id: string, ownerId: string // Service expects the owner context ID ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => { try { const document: DocumentWithOwner | null = await prisma.document.findUnique({ where: { id, ownerId }, // Use both id and the provided ownerId include: { owner: true, }, }); if (!document) { logger.warn({ id, ownerId }, `${errorPrefix} Document not found`); return err({ code: ServiceErrorCode.NotFound, message: `Document with ID ${id} not found for this owner`, }); } return ok(document); } catch (error) { logger.error( { err: error, id, ownerId }, `${errorPrefix} Failed to fetch document for owner` ); return err({ code: ServiceErrorCode.InternalError, message: `Failed to find document with ID ${id}`, }); } }; // Expose public methods return { findAll, find }; // Update the return object };
The
find
method uses Prisma's findUnique()
method, searching for a document that matches both the provided id
and the ownerId
from the user context.If
findUnique
returns null
, you treat that as a predictable NotFound
error and return an err
result with the appropriate ServiceErrorCode
.You also update the return object from the
createDocumentService
function to include find
.Implement
with ID Generationcreate
You need a method that takes the validated input data, generates the internal UUID, and saves the new document using the
ownerId
provided by the caller (the router).Add the
create
method inside createDocumentService
after the find
method definition:export const createDocumentService = (params: CreateDocumentServiceParams) => { // ... logger, prisma, prefixes ... const findAll = async ( ownerId: string ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => { /* ... */ }; const find = async ( id: string, ownerId: string ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => { /* ... */ }; /** * Creates a document with a generated UUIDv7 for a specific user. * @param data - The document data (title, content). Expected to include ownerId provided by the caller. * @returns Result containing the created document or a ServiceErrorModel. */ const create = async ( // Service expects data object containing title, content, and ownerId data: CreateDocumentInput & { ownerId: string } ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => { const id = uuidv7(); try { const newDocument: DocumentWithOwner = await prisma.document.create({ // Use title, content, and ownerId from the data object data: { id, title: data.title, content: data.content, ownerId: data.ownerId, }, include: { owner: true, }, }); logger.info( { documentId: newDocument.id, title: newDocument.title, ownerId: newDocument.ownerId, // Log the ownerId used }, `${infoPrefix} Document created successfully` ); return ok(newDocument); } catch (error) { // Catch block for Prisma P2002 (Unique constraint violation) if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" ) { logger.warn( { title: data.title, ownerId: data.ownerId, error: error.meta }, `${errorPrefix} Failed to create document due to unique constraint violation.` ); return err({ code: ServiceErrorCode.Conflict, message: `Document creation failed: A document with similar properties might already exist for the user.`, }); } logger.error( { err: error, title: data.title, ownerId: data.ownerId }, `${errorPrefix} Failed to create document` ); return err({ code: ServiceErrorCode.InternalError, message: "Failed to create document", }); } }; // Expose public methods return { findAll, find, create }; };
This
create
method generates the unique id
using uuidv7()
. It expects the caller (the router) to provide an input object containing title
, content
, and the ownerId
representing the user context. It uses these values in prisma.document.create()
. It specifically catches Prisma's P2002
error code for unique constraint violations, returning a Conflict
error.As before, you add the return object from the
createDocumentService
function to include create
.Implement
by IDupdate
You need a method that updates a document found by its ID, accepting partial data and the
ownerId
passed by the caller (router) to ensure the update is only performed if the document belongs to that user context. It also catches the "record not found" error (P2025
) from Prisma.Add the
update
function inside createDocumentService
after the create
method.export const createDocumentService = (params: CreateDocumentServiceParams) => { // ... logger, prisma, prefixes ... const findAll = async ( ownerId: string ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => { /* ... */ }; const find = async ( id: string, ownerId: string ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => { /* ... */ }; const create = async ( data: CreateDocumentInput ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => { /* ... */ }; /** * Updates an existing document by ID, ensuring the user context owns the document. * @param id - The ID of the document to update. * @param data - Update data (optional title/content) including the ownerId from the user context. * @returns Result containing the updated document or a ServiceErrorModel. */ const update = async ( id: string, data: UpdateDocumentInput & { ownerId: string } ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => { const { ownerId, ...updateData } = data; try { const updatedDocument: DocumentWithOwner = await prisma.document.update({ where: { id, ownerId }, data: updateData, include: { owner: true, }, }); logger.info( { documentId: updatedDocument.id, title: updatedDocument.title, ownerId: updatedDocument.ownerId, }, `${infoPrefix} Document updated successfully` ); return ok(updatedDocument); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025" // Record to update not found (or ownerId didn't match) ) { logger.warn( { id, ownerId }, `${errorPrefix} Document not found for update` ); return err({ code: ServiceErrorCode.NotFound, message: `Document with ID ${id} not found for this owner`, }); } // Catch block for Prisma P2002 (Unique constraint violation) during update if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" ) { logger.warn( { id, data: updateData, ownerId: ownerId, error: error.meta }, `${errorPrefix} Failed to update document due to unique constraint violation.` ); return err({ code: ServiceErrorCode.Conflict, message: `Document update failed: The changes conflict with another existing document.`, }); } logger.error( { err: error, id, ownerId }, `${errorPrefix} Failed to update document` ); return err({ code: ServiceErrorCode.InternalError, message: `Failed to update document with ID ${id}`, }); } }; // Expose public methods return { findAll, find, create, update }; };
The
update
method uses prisma.document.update()
. The where
clause uses both the document id
and the ownerId
provided by the caller (the router). This ensures that the update only proceeds if a document with that id
exists and it belongs to the specified ownerId
. It catches the P2025
error code, mapping it to the NotFound
service error.The return object from the
createDocumentService
function includes update
as well.Implement
by IDremove
Finally, let's implement the deletion logic. It requires both the document
id
and the ownerId
from the caller (router) to ensure the correct document is deleted. It handles the P2025
"not found" error.Add the
remove
function inside createDocumentService
after the update
method definition:export const createDocumentService = (params: CreateDocumentServiceParams) => { // ... logger, prisma, prefixes ... const findAll = async ( ownerId: string ): Promise<Result<DocumentWithOwner[], ServiceErrorModel>> => { /* ... */ }; const find = async ( id: string, ownerId: string ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => { /* ... */ }; const create = async ( data: CreateDocumentInput & { ownerId: string } ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => { /* ... */ }; const update = async ( id: string, data: UpdateDocumentInput & { ownerId: string } ): Promise<Result<DocumentWithOwner, ServiceErrorModel>> => { /* ... */ }; /** * Deletes a document by ID, ensuring the user context owns the document. * @param id - The ID of the document to delete. * @param ownerId - The ID of the user context (provided by the caller). * @returns Result containing void or a ServiceErrorModel. */ const remove = async ( id: string, ownerId: string ): Promise<Result<void, ServiceErrorModel>> => { try { await prisma.document.delete({ where: { id, ownerId }, }); logger.info( { documentId: id, ownerId }, `${infoPrefix} Document deleted successfully by owner` ); return ok(void 0); } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025" // Record to delete not found (or ownerId didn't match) ) { logger.warn( { id, ownerId }, `${errorPrefix} Document not found for deletion` ); return err({ code: ServiceErrorCode.NotFound, message: `Document with ID ${id} not found for this owner`, }); } logger.error( { err: error, id, ownerId }, `${errorPrefix} Failed to delete document` ); return err({ code: ServiceErrorCode.InternalError, message: `Failed to delete document with ID ${id}`, }); } }; // Expose public methods return { findAll, find, create, update, remove }; };
The
remove
method uses prisma.document.delete()
. Similar to update
, the where
clause uses both the id
and the ownerId
provided by the caller (router) to ensure the correct document is targeted. It returns ok(void 0)
on success and handles the P2025
error if the document doesn't exist or isn't owned by the specified user context.The returned object from the
createDocumentService
function now includes remove
to have all the methods related to CRUD operations on the document resource.With these steps, the service layer (
document.service.ts
) is complete. Its methods accept the necessary ownerId
to perform context-aware operations, ready to be called by the router, which will supply this context (initially simulated, later from authentication).Create a Custom Plugin For Services
Earlier, we created a
prismaPlugin
that handles the database connection nicely. But where should we create our DocumentService
? We could technically do it right in src/app.ts
, but that file's already coordinating quite a bit. Plus, as our app grows, we could have more services, such as UserService
or OrderService
, and dumping them all in src/app.ts
would get messy fast.A much cleaner, Fastify-idiomatic way is to create another plugin specifically for your services. This keeps things organized and lets you manage dependencies. This services plugin will:
- Depend on our
so we knowprismaPlugin
is ready when we create it.fastify.prisma
- Create the
instance usingDocumentService
andfastify.prisma
.fastify.log
- Decorate the
instance with our service (fastify
) so our routes can easily access it.fastify.documentService
As we noted with the Prisma plugin, the beauty of using the
@fastify/autoload
plugin for our src/plugins
directory is that we don't even need to touch src/app.ts
again to get this new plugin registered. Autoload will automatically detect our services plugin, check its dependencies (prismaPlugin
), and load it in the correct order.Let's get that plugin out. Create a
services.ts
file under the src/plugins
subdirectory:touch src/plugins/services.ts
Add the following content to that file:
import fp from "fastify-plugin"; import { FastifyInstance, FastifyPluginAsync } from "fastify"; import { createDocumentService, DocumentService, } from "../routes/documents/document.service"; declare module "fastify" { interface FastifyInstance { documentService: DocumentService; // Add other services here if needed } } const services: FastifyPluginAsync = fp( async (fastify: FastifyInstance) => { const documentService = createDocumentService({ logger: fastify.log, prisma: fastify.prisma, }); fastify.decorate("documentService", documentService); fastify.log.info("DocumentService registered"); }, { name: "servicesPlugin", dependencies: ["prismaPlugin"], } ); export default services;
This new plugin follows the same patterns and uses the same APIs we used to create the Prisma plugin. What's new here is that we define a dependency that needs to be loaded before this plugin can be loaded.
Create Fastify Routes
For this application, we want to create endpoints to perform read and write operations on documents:
# get all documents (for the demo user) GET /api/documents # get a document using an id parameter (for the demo user) GET /api/documents/:id # create a document (for the demo user) POST /api/documents # update a document using an id parameter (for the demo user) PUT /api/documents/:id # delete a document using an id parameter (for the demo user) DELETE /api/documents/:id
In this phase of the tutorial, before implementing authentication, you will simulate the user context within the router by using a hardcoded user ID. This ensures our service layer receives the necessary context without relying on insecure client input, such as passing the
ownerId
as a query parameter in the request.As you can see, we must prefix our API routes with
/api
. You can do so easily by making a small update in the app
function present in the src/app.ts
file.Locate the code that uses
AutoLoad
to load all the plugins defined in the routes
directory and update it like so:// This loads all plugins defined in routes // define your routes in one of these // eslint-disable-next-line no-void void fastify.register(AutoLoad, { dir: join(__dirname, "routes"), options: { ...opts, prefix: "/api" }, });
You are adding the
prefix
option to its options
object with the value that we need, /api
, satisfying the requirements of the API design.In Fastify, everything is a plugin, including routers that define your API endpoints. Implementing routes as a Fastify plugin provides the following benefits:
- Encapsulation: A plugin groups related routes and logic within a single module.
- Modularity: A plugin allows you to manage an application feature independently.
- Shared Context: A plugin provides access to a decorated (enhanced)
instance, such as usingfastify
.fastify.documentService
- Now, let's create the basic structure of the documents router plugin:
touch src/routes/documents/index.ts
Then, populate that file with the following content:
import { FastifyPluginAsync } from "fastify"; import { CreateDocumentInput, jsonSchemas, UpdateDocumentInput, } from "./document.zod"; import { ServiceErrorCode } from "../../models/service-error.model"; interface DocumentParams { id: string; } const DEFAULT_OWNER_ID = "auth0|demo-user"; const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => { // Add route definitions here }; export default documents;
You define the
DEFAULT_OWNER_ID
constant within the plugin scope. This ID will be used to simulate the authenticated user context when calling the service layer. For a production application, you use an authenticated user's ID derived from a validated session or token.Define the route to retrieve all documents for the simulated user:
// imports, interface, constant ... const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => { fastify.get( "/", { schema: jsonSchemas.getAllDocuments }, async (request, reply) => { // Simulate getting the ownerId from auth context const ownerId = DEFAULT_OWNER_ID; const result = await fastify.documentService.findAll(ownerId); if (result.isErr()) { fastify.log.error( { error: result.error, ownerId }, "Error fetching all documents for user" ); return reply.status(500).send(result.error.message); } reply.status(200).send(result.value); } ); // Add more route definitions here... }; export default documents;
The
fastify.get()
method is a quick way to define a Fastify route that handles the GET
method. It takes the
route path, configuration options, and the route handler as arguments. All other REST methods have the same
signature, such as fastify.post()
or fastify.put()
.You provide the Zod-generated JSON schema via
jsonSchemas.getAllDocuments
for response serialization. Inside the handler, you use our DEFAULT_OWNER_ID
constant and pass it to fastify.documentService.findAll()
. You access the injected service via the decorated fastify instance: fastify.documentService
to findAll
documents in the database. If the service returns an error (result.isErr()
), you log it and send a 500 response. Otherwise, we send a 200 OK with the documents.Learn more about Fastify validation and serialization.
If this task fails, you throw the error that Fastify's centralized error handler will catch. On success, you reply with a
200 OK
status code along with the array of documents, result.value
.The rest of the route will follow a similar pattern. So, let's define the route to retrieve a specific document by its ID next:
// imports, interface, constant ... const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => { fastify.get( "/", { schema: jsonSchemas.getAllDocuments }, async (request, reply) => { /* ... */ } ); fastify.get<{ Params: DocumentParams }>( "/:id", { schema: jsonSchemas.getDocument }, const id = request.params.id; // Simulate getting the ownerId from auth context const ownerId = DEFAULT_OWNER_ID; const result = await fastify.documentService.find(id, ownerId); if (result.isErr()) { if (result.error.code === ServiceErrorCode.NotFound) { fastify.log.warn( { id, ownerId, error: result.error }, "Failed to find document for user" ); return reply.status(404).send(result.error.message); } fastify.log.warn( { id, ownerId, error: result.error }, "Error finding document for user" ); return reply.status(500).send(result.error.message); } reply.status(200).send(result.value); }, ); // Add more route definitions here... }; export default documents;
This route extracts the
id
from request.params
and uses the DEFAULT_OWNER_ID
when calling fastify.documentService.find()
. It handles the NotFound
service error specifically by returning a 404 status.Next, define the route to create a new document for the simulated user:
// imports, interface, constant ... const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => { fastify.get( "/", { schema: jsonSchemas.getAllDocuments }, async (request, reply) => { /* ... */ } ); fastify.get<{ Params: DocumentParams }>( "/:id", { schema: jsonSchemas.getDocument }, async (request, reply) => { /* ... */ } ); fastify.post<{ Body: CreateDocumentInput }>( "/", { schema: jsonSchemas.createDocument }, async (request, reply) => { const documentData = request.body; const ownerId = DEFAULT_OWNER_ID; const serviceInput = { ...documentData, ownerId: ownerId, }; const result = await fastify.documentService.create(serviceInput); if (result.isErr()) { // Handle potential conflict error from service if (result.error.code === ServiceErrorCode.Conflict) { fastify.log.warn( { error: result.error, body: serviceInput }, "Conflict error creating document for user" ); return reply.status(409).send(result.error.message); } fastify.log.error( { error: result.error, body: serviceInput }, "Error creating document for user" ); return reply.status(500).send(result.error.message); } reply.status(201).send(result.value); } ); // Add more route definitions here... }; export default documents;
Here,
request.body
is validated against CreateDocumentSchema
, which only includes title
and content
. You then create a serviceInput
object, combining the client data with our simulated DEFAULT_OWNER_ID
, before passing it to fastify.documentService.create()
. You also handle the Conflict
error with a 409
status.With that in place, let's define the route to update an existing document for the simulated user:
// imports, interface, constant ... const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => { fastify.get( "/", { schema: jsonSchemas.getAllDocuments }, async (request, reply) => { /* ... */ } ); fastify.get<{ Params: DocumentParams }>( "/:id", { schema: jsonSchemas.getDocument }, async (request, reply) => { /* ... */ } ); fastify.post<{ Body: CreateDocumentInput }>( "/", { schema: jsonSchemas.createDocument }, async (request, reply) => { /* ... */ } ); fastify.put<{ Params: DocumentParams; Body: UpdateDocumentInput }>( "/:id", { schema: jsonSchemas.updateDocument }, async (request, reply) => { const id = request.params.id; const documentData = request.body; const ownerId = DEFAULT_OWNER_ID; const serviceInput = { ...documentData, ownerId: ownerId, }; const result = await fastify.documentService.update(id, serviceInput); if (result.isErr()) { if (result.error.code === ServiceErrorCode.NotFound) { fastify.log.warn( { id, ownerId, error: result.error }, "Failed to update document (not found or wrong user)" ); return reply.status(404).send(result.error.message); } // Handle potential conflict error from service if (result.error.code === ServiceErrorCode.Conflict) { fastify.log.warn( { id, error: result.error, body: serviceInput }, "Conflict error updating document for user" ); return reply.status(409).send(result.error.message); } fastify.log.error( { id, ownerId, error: result.error, body: serviceInput }, "Error updating document for user" ); return reply.status(500).send(result.error.message); } reply.status(200).send(result.value); } ); // Add more route definitions here... }; export default documents;
Similar to
POST
, the PUT
handler combines the validated client data (request.body
containing optional title
/content
) with the simulated DEFAULT_OWNER_ID
into serviceInput
. This object is then passed to fastify.documentService.update()
, which uses the ownerId
within serviceInput
for its where
clause check.Finally, define the route to delete a document for the simulated user:
// imports, interface, constant ... const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => { fastify.get( "/", { schema: jsonSchemas.getAllDocuments }, async (request, reply) => { /* ... */ } ); fastify.get<{ Params: DocumentParams }>( "/:id", { schema: jsonSchemas.getDocument }, async (request, reply) => { /* ... */ } ); fastify.post<{ Body: CreateDocumentInput }>( "/", { schema: jsonSchemas.createDocument }, async (request, reply) => { /* ... */ } ); fastify.put<{ Params: DocumentParams; Body: UpdateDocumentInput }>( "/:id", { schema: jsonSchemas.updateDocument }, async (request, reply) => { /* ... */ } ); fastify.delete<{ Params: DocumentParams }>( "/:id", { schema: jsonSchemas.deleteDocument }, async (request, reply) => { const id = request.params.id; const ownerId = DEFAULT_OWNER_ID; const result = await fastify.documentService.remove(id, ownerId); if (result.isErr()) { if (result.error.code === ServiceErrorCode.NotFound) { fastify.log.warn( { id, ownerId, error: result.error }, "Failed to delete document (not found or wrong user)" ); return reply.status(404).send(result.error.message); } fastify.log.error( { id, ownerId, error: result.error }, "Error removing document for user" ); return reply.status(500).send(result.error.message); } reply.status(204).send(); } ); }; export default documents;
The
DELETE
handler simply extracts the id
from params and uses the DEFAULT_OWNER_ID
when calling fastify.documentService.remove()
. The schema correctly specifies no request body is expected.By building this routes plugin, you've seen the following key Fastify concepts in action:
- Request Validation: Zod schemas defined in
are used within thejsonSchemas
option of each route definition to automatically validate request parameters (schema
) and bodies (Params
). Invalid requests result in a 400 Bad Request response.Body
- Response Serialization: The same schemas define the structure of the response (
), ensuring consistent output and providing performance benefits through Fastify's optimized serialization.Reply
- Service Layer Integration: Route handlers delegate business logic and data operations to the injected
.fastify.documentService
- Simulated User Context: For this unauthenticated phase, the router uses a hardcoded
to provide the necessary user context to the service layer, ensuring security while preparing for real authentication.DEFAULT_OWNER_ID
- Error Handling: Service layer methods return a
. Route handlers check for errors (Result
), log them, and map specific service errors (isErr()
,NotFound
) to appropriate HTTP status codes (404, 409), returning a generic 500 for other errors.Conflict
Error Handling
It's easy to fall into traps related to Cross-Origin Resource Sharing (CORS) errors when building a frontend application that needs to communicate with your Fastify backend, particularly when credentialed requests are involved, such as including
Authorization
headers.While you already set up a CORS plugin on your entry point plugin, the
@fastify/cors
plugin adds CORS headers only to successful responses from defined routes and preflight (OPTIONS
) responses. It does not automatically add CORS headers to responses generated by Fastify's internal default 404 (Not Found)
handler, especially when using the Fastify CLI to run your application.When the browser receives the 404 response lacking the
Access-Control-Allow-Origin
header, it blocks the response due to the CORS policy, even though the 404
status itself might be expected.You can prevent this issue by implementing a custom
404
handler using fastify.setNotFoundHandler
to ensure the necessary CORS headers are added to 404
responses when the request originates from an allowed origin. You also need to do the same for internal error responses generated by Fastify.Add the following code after the registration of the
Cors
and Helmet
plugins in your src/app.ts
file and before Fastify registers the AutoLoad
plugin:fastify.setNotFoundHandler((request, reply) => { // Set CORS headers explicitly reply.header("Access-Control-Allow-Origin", CORS_ALLOWED_ORIGINS); // Or your origin // Add other necessary CORS headers (methods, headers, etc.) if needed reply.code(404).send({ message: "Resource not found" }); }); fastify.setErrorHandler((error, request, reply) => { // Log the error request.log.error(error); // Set CORS headers explicitly reply.header("Access-Control-Allow-Origin", CORS_ALLOWED_ORIGINS); // Or your origin // Add other necessary CORS headers if needed // Send generic error response reply.status(500).send({ message: "Internal Server Error" }); // Or customize based on error type if needed });
Test the Fastify API Endpoints
Ensure your server is running (
npm run dev
). You might need to re-seed your database (npm run db:seed
) if you reset it earlier or made incompatible changes.Use
curl
or a tool like Postman/Insomnia to test the endpoints.Get All Documents (for Demo User)
curl -X GET "http://localhost:3000/api/documents"
Get a Specific Document (for Demo User)
Replace
genai_doc_001
with an actual ID from your seeded data (e.g., 0195ed4b-8d29-7588-ae8d-7305e542c305
).curl -X GET "http://localhost:3000/api/documents/0195ed4b-8d29-7588-ae8d-7305e542c305"
Create a New Document (for Demo User)
curl -X POST "http://localhost:3000/api/documents" \ -H "Content-Type: application/json" \ -d '{ "title": "GenAI Prompt Engineering Guide", "content": "Best practices for writing effective prompts for large language models." }'
Note the server will automatically assign ownership to the demo user.
Update a Document (for Demo User)
Replace
genai_doc_001
with an actual ID.curl -X PUT "http://localhost:3000/api/documents/0195ed4b-8d29-7588-ae8d-7305e542c305" \ -H "Content-Type: application/json" \ -d '{ "title": "Advanced GenAI Prompt Engineering", "content": "Advanced techniques for prompt chaining and few-shot learning in LLMs." }'
This only works if the document is owned by the demo user.
Delete a Document (for Demo User)
Replace
genai_doc_001
with an actual ID.curl -X DELETE "http://localhost:3000/api/documents/0195ed4b-8d29-7588-ae8d-7305e542c305"
This only works if the document is owned by the demo user. No request body is needed.
Test 404 Not Found
Try to get a document that does not exist:
curl -X GET "http://localhost:3000/api/documents/non_existent_doc_id"
Try to get a route that does not exist:
curl -X GET "http://localhost:8080/api/orders
Security Considerations
Currently, all API endpoints are public. You would typically implement authentication and authorization to properly secure the write operations (
POST
, PUT
, DELETE
). This involves:- Integrating an identity provider like Auth0.
- Validating access tokens (e.g., JWTs) on incoming requests.
- Checking user permissions or roles before allowing sensitive operations.
Auth0 recently released a Fastify API SDK for handling JWTs and implementing authorization in your Fastify APIs:
. Be sure to check it out.@auth0/auth0-fastify-api
Conclusion
You've built a robust CRUD API using Node.js, Fastify, and TypeScript. You've leveraged powerful tools and patterns:
- Fastify: For high performance and a great developer experience.
- TypeScript: For static typing and improved code maintainability.
- Prisma: As a type-safe ORM for database interactions (with Prisma Studio for visualization).
- Zod: As a single source of truth for validation and API data shapes.
: For the service layer's functional and explicit error handling.neverthrow
- UUIDv7: For efficient internal IDs and user-friendly public identifiers.
- Dependency Injection: Using Fastify decorators for cleaner service management.
- Centralized Error Handling: Leveraging Fastify's
andsetErrorHandler
for consistent error responses.setNotFoundHandler
This foundation provides a scalable and maintainable structure for building more complex features onto your API. Remember to explore testing strategies (unit, integration) and implement proper security measures for production deployment.
Happy coding!
About the author
Dan Arias
Staff Developer Advocate
The majority of my engineering work revolves around AWS, React, and Node, but my research and content development involves a wide range of topics such as Golang, performance, and cryptography. Additionally, I am one of the core maintainers of this blog. Running a blog at scale with over 600,000 unique visitors per month is quite challenging!
I was an Auth0 customer before I became an employee, and I've always loved how much easier it is to implement authentication with Auth0. Curious to try it out? Sign up for a free account ⚡️.View profile