NestJS: CRUD Controller
Initialize
One decorator turns a controller into a full CRUD API:
import { CrudController, RelayerController } from '@relayerjs/nestjs-crud';
import { PostEntity, type EM } from '../entities';import { PostsService } from './posts.service';
@CrudController<PostEntity, EM>({ model: PostEntity,})export class PostsController extends RelayerController<PostEntity, EM> { constructor(postsService: PostsService) { super(postsService); }}Pass TEntities (your entity map) as the second generic for full type-safe config — relation-aware field autocomplete in defaults, allow, and search.
Routes
Enable or disable individual routes:
@CrudController<PostEntity, EM>({ model: PostEntity, routes: { list: true, // GET /posts findById: true, // GET /posts/:id create: true, // POST /posts update: true, // PATCH /posts/:id delete: true, // DELETE /posts/:id count: true, // GET /posts/count aggregate: true, // GET /posts/aggregate },})Each route accepts true (enable with defaults), false (disable), or a config object. All routes are enabled by default.
Pagination
Offset (default)
@CrudController<PostEntity, EM>({ model: PostEntity, routes: { list: { defaultLimit: 20, maxLimit: 100, }, },})Cursor
@CrudController<PostEntity, EM>({ model: PostEntity, routes: { list: { pagination: 'cursor_UNSTABLE', defaults: { orderBy: { field: 'createdAt', order: 'desc' } }, defaultLimit: 20, }, },})See Search & Filtering for client-side usage and response formats. See Known Limitations for date precision workarounds.
Default selection
Set defaults for select, orderBy, and where. Used when the client doesn’t provide their own:
@CrudController<PostEntity, EM>({ model: PostEntity, routes: { list: { defaults: { select: { id: true, title: true, published: true, comments: { $limit: 5, id: true, content: true, author: { fullName: true } }, }, orderBy: { field: 'createdAt', order: 'desc' }, where: { status: 'published' }, }, }, findById: { defaults: { select: { id: true, title: true, content: true, comments: { id: true } }, }, }, },})defaults.select— used when client doesn’t send?select=defaults.orderBy— used when client doesn’t send?orderBy=or?sort=defaults.where— always merged with client’swhere(shallow merge, client values override)
Allow rules
Control what clients can query. Place allow inside the route config alongside defaults:
@CrudController<PostEntity, EM>({ model: PostEntity, routes: { list: { defaults: { orderBy: { field: 'createdAt', order: 'desc' }, }, allow: { // Block fields from select or limit relation rows select: { password: false, comments: { $limit: 10 } },
// Restrict which fields can be filtered and which operators are allowed where: { title: { operators: ['contains', 'ilike', 'startsWith'] }, published: true, // all operators allowed authorId: true, },
// Restrict which fields can be used for sorting orderBy: ['title', 'createdAt'], }, maxLimit: 100, defaultLimit: 20, }, },})If client sends ?select={"comments":{"$limit":100}}, server caps to 10.
Search
Define a search callback that maps a search string to a where clause. Place it in the list route config:
@CrudController<PostEntity, EM>({ model: PostEntity, routes: { list: { search: (q) => ({ OR: [ { title: { ilike: `%${q}%` } }, { content: { ilike: `%${q}%` } }, ], }), }, },})Clients use ?search=hello. The search where is AND-combined with any ?where= the client provides. See Search & Filtering for client-side usage.
Decorator targeting
Apply NestJS decorators to all or specific routes:
@CrudController<PostEntity, EM>({ model: PostEntity, decorators: [ // Bare decorator -> all routes UseGuards(AuthGuard),
// Targeted -> specific routes only { apply: [Roles('admin')], for: ['create', 'update', 'delete'] }, { apply: [CacheInterceptor], for: ['list', 'findById'] }, ],})Route names: 'list', 'findById', 'create', 'update', 'delete', 'count', 'aggregate'.
DtoMapper and Hooks
Register via config properties. Both are resolved via NestJS DI:
@CrudController<PostEntity, EM>({ model: PostEntity, dtoMapper: PostDtoMapper, hooks: PostHooks,})See Data Mapper and Hooks for full API reference.
Overriding handlers
Override any handler method directly in your controller class. Other handlers remain auto-generated:
@CrudController<PostEntity, EM>({ model: PostEntity })export class PostsController extends RelayerController<PostEntity, EM> { constructor(private readonly postsService: PostsService) { super(postsService); }
// Override list protected async handleList(request: { query: Record<string, string> }) { const data = await this.postsService.findPublished(); return { data, meta: { total: data.length, limit: 100, offset: 0 } }; }
// Override find by ID protected async handleFindById(id: string, request: unknown) { const post = await this.postsService.findFirst({ where: { id: parseInt(id, 10) }, select: { id: true, title: true, author: { fullName: true } }, }); return { data: post }; }
// Override create protected async handleCreate(body: Record<string, unknown>, request: unknown) { const post = await this.postsService.create({ data: body as Partial<PostEntity> }); return { data: post }; }
// Override update protected async handleUpdate(id: string, body: Record<string, unknown>, request: unknown) { const updated = await this.postsService.update({ where: { id: parseInt(id, 10) }, data: body as Partial<PostEntity>, }); return { data: updated }; }
// Override delete protected async handleDelete(id: string, request: unknown) { const deleted = await this.postsService.delete({ where: { id: parseInt(id, 10) } }); return { data: deleted }; }
// Override count protected async handleCount(request: { query: Record<string, string> }) { const count = await this.postsService.count(); return { data: { count } }; }
// Override aggregate protected async handleAggregate(request: { query: Record<string, string> }) { const result = await this.postsService.aggregate({ _count: true }); return { data: result }; }}Custom routes
Add non-CRUD routes alongside auto-generated ones:
@CrudController<PostEntity, EM>({ model: PostEntity })export class PostsController extends RelayerController<PostEntity, EM> { constructor(private readonly postsService: PostsService) { super(postsService); }
@Post(':id/publish') @UseGuards(AuthGuard) async publish(@Param('id', ParseIntPipe) id: number) { return { data: await this.postsService.publish(id) }; }
@Get('published') async published() { return { data: await this.postsService.findPublished() }; }}Nested resources
Mount a controller under a parent resource path. The params option maps URL parameters to entity fields — they are automatically applied as where filters on list/findById/count and injected into the body on create:
@CrudController<CommentEntity, EM>({ model: CommentEntity,
// URL path with :postId parameter path: 'posts/:postId/comments',
// Map :postId from URL to the 'postId' field on CommentEntity params: { postId: { field: 'postId', type: 'number' }, },})export class CommentsController extends RelayerController<CommentEntity, EM> { constructor(@InjectQueryService(CommentEntity) service: RelayerService<CommentEntity, EM>) { super(service); }}What this does:
GET /posts/5/comments->service.findMany({ where: { postId: 5 } })GET /posts/5/comments/3->service.findFirst({ where: { postId: 5, id: 3 } })POST /posts/5/commentswith{ content: "hello" }->service.create({ data: { content: "hello", postId: 5 } })GET /posts/5/comments/count->service.count({ where: { postId: 5 } })
ID configuration
By default, the ID field is auto-detected from the Drizzle table primary key. Override if your table uses a non-standard name or UUID:
@CrudController<UserEntity, EM>({ model: UserEntity, id: { field: 'userId', type: 'uuid' },})Types: 'number' (default), 'string', 'uuid'. This affects how the :id path parameter is parsed.
Full example
See the complete controller example in the repository.