DEV Community

Antonio Tripodi
Antonio Tripodi

Posted on

Setting Up Fastify in a Monorepo with pnpm

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
Enter fullscreen mode Exit fullscreen mode

Note: Separating app.ts from server.ts is 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
Enter fullscreen mode Exit fullscreen mode

Configure pnpm Workspace

Create the pnpm-workspace.yaml file in the root:

packages:
  - 'apps/*'
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

Create the API Package

mkdir -p apps/api/src/{routes/users,plugins}
cd apps/api
pnpm init
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now run this command from the terminal

pnpm install
Enter fullscreen mode Exit fullscreen mode

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"]
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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 },
  });
}
Enter fullscreen mode Exit fullscreen mode

Note: pino-pretty is 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'
});
Enter fullscreen mode Exit fullscreen mode

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'
});

Enter fullscreen mode Exit fullscreen mode

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);
});

Enter fullscreen mode Exit fullscreen mode

Why fastify-plugin? Without it, each plugin has its own encapsulated scope. With fastify-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() };
  });
}
Enter fullscreen mode Exit fullscreen mode

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 };
  });
}
Enter fullscreen mode Exit fullscreen mode

Note: @fastify/autoload automatically maps routes/users/index.ts to the /users path. No need to register routes manually in app.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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I hope this guide helps you set up a Fastify monorepo. This post will be continuously updated as improvements are made.

Top comments (0)