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 relations: { postCategories: true, // POST/DELETE/PUT /posts/:id/relations/postCategories }, },})Each route accepts true (enable with defaults), false (disable), or a config object. CRUD routes are enabled by default; relation routes must be enabled explicitly.
See Relations for the full relation endpoints guide.
Pagination
Offset (default)
@CrudController<PostEntity, EM>({ model: PostEntity, routes: { list: { defaultLimit: 20, maxLimit: 100, }, },})Cursor
@CrudController<PostEntity, EM>({ model: PostEntity, routes: { list: { pagination: 'cursor', defaults: { orderBy: { field: 'createdAt', order: 'desc' } }, defaultLimit: 20, }, },})See Search & Filtering for client-side usage and response formats.
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', 'relationConnect', 'relationDisconnect', 'relationSet'.
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. Relation-specific hooks (beforeRelation, afterRelation) are covered in Relations.
Swagger
All auto-generated routes include OpenAPI metadata — summaries, query parameters with examples, request body schemas, response codes. Install @nestjs/swagger and it just works. Customize per-route via swagger config:
@CrudController<PostEntity, EM>({ model: PostEntity, swagger: { tag: 'Blog Posts', list: { summary: 'Search blog posts' }, create: { description: 'Publish a new post with tags' }, },})Set swagger: false to disable. See Swagger / OpenAPI for the full guide.
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 }; }
// Override relation connect protected async handleRelationConnect( id: string, relationName: RelationKeys<PostEntity, EM>, body: Record<string, unknown>, request: unknown, ) { console.log(`Connecting ${relationName} to post ${id}`); return super.handleRelationConnect(id, relationName, body, request); }}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.
Typed context
RelayerController accepts two extra generics — TCtx (the request-scoped context that hooks receive) and TQueryCtx (the slim context the service consumes). Override buildContext to extract data from the request, and buildQueryContext to derive the query-scope context from it. Both are auto-wired into every handler.
The typed flow:
HTTP request -> buildContext(request) -> AppContext (passed to hooks) -> buildQueryContext(appCtx) -> AppQueryContext (passed to service.* { context }) -> getDefaultWhere(_, ctx) is called with AppQueryContext for row-level scopingDefining the contexts
Two interfaces — one for hooks (request-rich) and one for the service (slim, SQL-bound):
import type { RequestContext } from '@relayerjs/nestjs-crud';
export interface AppUser { id: number; role: 'admin' | 'user';}
// Full request-scoped context. Used by NestJS lifecycle hooks.export interface AppContext extends RequestContext { currentUser: AppUser;}
// Slimmer query context. Flows into service.findMany / computed / derived resolvers.export interface AppQueryContext { currentUserId: number; isAdmin: boolean;}Typing the controller
Pass AppContext and AppQueryContext as the 4th and 5th generics, then override the two builders:
import { CrudController, RelayerController } from '@relayerjs/nestjs-crud';
import type { AppContext, AppQueryContext, AppUser } from '../../common/app-context';import { PostEntity, type EM } from '../entities';import { PostDtoMapper } from './posts.dto-mapper';import { PostHooks } from './posts.hooks';import { PostsService } from './posts.service';
@CrudController<PostEntity, EM>({ model: PostEntity, routes: { list: true, create: true, update: true, delete: true }, hooks: PostHooks, dtoMapper: PostDtoMapper,})export class PostsController extends RelayerController< PostEntity, EM, PostDtoMapper, AppContext, AppQueryContext> { constructor(private readonly postsService: PostsService) { super(postsService); }
protected buildContext(request: unknown): AppContext { const user = (request as { user?: AppUser }).user ?? { id: 0, role: 'user' }; return { request, currentUser: user }; }
protected buildQueryContext(ctx: AppContext): AppQueryContext { return { currentUserId: ctx.currentUser.id, isAdmin: ctx.currentUser.role === 'admin', }; }}How it propagates
For every auto-generated route the controller automatically:
- Calls
buildContext(request)to produceAppContext - Passes
AppContextto all matching lifecycle hooks (beforeFind,afterCreate,beforeRelation, etc.) - Calls
buildQueryContext(ctx)to produceAppQueryContext - Forwards
AppQueryContextto the service via{ context: queryCtx }on every read AND write call
The service then uses it inside getDefaultWhere(upstream, ctx) for row-level filtering — see Query Service > Typed context.
Why two contexts
AppContext is rich and request-bound (carries the raw request, headers, full user object) — perfect for hooks doing logging, metrics, or cache invalidation. AppQueryContext is slim and serializable — only the fields the SQL layer actually needs. Keeping them separate makes hook code expressive and SQL-resolver code minimal.
If you don’t need the split, set both generics to the same interface and return ctx from buildQueryContext.
Hook types
Update PostHooks to take advantage of the typed AppContext:
import { Injectable, Logger } from '@nestjs/common';import { RelayerHooks } from '@relayerjs/nestjs-crud';
import type { AppContext } from '../../common/app-context';import { PostEntity, type EM } from '../entities';
@Injectable()export class PostHooks extends RelayerHooks<PostEntity, EM, AppContext> { private readonly logger = new Logger(PostHooks.name);
async afterCreate(entity: PostEntity, ctx: AppContext): Promise<void> { this.logger.log(`Post ${entity.id} created by user ${ctx.currentUser.id}`); }}ctx.currentUser is fully typed — no casts.
Full example
See the complete controller example in the repository.