Building Production-Ready APIs with Fastify and TypeScript

Why Fastify?
Fastify is one of the fastest Node.js web frameworks available, consistently benchmarking at over 70,000 requests per second. Unlike Express, Fastify was built from the ground up with performance, developer experience, and TypeScript in mind. Its plugin architecture, built-in schema validation with JSON Schema, and automatic serialization make it an excellent choice for production APIs.
In this tutorial, you will build a complete REST API for a task management system — from project scaffolding to deployment-ready configuration.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed
- TypeScript fundamentals (interfaces, generics, decorators)
- Basic understanding of REST API concepts
- A code editor (VS Code recommended)
- Docker installed (for the database)
What You'll Build
A production-ready task management API with:
- CRUD operations for tasks and projects
- JWT authentication with refresh tokens
- PostgreSQL database with Drizzle ORM
- Request validation using JSON Schema / TypeBox
- Structured logging with Pino
- Rate limiting and CORS
- Comprehensive error handling
- Unit and integration tests
Step 1: Project Setup
Start by creating a new project and installing dependencies:
mkdir fastify-tasks-api && cd fastify-tasks-api
npm init -yInstall the core dependencies:
npm install fastify @fastify/cors @fastify/rate-limit @fastify/jwt @fastify/swagger @fastify/swagger-ui @sinclair/typebox drizzle-orm postgres dotenvInstall dev dependencies:
npm install -D typescript @types/node tsx vitest drizzle-kitInitialize TypeScript:
npx tsc --initUpdate your tsconfig.json with these settings:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Add scripts to your package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
}Step 2: Project Structure
Organize your project with a clean, modular structure:
src/
├── config/
│ └── env.ts # Environment configuration
├── db/
│ ├── index.ts # Database connection
│ ├── schema.ts # Drizzle schema definitions
│ └── migrate.ts # Migration runner
├── modules/
│ ├── auth/
│ │ ├── auth.routes.ts
│ │ ├── auth.service.ts
│ │ └── auth.schema.ts
│ └── tasks/
│ ├── tasks.routes.ts
│ ├── tasks.service.ts
│ └── tasks.schema.ts
├── plugins/
│ ├── auth.ts # JWT auth plugin
│ ├── cors.ts # CORS plugin
│ └── rate-limit.ts # Rate limiting plugin
├── utils/
│ └── errors.ts # Custom error classes
├── app.ts # Fastify app factory
└── server.ts # Server entry point
Step 3: Environment Configuration
Create a .env file at the project root:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/fastify_tasks
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
PORT=3000
HOST=0.0.0.0
NODE_ENV=developmentNow create the environment configuration module with TypeBox validation:
// src/config/env.ts
import { Type, type Static } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import "dotenv/config";
const EnvSchema = Type.Object({
DATABASE_URL: Type.String(),
JWT_SECRET: Type.String({ minLength: 32 }),
JWT_REFRESH_SECRET: Type.String({ minLength: 32 }),
PORT: Type.Number({ default: 3000 }),
HOST: Type.String({ default: "0.0.0.0" }),
NODE_ENV: Type.Union([
Type.Literal("development"),
Type.Literal("production"),
Type.Literal("test"),
]),
});
type Env = Static<typeof EnvSchema>;
const rawEnv = {
...process.env,
PORT: Number(process.env.PORT) || 3000,
};
if (!Value.Check(EnvSchema, rawEnv)) {
const errors = [...Value.Errors(EnvSchema, rawEnv)];
console.error("❌ Invalid environment variables:");
errors.forEach((err) => console.error(` ${err.path}: ${err.message}`));
process.exit(1);
}
export const env: Env = rawEnv as Env;This gives you validated, type-safe environment variables. If any required variable is missing or invalid, the app exits immediately with a clear error message.
Step 4: Database Schema with Drizzle
Start a PostgreSQL container:
docker run -d --name fastify-pg \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=fastify_tasks \
-p 5432:5432 \
postgres:16-alpineDefine the database schema:
// src/db/schema.ts
import {
pgTable,
uuid,
varchar,
text,
timestamp,
boolean,
pgEnum,
} from "drizzle-orm/pg-core";
export const taskStatusEnum = pgEnum("task_status", [
"todo",
"in_progress",
"done",
"cancelled",
]);
export const taskPriorityEnum = pgEnum("task_priority", [
"low",
"medium",
"high",
"urgent",
]);
export const users = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
name: varchar("name", { length: 100 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const projects = pgTable("projects", {
id: uuid("id").defaultRandom().primaryKey(),
name: varchar("name", { length: 200 }).notNull(),
description: text("description"),
ownerId: uuid("owner_id")
.references(() => users.id)
.notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const tasks = pgTable("tasks", {
id: uuid("id").defaultRandom().primaryKey(),
title: varchar("title", { length: 300 }).notNull(),
description: text("description"),
status: taskStatusEnum("status").default("todo").notNull(),
priority: taskPriorityEnum("priority").default("medium").notNull(),
dueDate: timestamp("due_date"),
completed: boolean("completed").default(false).notNull(),
projectId: uuid("project_id")
.references(() => projects.id)
.notNull(),
assigneeId: uuid("assignee_id").references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});Set up the database connection:
// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "../config/env.js";
import * as schema from "./schema.js";
const client = postgres(env.DATABASE_URL);
export const db = drizzle(client, { schema });
export type Database = typeof db;Create the Drizzle config file at the project root:
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});Generate and run migrations:
npm run db:generate
npm run db:migrateStep 5: Custom Error Handling
Create structured error classes that Fastify can serialize:
// src/utils/errors.ts
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code: string
) {
super(message);
this.name = "AppError";
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(404, `${resource} with id '${id}' not found`, "NOT_FOUND");
}
}
export class UnauthorizedError extends AppError {
constructor(message = "Authentication required") {
super(401, message, "UNAUTHORIZED");
}
}
export class ForbiddenError extends AppError {
constructor(message = "Insufficient permissions") {
super(403, message, "FORBIDDEN");
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(409, message, "CONFLICT");
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(400, message, "VALIDATION_ERROR");
}
}Step 6: Authentication Plugin
Build a reusable JWT authentication plugin:
// src/plugins/auth.ts
import fp from "fastify-plugin";
import fjwt from "@fastify/jwt";
import { type FastifyInstance, type FastifyRequest } from "fastify";
import { env } from "../config/env.js";
import { UnauthorizedError } from "../utils/errors.js";
declare module "fastify" {
interface FastifyInstance {
authenticate: (
request: FastifyRequest
) => Promise<void>;
}
}
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: { userId: string; email: string };
user: { userId: string; email: string };
}
}
export default fp(async (fastify: FastifyInstance) => {
await fastify.register(fjwt, {
secret: env.JWT_SECRET,
sign: { expiresIn: "15m" },
});
fastify.decorate(
"authenticate",
async (request: FastifyRequest) => {
try {
await request.jwtVerify();
} catch {
throw new UnauthorizedError("Invalid or expired token");
}
}
);
});Step 7: Request Validation Schemas with TypeBox
TypeBox generates JSON Schema at runtime while giving you full TypeScript types at compile time. This is one of Fastify's superpowers — your validation and types stay perfectly in sync:
// src/modules/auth/auth.schema.ts
import { Type, type Static } from "@sinclair/typebox";
export const RegisterBody = Type.Object({
email: Type.String({ format: "email" }),
password: Type.String({ minLength: 8, maxLength: 128 }),
name: Type.String({ minLength: 1, maxLength: 100 }),
});
export const LoginBody = Type.Object({
email: Type.String({ format: "email" }),
password: Type.String(),
});
export const AuthResponse = Type.Object({
accessToken: Type.String(),
refreshToken: Type.String(),
user: Type.Object({
id: Type.String(),
email: Type.String(),
name: Type.String(),
}),
});
export type RegisterBodyType = Static<typeof RegisterBody>;
export type LoginBodyType = Static<typeof LoginBody>;
export type AuthResponseType = Static<typeof AuthResponse>;// src/modules/tasks/tasks.schema.ts
import { Type, type Static } from "@sinclair/typebox";
export const TaskParams = Type.Object({
id: Type.String({ format: "uuid" }),
});
export const TaskQuerystring = Type.Object({
status: Type.Optional(
Type.Union([
Type.Literal("todo"),
Type.Literal("in_progress"),
Type.Literal("done"),
Type.Literal("cancelled"),
])
),
priority: Type.Optional(
Type.Union([
Type.Literal("low"),
Type.Literal("medium"),
Type.Literal("high"),
Type.Literal("urgent"),
])
),
projectId: Type.Optional(Type.String({ format: "uuid" })),
page: Type.Optional(Type.Number({ minimum: 1, default: 1 })),
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })),
});
export const CreateTaskBody = Type.Object({
title: Type.String({ minLength: 1, maxLength: 300 }),
description: Type.Optional(Type.String()),
status: Type.Optional(
Type.Union([
Type.Literal("todo"),
Type.Literal("in_progress"),
Type.Literal("done"),
Type.Literal("cancelled"),
])
),
priority: Type.Optional(
Type.Union([
Type.Literal("low"),
Type.Literal("medium"),
Type.Literal("high"),
Type.Literal("urgent"),
])
),
dueDate: Type.Optional(Type.String({ format: "date-time" })),
projectId: Type.String({ format: "uuid" }),
assigneeId: Type.Optional(Type.String({ format: "uuid" })),
});
export const UpdateTaskBody = Type.Partial(CreateTaskBody);
export const TaskResponse = Type.Object({
id: Type.String(),
title: Type.String(),
description: Type.Union([Type.String(), Type.Null()]),
status: Type.String(),
priority: Type.String(),
dueDate: Type.Union([Type.String(), Type.Null()]),
completed: Type.Boolean(),
projectId: Type.String(),
assigneeId: Type.Union([Type.String(), Type.Null()]),
createdAt: Type.String(),
updatedAt: Type.String(),
});
export const TaskListResponse = Type.Object({
data: Type.Array(TaskResponse),
total: Type.Number(),
page: Type.Number(),
limit: Type.Number(),
});
export type TaskParamsType = Static<typeof TaskParams>;
export type TaskQuerystringType = Static<typeof TaskQuerystring>;
export type CreateTaskBodyType = Static<typeof CreateTaskBody>;
export type UpdateTaskBodyType = Static<typeof UpdateTaskBody>;Step 8: Service Layer
Keep your business logic in service modules, separate from route handlers:
// src/modules/tasks/tasks.service.ts
import { eq, and, sql, type SQL } from "drizzle-orm";
import { type Database } from "../../db/index.js";
import { tasks } from "../../db/schema.js";
import {
type CreateTaskBodyType,
type UpdateTaskBodyType,
type TaskQuerystringType,
} from "./tasks.schema.js";
import { NotFoundError } from "../../utils/errors.js";
export class TasksService {
constructor(private db: Database) {}
async list(query: TaskQuerystringType) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const offset = (page - 1) * limit;
const conditions: SQL[] = [];
if (query.status) conditions.push(eq(tasks.status, query.status));
if (query.priority) conditions.push(eq(tasks.priority, query.priority));
if (query.projectId) conditions.push(eq(tasks.projectId, query.projectId));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [data, countResult] = await Promise.all([
this.db
.select()
.from(tasks)
.where(where)
.limit(limit)
.offset(offset)
.orderBy(tasks.createdAt),
this.db
.select({ count: sql<number>`count(*)` })
.from(tasks)
.where(where),
]);
return {
data,
total: Number(countResult[0].count),
page,
limit,
};
}
async getById(id: string) {
const result = await this.db
.select()
.from(tasks)
.where(eq(tasks.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundError("Task", id);
}
return result[0];
}
async create(data: CreateTaskBodyType) {
const result = await this.db
.insert(tasks)
.values({
title: data.title,
description: data.description,
status: data.status ?? "todo",
priority: data.priority ?? "medium",
dueDate: data.dueDate ? new Date(data.dueDate) : null,
projectId: data.projectId,
assigneeId: data.assigneeId,
})
.returning();
return result[0];
}
async update(id: string, data: UpdateTaskBodyType) {
// Verify the task exists first
await this.getById(id);
const result = await this.db
.update(tasks)
.set({
...data,
dueDate: data.dueDate ? new Date(data.dueDate) : undefined,
updatedAt: new Date(),
})
.where(eq(tasks.id, id))
.returning();
return result[0];
}
async delete(id: string) {
await this.getById(id);
await this.db.delete(tasks).where(eq(tasks.id, id));
}
}Step 9: Route Handlers
Now connect everything in route handlers. Notice how Fastify's schema option gives you automatic validation and type inference:
// src/modules/tasks/tasks.routes.ts
import { type FastifyInstance } from "fastify";
import { TasksService } from "./tasks.service.js";
import {
TaskParams,
TaskQuerystring,
CreateTaskBody,
UpdateTaskBody,
TaskResponse,
TaskListResponse,
type TaskParamsType,
type TaskQuerystringType,
type CreateTaskBodyType,
type UpdateTaskBodyType,
} from "./tasks.schema.js";
import { db } from "../../db/index.js";
export default async function tasksRoutes(fastify: FastifyInstance) {
const service = new TasksService(db);
// All routes in this plugin require authentication
fastify.addHook("onRequest", fastify.authenticate);
// GET /tasks
fastify.get<{ Querystring: TaskQuerystringType }>(
"/",
{
schema: {
querystring: TaskQuerystring,
response: { 200: TaskListResponse },
tags: ["Tasks"],
summary: "List all tasks with optional filters",
},
},
async (request) => {
return service.list(request.query);
}
);
// GET /tasks/:id
fastify.get<{ Params: TaskParamsType }>(
"/:id",
{
schema: {
params: TaskParams,
response: { 200: TaskResponse },
tags: ["Tasks"],
summary: "Get a task by ID",
},
},
async (request) => {
return service.getById(request.params.id);
}
);
// POST /tasks
fastify.post<{ Body: CreateTaskBodyType }>(
"/",
{
schema: {
body: CreateTaskBody,
response: { 201: TaskResponse },
tags: ["Tasks"],
summary: "Create a new task",
},
},
async (request, reply) => {
const task = await service.create(request.body);
return reply.status(201).send(task);
}
);
// PATCH /tasks/:id
fastify.patch<{ Params: TaskParamsType; Body: UpdateTaskBodyType }>(
"/:id",
{
schema: {
params: TaskParams,
body: UpdateTaskBody,
response: { 200: TaskResponse },
tags: ["Tasks"],
summary: "Update a task",
},
},
async (request) => {
return service.update(request.params.id, request.body);
}
);
// DELETE /tasks/:id
fastify.delete<{ Params: TaskParamsType }>(
"/:id",
{
schema: {
params: TaskParams,
tags: ["Tasks"],
summary: "Delete a task",
},
},
async (request, reply) => {
await service.delete(request.params.id);
return reply.status(204).send();
}
);
}Step 10: App Factory and Server
Create the application factory. This pattern makes testing much easier since you can create isolated app instances:
// src/app.ts
import Fastify, { type FastifyInstance } from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
import authPlugin from "./plugins/auth.js";
import tasksRoutes from "./modules/tasks/tasks.routes.js";
import { AppError } from "./utils/errors.js";
import { env } from "./config/env.js";
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
logger: {
level: env.NODE_ENV === "production" ? "info" : "debug",
transport:
env.NODE_ENV === "development"
? { target: "pino-pretty", options: { colorize: true } }
: undefined,
},
});
// --- Plugins ---
await app.register(cors, {
origin: env.NODE_ENV === "production"
? ["https://yourdomain.com"]
: true,
credentials: true,
});
await app.register(rateLimit, {
max: 100,
timeWindow: "1 minute",
});
await app.register(authPlugin);
// --- Swagger Docs ---
await app.register(swagger, {
openapi: {
info: {
title: "Fastify Tasks API",
version: "1.0.0",
description: "A production-ready task management API",
},
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
},
security: [{ bearerAuth: [] }],
},
});
await app.register(swaggerUi, {
routePrefix: "/docs",
});
// --- Routes ---
await app.register(tasksRoutes, { prefix: "/api/tasks" });
// --- Health Check ---
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
}));
// --- Global Error Handler ---
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: error.code,
message: error.message,
statusCode: error.statusCode,
});
}
// Fastify validation errors
if (error.validation) {
return reply.status(400).send({
error: "VALIDATION_ERROR",
message: error.message,
statusCode: 400,
});
}
// Unexpected errors
request.log.error(error);
return reply.status(500).send({
error: "INTERNAL_SERVER_ERROR",
message:
env.NODE_ENV === "production"
? "An unexpected error occurred"
: error.message,
statusCode: 500,
});
});
return app;
}// src/server.ts
import { buildApp } from "./app.js";
import { env } from "./config/env.js";
async function start() {
const app = await buildApp();
try {
await app.listen({ port: env.PORT, host: env.HOST });
app.log.info(`Server running at http://${env.HOST}:${env.PORT}`);
app.log.info(`API docs at http://${env.HOST}:${env.PORT}/docs`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
start();Start the dev server:
npm run devVisit http://localhost:3000/docs to see the auto-generated Swagger documentation.
Step 11: Adding Graceful Shutdown
Production servers need to handle shutdown signals properly — finishing in-flight requests before exiting:
// Add to src/server.ts after app.listen()
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
for (const signal of signals) {
process.on(signal, async () => {
app.log.info(`Received ${signal}, shutting down gracefully...`);
await app.close();
process.exit(0);
});
}
process.on("unhandledRejection", (err) => {
app.log.error(err, "Unhandled rejection");
process.exit(1);
});Step 12: Testing with Vitest
Fastify's inject method lets you test routes without starting a real HTTP server:
// src/modules/tasks/__tests__/tasks.routes.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp } from "../../../app.js";
import { type FastifyInstance } from "fastify";
describe("Tasks Routes", () => {
let app: FastifyInstance;
let authToken: string;
beforeAll(async () => {
app = await buildApp();
// Create a test user and get a token
// In a real app, you'd use a test database
const registerRes = await app.inject({
method: "POST",
url: "/api/auth/register",
payload: {
email: "test@example.com",
password: "securepassword123",
name: "Test User",
},
});
const body = JSON.parse(registerRes.body);
authToken = body.accessToken;
});
afterAll(async () => {
await app.close();
});
it("should return 401 without auth token", async () => {
const response = await app.inject({
method: "GET",
url: "/api/tasks",
});
expect(response.statusCode).toBe(401);
});
it("should create a task", async () => {
const response = await app.inject({
method: "POST",
url: "/api/tasks",
headers: {
authorization: `Bearer ${authToken}`,
},
payload: {
title: "Write documentation",
description: "Write API docs for the tasks module",
priority: "high",
projectId: "some-project-id",
},
});
expect(response.statusCode).toBe(201);
const task = JSON.parse(response.body);
expect(task.title).toBe("Write documentation");
expect(task.priority).toBe("high");
});
it("should validate request body", async () => {
const response = await app.inject({
method: "POST",
url: "/api/tasks",
headers: {
authorization: `Bearer ${authToken}`,
},
payload: {
// Missing required 'title' field
description: "No title provided",
},
});
expect(response.statusCode).toBe(400);
});
it("should return health check", async () => {
const response = await app.inject({
method: "GET",
url: "/health",
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.status).toBe("ok");
});
});Configure Vitest:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
},
},
});Run tests:
npm testStep 13: Docker Configuration
Create a multi-stage Dockerfile for production:
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 fastify
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/drizzle ./drizzle
RUN npm ci --omit=dev
USER fastify
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]And a docker-compose.yml for local development:
version: "3.8"
services:
api:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/fastify_tasks
- JWT_SECRET=dev-secret-that-is-at-least-32-chars-long
- JWT_REFRESH_SECRET=dev-refresh-secret-32-chars-long-too
- NODE_ENV=production
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: fastify_tasks
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:Performance Tips
Fastify is already fast out of the box, but here are ways to squeeze out even more performance:
1. Use Schema for Response Serialization
When you define response schemas, Fastify uses fast-json-stringify instead of JSON.stringify, which can be up to 2x faster:
// The response schema in your route options enables fast serialization
schema: {
response: {
200: TaskResponse // This enables fast-json-stringify
}
}2. Connection Pooling
Configure your PostgreSQL connection pool based on your workload:
const client = postgres(env.DATABASE_URL, {
max: 20, // Maximum pool size
idle_timeout: 30, // Close idle connections after 30s
connect_timeout: 10 // Timeout for new connections
});3. Enable HTTP/2 for Production
import { readFileSync } from "fs";
const app = Fastify({
http2: true,
https: {
key: readFileSync("./certs/key.pem"),
cert: readFileSync("./certs/cert.pem"),
},
});Troubleshooting
Common Issues
"Cannot find module" errors with ESM:
Make sure your package.json has "type": "module" and all local imports use the .js extension (even for .ts files). TypeScript resolves .js imports to .ts files during compilation.
Fastify plugin registration order matters:
Plugins must be registered before routes that depend on them. Always register authPlugin before routes that use fastify.authenticate.
Type errors with request.user:
Ensure you have the declare module "@fastify/jwt" augmentation in your auth plugin. Without it, TypeScript will not recognize the user property on requests.
Schema validation not working:
Check that you are passing TypeBox schemas (not plain TypeScript types) to the schema option. TypeBox produces JSON Schema objects at runtime, which is what Fastify needs.
Next Steps
Now that you have a production-ready Fastify API, consider adding:
- WebSocket support with
@fastify/websocketfor real-time updates - File uploads with
@fastify/multipart - Caching with
@fastify/cachingor Redis - OpenTelemetry instrumentation for distributed tracing
- CI/CD pipeline with GitHub Actions for automated testing and deployment
Conclusion
You have built a complete, production-ready API with Fastify and TypeScript. The combination of Fastify's built-in schema validation, TypeBox's type-safe schemas, Drizzle ORM's type-safe queries, and structured error handling gives you a robust foundation that catches bugs at compile time and validates data at runtime.
Fastify's plugin architecture makes it easy to add features incrementally. The app factory pattern keeps your code testable, and Docker makes deployment consistent across environments. Whether you are building a microservice or a full-featured backend, this architecture scales well as your project grows.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build and Deploy a Serverless API with Cloudflare Workers, Hono, and D1
Learn how to build a production-ready serverless REST API using Cloudflare Workers, the Hono web framework, and D1 SQLite database — from project setup to global deployment.

Build a Type-Safe GraphQL API with Next.js App Router, Yoga, and Pothos
Learn how to build a fully type-safe GraphQL API using Next.js 15 App Router, GraphQL Yoga, and Pothos schema builder. This hands-on tutorial covers schema design, queries, mutations, authentication middleware, and a React client with urql.

Mistral AI API with TypeScript: Building Intelligent Applications
Learn how to use the Mistral AI API with TypeScript to build intelligent applications: chat, structured generation, function calling, and RAG.