Setting Up Fastify in a Monorepo with pnpm
Source: Dev.to
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 install the dependencies:
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).
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 }
});
}
Create the Server Entry Point
close-with-grace is the package recommended by the Fastify team 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 (optional)
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);
}
});
You now have a fully configured Fastify project inside a pnpm workspace monorepo. Use pnpm --filter @monorepo/api dev to start the development server, pnpm --filter @monorepo/api build to compile, and pnpm --filter @monorepo/api start to run the built output. Happy coding!
Automatically Load All Routes
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
package.json at the repository root:
{
"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.