| name | nestjs-crud |
| description | Use when integrating `@nestjs-crud/*` (v2.2+) — wiring TypeORM/Drizzle/MikroORM/Prisma adapters, configuring `@Crud()`/`@CrudAuth()`/`@Override()`/`@Feature()`/`@Action()`, opt-in cursor pagination, ACL/RBAC guards via `getFeature`/`getAction` + `nest-access-control`/CASL, DTOs with `CrudValidationGroups`, split-query relation loading, debugging `RequestQueryException`, `CrudCacheNotConfiguredError`, `EBADENGINE` (Node <22), validation-fails-on-update, MikroORM stale-em, savepoints on overridden writes, TS2559 "has no properties in common" on `implements CrudController` with `serviceProperty` (use `CrudControllerFor`). |
@nestjs-crud
Auto-generates RESTful CRUD endpoints from @Crud(). Four adapters: TypeORM, Drizzle, MikroORM, Prisma. Node 22+. CHANGELOG.
Install
npm install @nestjs-crud/core @nestjs-crud/<adapter>
npm install @nestjs-crud/request
npm install pg
npm install mysql2
npm install @prisma/adapter-pg pg
npm install @prisma/adapter-mariadb mariadb
npm install -D prisma
Swagger optional — install @nestjs/swagger to enable metadata; library null-guards every Swagger path when absent.
Quickstart
@Crud() block + @Controller() are identical across adapters. Only service ctor differs.
@Crud({
model: { type: User },
query: {
limit: 25, maxLimit: 100,
join: { profile: { eager: true }, posts: { allow: ['id', 'title'] } },
},
})
@Controller('users')
export class UsersController implements CrudController<User> {
constructor(public service: UsersService) {}
}
TypeORM service
@Injectable()
export class UsersService extends TypeOrmCrudService<User> {
constructor(@InjectRepository(User) repo: Repository<User>) {
super(repo);
}
}
Drizzle service
@Injectable()
export class UsersService extends DrizzleCrudService<typeof users.$inferSelect> {
constructor(@Inject('DB') drizzleDb: typeof db) {
super(drizzleDb, users);
}
}
MikroORM service
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/core';
@Injectable()
export class UsersService extends MikroOrmCrudService<User> {
constructor(@InjectRepository(User) usersRepo: EntityRepository<User>) {
super(usersRepo, User);
}
}
Constructor accepts EntityManager | EntityRepository<T> (v2.2+). super(usersRepo, User) unwraps via repo.getEntityManager() internally. The DI-injected em proxy resolves to the per-request forked em via RequestContext — request-scope identity-map isolation preserved without effort. To pass EntityManager directly: constructor(em: EntityManager) { super(em, User); } — same behavior.
Prisma service (v2.1+ with Prisma 7)
const prisma = new PrismaClient({
adapter: new PrismaPg(new Pool({ connectionString: process.env.DATABASE_URL })),
});
@Injectable()
export class UsersService extends PrismaCrudService<User> {
constructor(@Inject('PRISMA') prisma: PrismaClient) {
super(prisma, 'user', {
entityColumns: ['id', 'email', 'isActive', 'companyId', 'deletedAt'],
primaryColumns: ['id'],
softDeleteColumn: 'deletedAt',
});
}
}
Prisma 7 removed the env-URL path — driver adapter required. Schema rewrite covered in v2.1 Migration wiki. Swap @prisma/adapter-pg for @prisma/adapter-mariadb on MySQL. 3rd ctor arg = PrismaCrudServiceConfig (logger lives inside).
Generated Endpoints
| Method | Path | Handler |
|---|
| GET | /users | getManyBase |
| GET | /users/:id | getOneBase |
| POST | /users | createOneBase |
| POST | /users/bulk | createManyBase |
| PATCH | /users/:id | updateOneBase |
| PUT | /users/:id | replaceOneBase |
| DELETE | /users/:id | deleteOneBase |
| POST | /users/:id/recover | recoverOneBase (requires query.softDelete: true) |
@Crud() Key Options
@Crud({
model: { type: Entity },
dto: { create, update, replace },
serialize: { getMany, get },
query: {
limit: 25, maxLimit: 100,
pagination: 'offset' | 'cursor',
cache: 2000,
alwaysPaginate: false,
softDelete: false,
relationLoadStrategy: 'join' | 'query',
join: { rel: { eager, allow, exclude, required } },
filter: [{ field, operator, value }],
sort: [{ field, order }],
exclude: ['password'],
},
routes: { exclude: ['createManyBase'], updateOneBase: { allowParamsOverride: false } },
params: { id: { field: 'id', type: 'uuid', primary: true } },
serviceProperty: 'usersService',
})
Full reference: Controllers wiki.
Cursor Pagination (v2.2+, opt-in)
@Crud({ query: { pagination: 'cursor', limit: 25 } })
Switches getManyBase to keyset cursor mode. Response: { data, count, cursor: { next, prev } }. Default 'offset' preserved (non-breaking).
- Cursor = opaque base64url JSON
{sortField, sortValue, id, dir}. NOT signed — authorization stays in @CrudAuth. Cursor opacity ≠ access boundary.
@CrudAuth filter applies BEFORE cursor (auth merges into parsed.search at the interceptor; cursor WHERE ANDs on top at the service). Cursor pages are subsets of the @CrudAuth-filtered result set — same isolation guarantees as offset mode.
- Single sort field + auto-PK tail; multi-sort + cursor → 400. Requires
limit or maxLimit (missing → 400). Tampered/wrong-sort cursor → 400.
- Cursor mode bypasses cache wrap (per-cursor-key cardinality unbounded). On hot endpoints, pair with
@nestjs/throttler.
- Honored across all 4 adapters via per-adapter
QueryComposer.applyCursor. Prisma's built-in cursor: arg intentionally bypassed (single-column unique-key only).
Cursor Pagination wiki.
Query Params (Frontend → Backend)
RequestQueryBuilder.create()
.setFilter({ field: 'isActive', operator: CondOperator.EQUALS, value: true })
.setOr({ field: 'role', operator: CondOperator.IN, value: ['admin'] })
.setJoin({ field: 'profile' })
.sortBy({ field: 'createdAt', order: 'DESC' })
.setLimit(20).setPage(2)
.query();
23 operators (CondOperator): $eq $ne $gt $gte $lt $lte $between $isnull $notnull $in $notin $cont $excl $starts $ends + 8 case-insensitive *L variants.
Raw form: ?filter=name||$cont||john&sort=createdAt,DESC&join=profile&limit=25&page=2&cursor=<opaque>
Global Defaults
Set project-wide defaults in main.ts BEFORE NestFactory.create. Every @Crud() deep-merges; controller scalars win, arrays replace.
CrudConfigService.load({
query: { limit: 25, maxLimit: 100, alwaysPaginate: true, cache: 2000, softDelete: false },
routes: { updateOneBase: { allowParamsOverride: false, returnShallow: false }, deleteOneBase: { returnDeleted: false } },
params: { id: { field: 'id', type: 'number', primary: true } },
serialize: { getMany: false },
queryParser: { delimiter: ',' },
auth: { property: 'user' },
});
@CrudAuth — Per-User Scoping
@Crud({ model: { type: Post } })
@CrudAuth({
property: 'user',
filter: (u: User) => ({ authorId: { $eq: u.id } }),
persist: (u: User) => ({ authorId: u.id }),
or: false,
})
@UseGuards(JwtAuthGuard)
@Controller('posts')
export class PostsController { constructor(public service: PostsService) {} }
persist keys validated against entity columns at runtime — typos throw RequestQueryException → 400.
Guard ordering: @UseGuards() MUST be on the controller class (runs before CrudRequestInterceptor reads req.user).
ACL / RBAC integration
Generated route handlers carry an auto-applied @Action(CrudActions.<X>) — values: Read-All, Read-One, Create-One, Create-Many, Update-One, Replace-One, Delete-One, Recover-One. Tag the controller with @Feature(name) and reflect both from a guard via getFeature(ctx.getClass()) + getAction(ctx.getHandler()), then dispatch to your ACL backend (nest-access-control, CASL, etc). Full ACLGuard example: Controllers wiki §Additional decorators.
@Override() — Custom Handlers
@Override('getOneBase')
getUser(@ParsedRequest() req: CrudRequest, @Param('id') id: string) {
return this.service.getOne(req);
}
@Override('createOneBase')
createUser(@ParsedRequest() req: CrudRequest, @ParsedBody() dto: User) {
return this.service.createOne(req, dto);
}
@Override accepts: 'getManyBase', 'getOneBase', 'createOneBase', 'createManyBase', 'updateOneBase', 'replaceOneBase', 'deleteOneBase', 'recoverOneBase'.
Use @ParsedBody() not @Body() in write overrides — @Body() bypasses class-validator group selection (CREATE and UPDATE validation become identical).
DTOs — Two Patterns
Pattern A (default): Entity-as-DTO with class-validator groups:
const { CREATE, UPDATE } = CrudValidationGroups;
@IsOptional({ groups: [UPDATE] })
@IsNotEmpty({ groups: [CREATE] })
@IsString({ always: true })
name: string;
CREATE → POST + PUT; UPDATE → PATCH; { always: true } → both.
Pattern B: Dedicated DTOs via @Crud({ dto: { create, update, replace } }). With dto, CrudValidationGroups not used — controller validates against the DTO class directly. Combine entity-in (A) + DTO-out (@Crud({ serialize })) freely.
Validation groups: factory vs hand-rolled controllers
CrudValidationGroups.CREATE / UPDATE only fire when the validator runs with matching groups. The @Crud() factory binds new ValidationPipe({ groups: [CREATE | UPDATE], ... }) per generated route automatically. Hand-rolled controllers built without @Crud() get the default pipe (groups: undefined); group-scoped decorators silently skip — required-field validators don't fire.
@Controller('special')
export class SpecialController {
@Post('/users')
createSpecial(@Body() user: User) { ... }
}
@Post('/users')
createSpecial(
@Body(new ValidationPipe({ groups: [CrudValidationGroups.CREATE], whitelist: true })) user: User,
) { ... }
Many such routes? Factor a helper. Alternative: switch to dedicated DTOs (Pattern B) without group decorators — they validate identically everywhere.
Strict Field Allowlist (v2 BREAKING)
Every field in ?sort/?filter/?search/?fields/?join MUST be entity column OR allow-listed relation. Otherwise: RequestQueryException: Invalid field 'X' → 400. No opt-out. Common breakages: TypeORM @VirtualColumn/@Formula (not in metadata.columns — override protected entityColumnsHash in subclass to allow), client-side aliases, dotted paths against unjoined relations.
Caching
@Crud({ query: { cache: <ttl-ms> } }) honored by all 4 adapters when a CacheStrategy is wired (TypeORM-only pre-v2.2). TTL is milliseconds.
import { createClient } from 'redis';
import { CrudConfigService } from '@nestjs-crud/core';
import { TypeOrmCacheStrategy } from '@nestjs-crud/typeorm';
const redis = createClient({ url: 'redis://localhost:6379' });
CrudConfigService.load({
query: { cache: 5000, cacheStrategy: new TypeOrmCacheStrategy(redis) },
});
Per-CrudService override available via constructor. Per-route override via @Crud({ query: { cache } }). Per-request bypass via ?cache=0.
Strategies: TypeOrm | MikroOrm | Drizzle | PrismaRedis | PrismaAccelerate. Accept redis (node-redis v5) or ioredis clients with lazy-once auto-connect — no explicit connect() required. Custom backends: implement RedisLike (set / get / del / scanPrefix) from @nestjs-crud/core/cache. Writes auto-invalidate by entity prefix.
@Crud cache set without strategy (and no TypeORM DataSource.cache fallback) → CrudCacheNotConfiguredError on next cached read. Setup, security, tuning: Caching wiki.
Cursor mode bypasses cache wrap entirely (per-cursor-key cardinality unbounded — see §Cursor Pagination). If you opt a route into pagination: 'cursor', the cache knob on that route becomes a no-op; pair with @nestjs/throttler instead.
Transactions on Write Overrides
updateOne / replaceOne / deleteOne wrap read-modify-write in tx at READ COMMITTED — closes lost-update race. recoverOne excluded (no prior read). If you wrap an @Override() in your own outer tx, adapter's inner tx becomes a savepoint inside yours; higher outer isolation NOT downgraded; rollbacks cascade up.
TypeORM Split-Query Relation Loading
@Crud({ query: { relationLoadStrategy: 'query' } }) — separate query per relation via setFindOptions. Footgun: JoinOption.allow is ignored; relations load all columns. Don't opt in if you use allow to hide sensitive columns. Other adapters use split queries natively; opt-in is no-op.
| Strategy | Behavior | When to pick |
|---|
'join' | Single SQL via leftJoin/innerJoin | Shallow joins; or you depend on JoinOption.allow |
'query' | Separate query per relation via setFindOptions | Deep multi-relation reads where JOIN cross-products inflate payload |
Swagger Customization
@Crud({ swagger: {...} }) controls operation text, tags, examples, error responses, version-aware grouping. Defaults: imperative summaries, multi-line descriptions, 400/401-if-auth/404 emission, auto @ApiTags, auto request-body examples from @ApiProperty.
Constraints: operationId not overridable; default 401 emission keys off @CrudAuth (set swagger.errorResponses.unauthorized: true for APP_GUARD setups); synthExample consumer fn wins over metadata; @Override() + manual @ApiOperation() wins over swagger.operations[...]. Full reference: Swagger wiki.
Logger
All 4 adapters default to new Logger(<ServiceName>). TypeORM/Drizzle/MikroORM accept logger?: LoggerService as last positional ctor arg. Prisma's logger lives inside serviceConfig as { error, warn?, debug? }.
Emission convention: debug for query traces; warn for SQLi rejections + tx rollbacks; error for uncaught DB errors. NEVER interpolate err.message — DB drivers leak SQL parameter values into messages; emit err.name as the message and pass err.stack as second arg.
IntelliSense
@Crud() wires handlers at runtime; TS can't see them. implements CrudController<T> restores service typing. From inside @Override(), this.service.getMany(req) is fully typed and idiomatic. Composing two generated handlers in one override: get base(): CrudController<User> { return this; }.
With serviceProperty (v2.2.5+): implements CrudController<T> fails — TS2559 has no properties in common. CrudController<T> is all-optional (weak type); a class declaring only a renamed field shares zero properties with it. Use CrudControllerFor<T, P> instead:
@Crud({ model: { type: Contact }, serviceProperty: 'contactService' })
@Controller('contacts')
export class ContactsController implements CrudControllerFor<Contact, 'contactService'> {
constructor(public contactService: ContactsCrudService) {}
}
Default CrudControllerFor<T> ≡ CrudController<T>. Alternative: drop implements entirely — routing doesn't need it.
Best Practices
- Use
CondOperator enum, not raw strings ('$cont' silently misspells)
- Always
exclude sensitive fields in query config (?fields=password returns it without)
- Always
allow-list join fields on sensitive relations
search for AND/OR composition; filter is AND-only
- Set
maxLimit server-side — never trust client limit
- Cursor mode on hot endpoints → pair with
@nestjs/throttler (cache bypass = unbounded cardinality)
Common Issues
| Symptom | Fix |
|---|
| Relations not loading | Add to query.join. Without it, client join requests silently ignored. |
maxLimit exceeded → 400 | Raise maxLimit or set alwaysPaginate: false. |
| Validation always fails on update | Fields need @IsOptional({ groups: [UPDATE] }). |
Cannot find module 'typeorm' / Cannot find module 'pg' / Cannot find module 'mysql2' / repo.createQueryBuilder is not a function | Adapter ORM or DB driver peer not installed. npm install typeorm pg (Postgres) or npm install typeorm mysql2 (MySQL). All four adapters declare DB drivers as optional peers — pick the one your backend uses. |
| Swagger metadata empty | @nestjs/swagger not installed. Library skips Swagger setup; install + restart. |
@CrudAuth filter not applying | @UseGuards() must be on controller class (runs before interceptor). |
Flat array instead of { data, count, total, page } | alwaysPaginate: true inside query: (NOT top-level). |
RequestQueryException: Invalid field 'X' → 400 | Add to entity columns or query.join allow-list. |
RequestQueryException: Invalid persist key 'X' → 400 | Typo in @CrudAuth({ persist }) against entity column. |
TS2559 has no properties in common with type 'CrudController<T>' | serviceProperty renamed the field; weak-type check fails. implements CrudControllerFor<Entity, 'fieldName'> (v2.2.5+) or drop implements. |
EBADENGINE on npm install | Node <22. Upgrade or pin to ^1.0.2. |
CrudCacheNotConfiguredError | @Crud cache set but no CacheStrategy wired (and no TypeORM DataSource.cache fallback). |
| Swagger metadata empty | @nestjs/swagger not installed. Library skips Swagger setup; install + restart. |
Cursor: Cursor pagination supports a single sort field → 400 | Multi-sort + cursor not supported. Use one sort field. |
Cursor: Cursor pagination requires a limit → 400 | Cursor mode needs query.limit or maxLimit. |
Cursor: Invalid cursor → 400 | Tampered, expired schema, or wrong sort field on this route. |
MikroORM: stale entity / em.flush() doesn't persist | Subclass cached em in ctor. Use getEm() thunk; never store em as field. |
| MikroORM: Jest ESM error | Use yarn test:mikro-orm — has NODE_OPTIONS=--experimental-vm-modules inline. Direct npx jest fails. |
Prisma: Unknown argument 'where' on include | Prisma rejects where on to-one include. Filter at parent where. Adapter handles SCondition dotted paths automatically. |
Outer tx + @Override() write: SERIALIZABLE persists, rollback cascades | Adapter inner tx is savepoint inside yours. Decide: remove the wrap or accept savepoint nesting. |
Split-query: relation loads all columns despite allow | setFindOptions doesn't expose alias-level select. Stay on 'join' for sensitive relations. |