npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nestjs-endpoints

v2.3.0

Published

A lightweight tool for writing clean and succinct HTTP APIs with NestJS that embraces the REPR design pattern, code colocation, and the Single Responsibility Principle.

Readme

nestjs-endpoints

PR workflow

Introduction

nestjs-endpoints is a lightweight tool for writing clean, succinct, end-to-end type-safe HTTP APIs with NestJS that encourages the REPR design pattern, code colocation, and the Single Responsibility Principle.

It's inspired by the Fast Endpoints .NET library, tRPC, and Next.js' file-based routing.

An endpoint can be as simple as this:

src/greet.endpoint.ts

export default endpoint({
  input: z.object({
    name: z.string(),
  }),
  output: z.string(),
  inject: {
    helloService: HelloService,
  },
  handler: ({ input, helloService }) => helloService.greet(input.name),
});
❯ curl 'http://localhost:3000/greet?name=Satie'
Hello, Satie!%
// axios client
const greeting = await client.greet({ name: 'Satie' });

// react-query client
const { data: greeting, error, status } = useGreet({ name: 'Satie' });

Features

  • Stable: Produces regular NestJS Controllers under the hood.
  • Two routing styles: manual imports with explicit paths, or file-based routing that scans your project.
  • Module-scoped middleware, interceptors, and guards — a gap in vanilla NestJS.
  • Schema validation: compile- and run-time validation of input and output using Zod.
  • OpenAPI 3.1.1: generated via @nestjs/swagger + zod-openapi.
  • End-to-end type safety: axios and @tanstack/react-query client libraries auto-generated with orval.
  • Adapter-agnostic: Express or Fastify; CommonJS or ESM.

New in v2

  • Zod v4
  • zod-openapi v5
  • OpenAPI 3.1.1

Requirements

  • Node.js >= 20
  • Zod >= 4.1

Installation

npm install nestjs-endpoints @nestjs/swagger zod

Setup

Pick automatic scanning, traditional manual imports, or mix both.

Option 1. Traditional

Import endpoints like regular NestJS controllers. No extra setup.

src/health-check.ts

import { endpoint } from 'nestjs-endpoints';

export const healthCheck = endpoint({
  path: '/status/health',
  inject: {
    health: HealthService,
  },
  handler: ({ health }) => health.check(),
});

src/app.module.ts

import { Module } from '@nestjs/common';
import { healthCheck } from './health-check';

@Module({
  controllers: [healthCheck],
  providers: [HealthService],
})
class AppModule {}

Endpoint available at /status/health.

Option 2. Automatic (file-based routing)

src/endpoints/status/health.ts

import { endpoint } from 'nestjs-endpoints';

export default endpoint({
  inject: {
    health: HealthService,
  },
  handler: ({ health }) => health.check(),
});

src/app.module.ts

import { EndpointRouterModule } from 'nestjs-endpoints';

@Module({
  imports: [
    EndpointRouterModule.create({
      rootDirectory: './endpoints',
      providers: [HealthService],
    }),
  ],
})
export class AppModule {}

Endpoint available at /status/health.

Complex query parameters

For GET endpoints with nested-object query params, configure the Express or Fastify adapter accordingly.

Usage

src/endpoints/user/find.endpoint.ts

import { endpoint, z } from 'nestjs-endpoints';

export default endpoint({
  input: z.object({
    // GET endpoints use query params for input,
    // so we need to coerce the string to a number
    id: z.coerce.number(),
  }),
  output: z
    .object({
      id: z.number(),
      name: z.string(),
      email: z.string().email(),
    })
    .nullable(),
  inject: {
    db: DbService,
  },
  injectOnRequest: {
    session: decorated<Session>(Session()),
  }
  // The handler's parameters are fully typed, and its
  // return value is type-checked against the output schema
  handler: async ({ input, db, session }) => {
    if (session.isAuthorized()) {
      return await db.user.find(input.id);
    }
    return null;
  }
});

src/endpoints/user/create.endpoint.ts

import { endpoint, z } from 'nestjs-endpoints';

export default endpoint({
  method: 'post',
  input: z.object({
    name: z.string(),
    email: z.string().email(),
  }),
  output: z.object({
    id: z.number(),
  }),
  inject: {
    db: DbService,
  },
  handler: async ({ input, db }) => {
    const user = await db.user.create(input);
    return {
      id: user.id,
      // Stripped during zod validation
      name: user.name,
    };
  },
});

You call the above using:

❯ curl 'http://localhost:3000/user/find?id=1'
null%

# bad input
❯ curl -s -X 'POST' 'http://localhost:3000/user/create' \
-H 'Content-Type: application/json' \
-d '{"name": "Art", "emailTYPO": "[email protected]"}' | jq
{
  "statusCode": 400,
  "message": "Validation failed",
  "errors": [
    {
      "code": "invalid_type",
      "expected": "string",
      "received": "undefined",
      "path": [
        "email"
      ],
      "message": "Required"
    }
  ]
}

# success
❯ curl -X 'POST' 'http://localhost:3000/user/create' \
-H 'Content-Type: application/json' \
-d '{"name": "Art", "email": "[email protected]"}'
{"id":1}%

File-based routing

HTTP paths for endpoints are derived from the file's path on disk:

  • rootDirectory is trimmed from the start
  • Optional basePath is prepended
  • Path segments that begin with an underscore (_) are removed
  • Filenames must either end in .endpoint.ts or be endpoint.ts
  • js, cjs, mjs, mts are also supported.
  • Route parameters are not supported (user/:userId)

Examples (assume rootDirectory is ./endpoints):

  • src/endpoints/user/find-all.endpoint.ts -> user/find-all
  • src/endpoints/user/_mutations/create/endpoint.ts -> user/create

Note: Bundled projects via Webpack or similar are not supported.

Nested router modules

A subdirectory can own its endpoints and providers by adding a router.module.ts that default-exports an EndpointRouterModule. The parent auto-discovers it and derives the child's basePath from its folder name.

src/
├── app.module.ts
└── endpoints/
    └── shop/
        ├── homepage.endpoint.ts
        └── recipes/
            ├── router.module.ts
            ├── repository.service.ts
            ├── endpoint.ts
            └── create.endpoint.ts
// src/endpoints/shop/recipes/router.module.ts
import { EndpointRouterModule } from 'nestjs-endpoints';
import { RecipesRepository } from './repository.service';

export default EndpointRouterModule.create({
  providers: [RecipesRepository],
});
❯ curl 'http://localhost:3000/shop/recipes/create?name=Pizza'
{"id":1,"name":"Pizza"}

# `endpoint.ts` inherits its path from the folder, so it maps to GET /shop/recipes.
❯ curl 'http://localhost:3000/shop/recipes'
[{"id":1,"name":"Pizza"}]

Middleware, interceptors, and guards

EndpointRouterModule.create() accepts middleware, interceptors, and guards that apply to every endpoint in the router, including nested ones.

Module-scoped interceptors and guards aren't really a thing in vanilla NestJS — you'd have to register them globally (via APP_INTERCEPTOR / APP_GUARD) or per-controller with @UseInterceptors / @UseGuards on each class. Here you declare them once on the router and they automatically apply to its whole subtree.

  • middleware: class-based (NestMiddleware) or functional. The last entry may be an options object; exclude paths are resolved relative to the router's basePath.
  • interceptors: applied via @UseInterceptors(...) at controller level.
  • guards: applied via @UseGuards(...) at controller level.
// src/endpoints/recipes/router.module.ts
import { IncomingMessage, ServerResponse } from 'node:http';
import { EndpointRouterModule } from 'nestjs-endpoints';
import { RecipesGuard } from './recipes.guard';
import { RecipesInterceptor } from './recipes.interceptor';
import { RecipesMiddleware } from './recipes.middleware';

export default EndpointRouterModule.create({
  middleware: [
    RecipesMiddleware, // class-based
    (_req: IncomingMessage, _res: ServerResponse, next: () => void) => {
      console.log('before handler');
      next();
    }, // functional
    { exclude: ['list'] }, // options (last); skips /recipes/list
  ],
  interceptors: [RecipesInterceptor],
  guards: [RecipesGuard],
});

Codegen (optional)

Generate a type-safe client SDK (axios and/or react-query) from your endpoints. Uses orval under the hood and works with both scanned and manually-imported endpoints.

Using setupCodegen

src/main.ts

import { setupCodegen } from 'nestjs-endpoints';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await setupCodegen(app, {
    clients: [
      {
        type: 'axios',
        outputFile: process.cwd() + '/generated/axios-client.ts',
      },
      {
        type: 'react-query',
        outputFile: process.cwd() + '/generated/react-query-client.tsx',
      },
    ],
  });
  await app.listen(3000);
}

axios

import { createApiClient } from './generated/axios-client';

const client = createApiClient({
  baseURL: process.env.API_BASE_URL,
  headers: {
    'x-test': 'test-1',
  },
});
// Access to axios instance
client.axios.defaults.headers.common['x-test'] = 'test-2';

const { id } = await client.userCreate({
  name: 'Tom',
  email: '[email protected]',
});

react-query

import {
  ApiClientProvider,
  createApiClient,
} from './generated/react-query-client';

export function App() {
  const queryClient = useMemo(() => new QueryClient({}), []);
  const apiClient = useMemo(
    () =>
      createApiClient({
        baseURL: import.meta.env.VITE_API_BASE_URL,
      }),
    [],
  );

  return (
    <QueryClientProvider client={queryClient}>
      <ApiClientProvider client={apiClient}>
        <UserPage />
      </ApiClientProvider>
    </QueryClientProvider>
  );
}
--
import {
  useUserCreate,
  useApiClient,
} from './generated/react-query-client';

export function UserPage() {
  // react-query mutation hook
  const userCreate = useUserCreate();
  const handler = () => userCreate.mutateAsync({ ... });

  // You can also use the api client, directly
  const client = useApiClient();
  const handler = () => client.userCreate({ ... });
  ...
}

More examples:

Manual codegen with OpenAPI spec file

If you only need the OpenAPI spec or want to drive orval (or another tool) yourself:

src/main.ts

import { setupOpenAPI } from 'nestjs-endpoints';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const { document, changed } = await setupOpenAPI(app, {
    configure: (builder) => builder.setTitle('My Api'),
    outputFile: process.cwd() + '/openapi.json',
  });
  if (changed) {
    void import('orval').then(({ generate }) => generate());
  }
  await app.listen(3000);
}

Advanced Usage

A fuller endpoint example: multi-status output, per-endpoint decorators, request-time injection. Full example here. You can also freely mix these with plain NestJS controllers.

src/endpoints/user/appointment/create.endpoint.ts

import { Inject, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { decorated, endpoint, schema, z } from 'nestjs-endpoints';

export default endpoint({
  method: 'post',
  summary: 'Create an appointment',
  input: z.object({
    userId: z.number(),
    date: z.coerce.date(),
  }),
  output: {
    201: schema(
      z.object({
        id: z.number(),
        date: z.date().transform((date) => date.toISOString()),
        address: z.string(),
      }),
      {
        description: 'Appointment created',
      },
    ),
    400: z.union([
      z.string(),
      z.object({
        message: z.string(),
        errorCode: z.string(),
      }),
    ]),
  },
  // Per-endpoint decorators. Guards, interceptors, and middleware that
  // should apply to every endpoint in the router can instead be passed
  // to `EndpointRouterModule.create({ guards, interceptors, middleware })`.
  decorators: [UseGuards(AuthGuard)],
  inject: {
    db: DbService,
    appointmentsRepository: decorated<IAppointmentRepository>(
      Inject(AppointmentRepositoryToken),
    ),
  },
  injectOnRequest: {
    req: decorated<Request>(Req()),
  },
  handler: async ({
    input,
    db,
    appointmentsRepository,
    req,
    response,
  }) => {
    const user = await db.find(input.userId);
    if (!user) {
      // Need to use response fn when multiple output status codes
      // are defined
      return response(400, 'User not found');
    }
    if (await appointmentsRepository.hasConflict(input.date)) {
      return response(400, {
        message: 'Appointment has conflict',
        errorCode: 'APPOINTMENT_CONFLICT',
      });
    }
    return response(
      201,
      await appointmentsRepository.create(
        input.userId,
        input.date,
        req.ip,
      ),
    );
  },
});

To call this endpoint:

❯ curl -X 'POST' 'http://localhost:3000/user/appointment/create' \
-H 'Content-Type: application/json' \
-H 'Authorization: secret' \
-d '{"userId": 1, "date": "2021-11-03"}'
{"id":1,"date":"2021-11-03T00:00:00.000Z","address":"::1"}%

Handling ZodEffects in output schemas

.transform() in an output schema produces a ZodEffect whose runtime type can't always be inferred for OpenAPI (more info). Use .overwrite() or .meta({ type: ... }) instead:

// Use .overwrite for same-type transforms:
z.string().overwrite((s) => s.toUpperCase())

// Or annotate the output type:
z.string().transform((s) => s.toUpperCase()).meta({ type: 'string' })

Testing

End-to-end tests

Use the generated client or supertest.

test('client library', async () => {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    imports: [AppModule],
  }).compile();
  const app = moduleFixture.createNestApplication();
  await app.init();
  await app.listen(0);
  const client = createApiClient({
    baseURL: await app.getUrl(),
  });
  await expect(client.userFind({ id: 1 })).resolves.toMatchObject({
    data: {
      id: 1,
      email: '[email protected]',
    },
  });
});

test('supertest', async () => {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    imports: [AppModule],
  }).compile();
  const app = moduleFixture.createNestApplication();
  await request(app.getHttpServer())
    .get('/user/find?id=1')
    .expect(200)
    .then((resp) => {
      expect(resp.body).toMatchObject({
        id: 1,
        email: '[email protected]',
      });
    });
});

Integration tests

Load individual endpoints without the full app:

import userFindEndpoint from 'src/endpoints/user/find.endpoint';

test('integration', async () => {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    controllers: [userFindEndpoint],
    providers: [DbService],
  }).compile();
  const app = moduleFixture.createNestApplication();
  await app.init();
  const userFind = app.get(userFindEndpoint);
  await expect(userFind.invoke({ id: 1 })).resolves.toMatchObject({
    id: 1,
    email: '[email protected]',
  });
});