DEV Community

Antonio Tripodi
Antonio Tripodi

Posted on • Edited 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
│       └── test/
│           ├── helper.ts
│           ├── tsconfig.json
│           ├── plugins/
│           │   └── sensible.test.ts
│           └── routes/
│               └── example.test.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": "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"]
}
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:

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

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.

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

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

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

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

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

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