Typed client with codegen
Generate a fully typed HTTP client for the inbox API from your NestJS controllers using nestjs-codegen — no hand-written fetch calls, no drift between server and client.
The React widget ships a hand-written NotificationsClient. If you'd
rather generate the client — and keep it in lockstep with your routes — point
@dudousxd/nestjs-codegen at your inbox
routes. It emits a typed api.ts + routes.ts (and, wired to
@dudousxd/nestjs-client's TanStack helpers,
ready-made query/mutation factories).
There are two ways to feed it the inbox routes:
- The codegen extension (
nestjsNotificationsCodegen()) — the recommended path. It injects the library's routes into your generatedapi.ts, so it works even when you mount the inbox with thecreateNotificationsControllerfactory (which static discovery can't see). Jump to The extension. - A hand-written static controller — if you already expose your own decorated notification controller, codegen discovers it by AST like any other. See A static, decorated controller.
The runnable examples/basic
app has the whole setup wired; this recipe walks through both.
The extension: nestjsNotificationsCodegen()
The library mounts its inbox (and preference center) with factories —
createNotificationsController and the
preference-center controller — that build their @Controller at runtime. Codegen's static AST pass
can't see a runtime-built class, so those routes are invisible to plain discovery.
@dudousxd/nestjs-notifications-codegen closes that gap. It's a codegen extension that appends the
library's exact routes to whatever your controllers already produced:
pnpm add -D @dudousxd/nestjs-notifications-codegenimport { defineConfig } from '@dudousxd/nestjs-codegen';
import { nestjsNotificationsCodegen } from '@dudousxd/nestjs-notifications-codegen';
export default defineConfig({
validation: noopAdapter, // see below
contracts: { glob: 'src/**/*.controller.ts' },
codegen: { outDir: 'src/generated' },
forms: { enabled: false },
extensions: [
nestjsNotificationsCodegen({ basePath: '/api', preferences: true }),
],
});Run codegen and the inbox is now a typed client:
const page = await api.notifications.list({ query: { page: 1, perPage: 20 } });
// ^? { items: StoredNotification[]; meta: { page, perPage, total, lastPage } }
const { count } = await api.notifications.unreadCount();
await api.notifications.markAsRead({ params: { id } });Options
nestjsNotificationsCodegen(options?) takes:
| Option | Type | Default | Description |
|---|---|---|---|
basePath | string | '' | Path prefix the controllers are mounted under. '/api' → /api/notifications. Match whatever your app prefixes routes with (e.g. app.setGlobalPrefix('api')). |
path | string | 'notifications' | URL segment the inbox controller sits at (after basePath). Set this when you passed createNotificationsController({ path }) a non-default path. Leading/trailing slashes are trimmed. |
name | string | 'notifications' | Route-name namespace for the generated client — api.<name>.list, api.<name>.unreadCount, … . Independent of path. |
inbox | boolean | true | Emit the in-app inbox routes (list / unread / unreadCount / markAsRead / markAllAsRead / remove). |
preferences | boolean | false | Also emit the preference-center routes (preferences.categories / preferences.matrix / preferences.setChannel / preferences.setDigest). |
basePath and path describe where you mounted the controllers; name decides what the client
namespace is called. Keep them consistent with your actual routing — the extension emits routes, it
doesn't discover them, so a mismatch produces a client that calls the wrong URL.
The injected inbox routes mirror createNotificationsController exactly: GET {path},
GET {path}/unread, GET {path}/unread/count, POST {path}/:id/read, POST {path}/read-all,
DELETE {path}/:id. The list query accepts page / perPage / type (a comma-separated type
filter) and returns the paginated { items, meta } shape, so the generated infinite-query factory
can page through the inbox out of the box.
Use the extension or a static controller for the same routes — not both. If you expose your own decorated notification controller (so codegen already discovers it), don't also add the extension; it would emit the routes a second time and collide.
Alternative: a static, decorated controller
Codegen discovers routes by static AST — it reads top-level @Controller classes with their
@Get/@Post/… methods, the @Query()/@Param()/@Body() param types, and each method's return
type. If you don't use the factory and instead write a plain decorated controller (delegating to
NotificationsQueryService), codegen reads it directly — no extension needed.
@Controller('notifications')
export class NotificationsInboxController {
constructor(private readonly notifications: NotificationsQueryService) {}
@Get()
async list(@Query() query: ListNotificationsQueryDto): Promise<PaginatedNotificationsDto> { /* … */ }
@Get('unread/count')
async unreadCount(): Promise<UnreadCountDto> { /* … */ }
@Post(':id/read')
async markAsRead(@Param('id') id: string): Promise<AckDto> { /* … */ }
// …unread(), markAllAsRead(), remove()
}The DTOs are plain classes — no decorators needed, codegen reads their field types:
export class NotificationDto {
id!: string;
type!: string;
data!: Record<string, unknown>;
readAt!: string | null;
createdAt!: string;
}
export class PaginatedNotificationsDto {
items!: NotificationDto[];
page!: number;
perPage!: number;
total!: number;
}
export class UnreadCountDto { count!: number; }
export class AckDto { ok!: boolean; }
export class ListNotificationsQueryDto { page?: number; perPage?: number; }Configure codegen
import { defineConfig, type ValidationAdapter } from '@dudousxd/nestjs-codegen';
// forms are disabled, so this is never invoked — it only satisfies the required `validation` field.
// Swap in `zodAdapter` from @dudousxd/nestjs-codegen-zod (and enable forms) to also emit client-side
// validation schemas.
const noopAdapter: ValidationAdapter = {
name: 'noop',
importStatements: () => [],
render: () => '',
renderModule: () => ({ schemaText: '', namedNestedSchemas: new Map(), warnings: [] }),
inferType: () => 'unknown',
};
export default defineConfig({
validation: noopAdapter,
contracts: { glob: 'src/**/*.controller.ts' },
codegen: { outDir: 'src/generated' },
forms: { enabled: false }, // typed client only (routes.ts + api.ts)
});pnpm add -D @dudousxd/nestjs-codegen tsx
pnpm add @dudousxd/nestjs-client # runtime the generated api.ts importsAdd a script and run it:
{ "scripts": { "codegen": "nestjs-codegen codegen" } }pnpm codegen
# ✓ Codegen generated artifacts in src/generatedCodegen runs entirely off your source files — it does not boot your app. tsx is needed only
to load the TypeScript config file.
The generated client
src/generated/api.ts gives you a typed client keyed by controller and method:
import { createApi } from './generated/api';
import { createFetcher } from '@dudousxd/nestjs-client';
const api = createApi(createFetcher({ baseUrl: '/' }));
const page = await api.notificationsInbox.list({ query: { page: 1, perPage: 20 } });
// ^? PaginatedNotificationsDto
const { count } = await api.notificationsInbox.unreadCount();
await api.notificationsInbox.markAsRead({ params: { id } });Path params are required and typed ({ params: { id } }), query/body are inferred from the DTOs, and
the response type resolves to the controller method's return type. routes.ts exposes the matching
ROUTES map and a route('notificationsInbox.list') helper.
The generated response types reference the controller's return type via import('…/inbox.controller'),
so the generated client typechecks within the same project. For a separate frontend build, point
outDir into the frontend and share the DTOs (or the controller's type) across the boundary — e.g.
via a small shared *-contracts package — so the import resolves on the client side.
Pairing with the React widget
The generated createApi(fetcher) and the React package's hand-written NotificationsClient solve the
same problem two ways. Use whichever fits: the hand-written client is zero-config and dependency-free;
the generated client removes manual mirroring and follows your routes automatically. You can back the
React hooks with the generated client by adapting it behind the NotificationsClient shape, or use the
generated client directly in your own components.
Delivery tracking
Persist the real per-channel delivery status — sent, failed, delivered, bounced — not just the in-memory SendResult. The delivery-tracking package records every send and updates it from Twilio / SES status webhooks.
Channel preferences
Let users mute channels they don't want. Register PreferencesModule, mute/unmute per user (and per tenant), and muted channels are auto-skipped because the package binds the core PreferenceGate.