Aviary
Recipes

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 generated api.ts, so it works even when you mount the inbox with the createNotificationsController factory (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 factoriescreateNotificationsController 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-codegen
nestjs-codegen.config.ts
import { 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:

OptionTypeDefaultDescription
basePathstring''Path prefix the controllers are mounted under. '/api'/api/notifications. Match whatever your app prefixes routes with (e.g. app.setGlobalPrefix('api')).
pathstring'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.
namestring'notifications'Route-name namespace for the generated client — api.<name>.list, api.<name>.unreadCount, … . Independent of path.
inboxbooleantrueEmit the in-app inbox routes (list / unread / unreadCount / markAsRead / markAllAsRead / remove).
preferencesbooleanfalseAlso 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.

inbox.controller.ts
@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:

inbox.dto.ts
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

nestjs-codegen.config.ts
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 imports

Add a script and run it:

package.json
{ "scripts": { "codegen": "nestjs-codegen codegen" } }
pnpm codegen
# ✓ Codegen generated artifacts in src/generated

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

On this page