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
└── 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": "commonjs",
"lib": ["ES2022"],
"moduleResolution": "node",
"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:
{
"name": "@monorepo/api",
"version": "1.0.0",
"type": "commonjs",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "node --test",
"type-check": "tsc --noEmit"
},
"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": "~5.9.3"
}
}
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.
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)