Setting Up Fastify in a Monorepo with pnpm

Published: (February 18, 2026 at 04:37 PM EST)
6 min read
Source: Dev.to

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.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

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

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

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/autoload automatically maps routes/users/index.ts to the /users path. No need to register routes manually in app.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.

0 views
Back to Blog

Related posts

Read more »

Procrastination in disguise

Introduction Recently I started exploring TypeScript. The initial excitement of gaining new skills soon gave way to feelings of overwhelm and confusion—especia...