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' });- 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:
axiosand@tanstack/react-queryclient libraries auto-generated with orval. - Adapter-agnostic: Express or Fastify; CommonJS or ESM.
- Zod v4
- zod-openapi v5
- OpenAPI 3.1.1
- Node.js >= 20
- Zod >= 4.1
npm install nestjs-endpoints @nestjs/swagger zodPick automatic scanning, traditional manual imports, or mix both.
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.
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.
For GET endpoints with nested-object query params, configure the Express or Fastify adapter accordingly.
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": "art@gmail.com"}' | 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": "art@vandelayindustries.com"}'
{"id":1}%HTTP paths for endpoints are derived from the file's path on disk:
rootDirectoryis trimmed from the start- Optional
basePathis prepended - Path segments that begin with an underscore (
_) are removed - Filenames must either end in
.endpoint.tsor beendpoint.ts js,cjs,mjs,mtsare also supported.- Route parameters are not supported (
user/:userId)
Examples (assume rootDirectory is ./endpoints):
src/endpoints/user/find-all.endpoint.ts->user/find-allsrc/endpoints/user/_mutations/create/endpoint.ts->user/create
Note: Bundled projects via Webpack or similar are not supported.
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"}]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/@UseGuardson 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;excludepaths are resolved relative to the router'sbasePath.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],
});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.
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);
}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: 'tom@gmail.com',
});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:
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);
}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"}%.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' })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: 'john@hotmail.com',
},
});
});
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: 'john@hotmail.com',
});
});
});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: 'john@hotmail.com',
});
});