A complete guide to create and configure a Fastify project within a monorepo managed with pnpm workspace.
Prerequisites
- Node.js (v22 or higher)
- pnpm installed
Monorepo Structure
The final structure will look like this:
app-monorepo/
├── package.json
├── pnpm-workspace.yaml
├── apps/
│ └── api/
│ ├── package.json
│ ├── tsconfig.json
│ ├── src/
│ │ ├── app.ts
│ │ ├── server.ts
│ │ ├── routes/
│ │ │ ├── root.ts
│ │ │ └── users/
│ │ │ └── index.ts
│ │ └── plugins/
│ │ ├── cors.ts
│ │ ├── helmet.ts
│ │ └── sensible.ts
│ └── test/
│ ├── helper.ts
│ ├── tsconfig.json
│ ├── plugins/
│ │ └── sensible.test.ts
│ └── routes/
│ └── example.test.ts
└── tsconfig.json
Note: Separating
app.tsfromserver.tsis an official Fastify convention. It allows you to test the app without starting the HTTP server.
Initialize the Monorepo
mkdir app-monorepo
cd app-monorepo
pnpm init
Configure pnpm Workspace
Create the pnpm-workspace.yaml file in the root:
packages:
- 'apps/*'
Configure TypeScript (Root)
Create the tsconfig.json file in the root:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"lib": ["ES2022"],
"moduleResolution": "Node16",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true
},
"exclude": ["**/node_modules", "**/dist"]
}
Create the API Package
mkdir -p apps/api/src/{routes/users,plugins}
cd apps/api
pnpm init
apps/api/package.json:
"scripts": {
"dev": "tsx watch --env-file=.env src/server.ts",
"build:ts": "tsc",
"watch:ts": "tsc -w",
"start": "npm run build:ts && node dist/server.js",
"test": "tsx --env-file=.env --test test/**/*.test.ts",
},
"dependencies": {
"fastify": "^5.7.4",
"@fastify/autoload": "^6.3.1",
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/sensible": "^6.0.4",
"close-with-grace": "^2.4.0",
"fastify-plugin": "^5.1.0"
},
"devDependencies": {
"@types/node": "^25.2.3",
"pino-pretty": "^13.1.3",
"tsx": "^4.21.0",
"typescript": "~6.0.2"
}
Now run this command from the terminal
pnpm install
Configure TypeScript for the API
apps/api/tsconfig.json:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Create the App Factory
The official Fastify convention is to separate app.ts (the factory that registers plugins and routes) from server.ts (the entry point that starts the server).
Create the Server Entry Point
close-with-grace is the package recommended by the Fastify team (created by Matteo Collina, core maintainer) for graceful shutdown. It handles SIGINT, SIGTERM, and uncaughtException in a single place, and the configurable delay gives in-flight requests time to complete before the server closes.
Install it first:
pnpm --filter @monorepo/api add close-with-grace
apps/api/src/server.ts:
import Fastify from 'fastify';
import closeWithGrace from 'close-with-grace';
import app, { options } from './app';
// Load .env file
try {
process.loadEnvFile();
} catch {
// .env is optional
}
// Instantiate Fastify with options exported from app.ts (logger included)
const server = Fastify(options);
// Register the app as a plugin
server.register(app);
// Graceful shutdown configuration
const closeListeners = closeWithGrace(
{ delay: parseInt(process.env.FASTIFY_CLOSE_GRACE_DELAY ?? '500') || 500 },
async function ({ err }) {
if (err) {
server.log.error(err);
}
await server.close();
}
);
server.addHook('onClose', async () => {
closeListeners.uninstall();
});
// Start listening
const PORT = parseInt(process.env.FASTIFY_PORT ?? '3000') || 3000;
server.listen({ port: PORT, host: '0.0.0.0' }, (err) => {
if (err) {
server.log.error({ err }, 'Server shutdown due to an error');
process.exit(1);
}
});
For this to work, app.ts must also export a options object with the Fastify configuration (including the logger):
apps/api/src/app.ts:
import path from 'path';
import AutoLoad from '@fastify/autoload';
import { FastifyInstance, FastifyPluginOptions, FastifyServerOptions } from 'fastify';
// Fastify server options
export const options: FastifyServerOptions = {
logger: {
level: process.env.LOG_LEVEL || 'debug',
transport:
process.env.LOG_LEVEL === 'debug'
? {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
colorize: true
}
}
: undefined
}
};
export default async function app(
fastify: FastifyInstance,
opts: FastifyPluginOptions
) {
// Automatically load all plugins from the plugins/ folder
await fastify.register(AutoLoad, {
dir: path.join(__dirname, 'plugins'),
options: { ...opts },
});
// Automatically load all routes from the routes/ folder
await fastify.register(AutoLoad, {
dir: path.join(__dirname, 'routes'),
options: { ...opts },
});
}
Note:
pino-prettyis only enabled outside of production. In production, raw JSON logs are more efficient and better supported by log aggregators.
Create Plugins
Plugins follow the convention of using fastify-plugin to expose decorations to the global context.
apps/api/src/plugins/cors.ts:
import fp from 'fastify-plugin';
import { FastifyInstance } from 'fastify';
import Cors from '@fastify/cors';
async function corsPlugin(fastify: FastifyInstance) {
await fastify.register(Cors, {
origin: true,
methods: ['GET', 'PUT', 'PATCH', 'POST', 'DELETE'],
credentials: true,
});
}
export default fp(corsPlugin, {
name: 'cors'
});
apps/api/src/plugins/helmet.ts:
import fp from 'fastify-plugin';
import { FastifyInstance } from 'fastify';
import helmet from '@fastify/helmet';
async function helmetPlugin(fastify: FastifyInstance) {
await fastify.register(helmet, {
crossOriginResourcePolicy: { policy: 'same-origin' },
crossOriginEmbedderPolicy: true,
// Other Helmet options
});
}
export default fp(helmetPlugin, {
name: 'helmet'
});
apps/api/src/plugins/sensible.ts:
import fp from 'fastify-plugin';
import sensible from '@fastify/sensible';
export default fp(async (fastify) => {
await fastify.register(sensible);
});
Why
fastify-plugin? Without it, each plugin has its own encapsulated scope. Withfastify-plugin, decorations and hooks are exposed to the parent context, making them globally available.
Create Routes
Each file in the routes/ folder is automatically loaded by @fastify/autoload. The folder structure mirrors the route paths.
apps/api/src/routes/root.ts:
import { FastifyInstance } from 'fastify';
export default async function (fastify: FastifyInstance) {
fastify.get('/', async (request, reply) => {
return { message: 'Hello from Fastify in monorepo!' };
});
fastify.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
}
apps/api/src/routes/users/index.ts:
import { FastifyInstance } from 'fastify';
export default async function (fastify: FastifyInstance) {
fastify.get('/', async (request, reply) => {
return [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
});
fastify.get('/:id', async (request, reply) => {
const { id } = request.params as { id: string };
if (isNaN(parseInt(id))) {
return reply.badRequest('ID must be a number');
}
return { id: parseInt(id), name: 'User ' + id };
});
}
Note:
@fastify/autoloadautomatically mapsroutes/users/index.tsto the/userspath. No need to register routes manually inapp.ts.
Testing
The test/ folder mirrors the structure of src/, keeping plugin tests and route tests organized separately.
apps/api/
└── test/
├── helper.ts
├── tsconfig.json
├── plugins/
│ └── sensible.test.ts
└── routes/
└── example.test.ts
Note: Separating
app.tsfromserver.tspays off here — tests import only the app factory, without ever starting an HTTP server.
Install Dev Dependencies
No external test runner needed: Node.js ships with a built-in test runner (node:test) since v18, and it's stable from v22 onwards.
Configure TypeScript for Tests
apps/api/test/tsconfig.json:
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../dist-test",
"rootDir": ".",
"composite": false,
"noEmit": true,
"types": ["node"]
},
"include": ["./**/*", "../src/**/*"],
"exclude": ["node_modules"]
}
Create the Test Helper
apps/api/test/helper.ts:
import Fastify, { FastifyInstance } from 'fastify';
import app from '../src/app';
export async function buildTestApp(): Promise<FastifyInstance> {
const testApp = Fastify({
logger: false
});
await testApp.register(app);
await testApp.ready();
return testApp;
}
export async function closeTestApp(app: FastifyInstance): Promise<void> {
await app.close();
}
buildTestApp() spins up the full app — plugins and routes included — without binding to any port. logger: false keeps test output clean.
Write Tests
apps/api/test/plugins/sensible.test.ts:
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import { buildTestApp, closeTestApp } from '../helper';
import { FastifyInstance } from 'fastify';
describe('Sensible Plugin', () => {
let app: FastifyInstance;
before(async () => {
app = await buildTestApp();
});
after(async () => {
await closeTestApp(app);
});
it('should throw 404 for non-existent route', async () => {
const response = await app.inject({
method: 'GET',
url: '/non-existent-route'
});
assert.strictEqual(response.statusCode, 404);
});
});
apps/api/test/routes/example.test.ts:
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import { buildTestApp, closeTestApp } from '../helper';
import { FastifyInstance } from 'fastify';
describe('Example Routes', () => {
let app: FastifyInstance;
before(async () => {
app = await buildTestApp();
});
after(async () => {
await closeTestApp(app);
});
it('should return 200 from root endpoint', async () => {
const response = await app.inject({
method: 'GET',
url: '/'
});
assert.strictEqual(response.statusCode, 200);
});
});
app.inject() is Fastify's built-in method for simulating HTTP requests without a real network connection — perfect for unit testing routes.
Root Scripts
Root package.json:
{
"scripts": {
"dev": "pnpm --filter @monorepo/api dev",
"build": "pnpm -r build",
"start": "pnpm --filter @monorepo/api start",
"test": "pnpm --filter @monorepo/api test",
"type-check": "pnpm -r type-check"
}
}
Example of Useful Commands
# Development
pnpm dev
# Build all packages
pnpm build
# Production
pnpm start
# Test
pnpm test
# Add a dependency to the API
pnpm --filter @monorepo/api add fastify-plugin
# Add a dev dependency
pnpm --filter @monorepo/api add -D @types/node
I hope this guide helps you set up a Fastify monorepo. This post will be continuously updated as improvements are made.
Top comments (0)