@nestjs-crud (v1.0.x — legacy)
Auto-generates RESTful CRUD endpoints from a single @Crud() decorator. Three adapters: TypeORM, Drizzle, MikroORM. Maintenance-line only — new projects should use v2.
Install
# Pick adapter
npm install @nestjs-crud/core @nestjs-crud/typeorm
npm install @nestjs-crud/core @nestjs-crud/drizzle
npm install @nestjs-crud/core @nestjs-crud/mikro-orm
# Optional frontend query builder
npm install @nestjs-crud/request
Required peer deps (install explicitly — v1 does NOT declare them):
@nestjs-crud/corepeers:class-validator,class-transformer@nestjs-crud/typeormALSO needs:typeorm,@nestjs/typeorm,@nestjs/common@nestjs-crud/drizzleALSO needs:drizzle-orm(≥ 0.36.0),@nestjs/common@nestjs-crud/mikro-ormALSO needs:@mikro-orm/core(≥ 6.0.0),@mikro-orm/knex(≥ 6.0.0),@nestjs/common
npm install will NOT warn if you forget. Symptoms at runtime: TypeError: repo.createQueryBuilder is not a function or Cannot find module 'typeorm'. Fixed in v2 (every adapter declares full peers).
Swagger optional — install @nestjs/swagger to enable; library safeRequire-skips when absent.
Global Defaults — CrudConfigService.load()
Set project-wide defaults in main.ts BEFORE NestFactory.create. Every @Crud() deep-merges; controller scalars win, arrays replace.
// src/main.ts
import { CrudConfigService } from '@nestjs-crud/core';
CrudConfigService.load({
query: { limit: 25, maxLimit: 100, alwaysPaginate: true, cache: 2000, softDelete: false },
routes: { updateOneBase: { allowParamsOverride: false }, deleteOneBase: { returnDeleted: false } },
params: { id: { field: 'id', type: 'number', primary: true } },
serialize: { getMany: false },
auth: { property: 'user' },
});
const app = await NestFactory.create(AppModule);
Default without load(): alwaysPaginate: false, no global params, no auth defaults.
Quickstart
Controller + @Crud() identical across adapters. Only service constructor 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); // (db instance, table reference)
}
}
MikroORM service
@Injectable()
export class UsersService extends MikroOrmCrudService<User> {
constructor(em: EntityManager) {
super(em, User);
}
}
MikroOrmCrudService accepts EntityManager directly. For @InjectRepository(User) style, unwrap manually:
constructor(@InjectRepository(User) repo: EntityRepository<User>) {
super(repo.getEntityManager(), User);
}
(v2.2.0+ accepts EntityManager | EntityRepository<T> directly — no unwrap.)
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 |
Query Params (Frontend → Backend)
import { RequestQueryBuilder, CondOperator } from '@nestjs-crud/request';
const qb = RequestQueryBuilder.create()
.setFilter({ field: 'isActive', operator: CondOperator.EQUALS, value: true })
.setOr({ field: 'role', operator: CondOperator.IN, value: ['admin', 'mod'] })
.setJoin({ field: 'profile' })
.sortBy({ field: 'createdAt', order: 'DESC' })
.setLimit(20)
.setPage(2);
fetch(`/users?${qb.query()}`);
23 operators (CondOperator enum):
- Equality / comparison:
$eq,$ne,$gt,$gte,$lt,$lte - Range / null:
$between,$isnull,$notnull - Set:
$in,$notin - String match:
$cont,$excl,$starts,$ends - Case-insensitive variants:
$eqL,$neL,$inL,$notinL,$contL,$exclL,$startsL,$endsL
Raw form: ?filter=name||$cont||john&sort=createdAt,DESC&join=profile&limit=25&page=2
@Crud() Key Options
@Crud({
model: { type: Entity }, // required
dto: { create: CreateDto, update: UpdateDto, replace: ReplaceDto },
serialize: { getMany: ListResponse, get: ItemResponse },
query: {
limit: 25, maxLimit: 100,
cache: 2000, // ms — TypeORM only; Drizzle/MikroORM no-op
alwaysPaginate: false,
softDelete: false, // enables recoverOneBase
join: { rel: { eager: true, allow: ['id', 'name'], exclude: ['secret'], required: false } },
filter: [{ field: 'deletedAt', operator: '$isnull' }],
sort: [{ field: 'id', order: 'ASC' }],
exclude: ['password'],
},
routes: {
exclude: ['createManyBase'],
updateOneBase: { allowParamsOverride: false, returnShallow: false },
deleteOneBase: { returnDeleted: false },
},
params: { id: { field: 'id', type: 'uuid', primary: true } },
validation: { whitelist: true },
})
recoverOneBase requires query.softDelete: true + soft-delete column on entity.
Scoping Requests Per User (@CrudAuth)
@Crud({ model: { type: Post } })
@CrudAuth({
property: 'user',
filter: (user: User) => ({ authorId: { $eq: user.id } }),
persist: (user: User) => ({ authorId: user.id }),
})
@UseGuards(JwtAuthGuard)
@Controller('posts')
export class PostsController implements CrudController<Post> {
constructor(public service: PostsService) {}
}
filter— appended to every readpersist— set on every write (prevents spoofing)property— where to find user onreq(default'user')
v1 footgun: persist typos against entity column names are SILENTLY dropped at runtime. persist: { user_id: ... } against entity column userId = auth-filter bypass on writes. Audit every @CrudAuth({ persist: {...} }) block by hand. v2 throws RequestQueryException on typo.
Guard ordering: @UseGuards() MUST come before @CrudAuth() reads req.user. Place auth guard on controller class.
Overriding Generated Routes (@Override)
@Override('getOneBase')
@UseInterceptors(MyInterceptor)
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);
}
Use @ParsedBody() not @Body() — @Body() bypasses class-validator group selection (CREATE and UPDATE collapse to identical validation).
@Override accepts: getManyBase, getOneBase, createOneBase, createManyBase, updateOneBase, replaceOneBase, deleteOneBase, recoverOneBase.
DTOs — Two Patterns
Pattern A: Entity-as-DTO (default). class-validator groups on entity:
import { CrudValidationGroups } from '@nestjs-crud/core';
const { CREATE, UPDATE } = CrudValidationGroups;
@Entity()
export class User {
@IsOptional({ groups: [UPDATE] })
@IsNotEmpty({ groups: [CREATE] })
@IsString({ always: true })
@Column()
name: string;
}
CREATE runs on POST + PUT; UPDATE on PATCH; { always: true } on both.
Pattern B: Dedicated DTO classes via @Crud({ dto: { create, update, replace } }). Use when write shape differs meaningfully from entity (nested input, computed fields, OpenAPI hardening). With dto, CrudValidationGroups is NOT used — controller validates against DTO class directly.
Output serialization is separate. Combine entity-in (Pattern A) + DTO-out (@Crud({ serialize: {...} })) freely.
Swagger
Install @nestjs/swagger — auto-generates. Library safeRequire-skips when absent.
v2 heads-up: ParamOption.enum's SwaggerEnumType is internal-import-pathed in v1 (@nestjs/swagger/dist/types/swagger-enum.type); v2 inlines it (one-line swap if you import same path directly). v2 also rewrites default Swagger text + adds @Crud({ swagger: {...} }) override surface — snapshot-test OpenAPI? Expect drift. See nestjs-crud-migration skill §Swagger.
Best Practices
CondOperatorenum, never raw strings.'$cont'silently misspells;CondOperator.CONTAINSis TypeScript-checked.CrudValidationGroupsconstants, never magic strings.'update'!==CrudValidationGroups.UPDATE.@ParsedBody()not@Body()in write overrides.@Body()bypasses group selection.- Always
excludesensitive fields inqueryconfig. Without it,?fields=passwordreturns it. - Always
allow-list join fields on sensitive relations. Withoutallow, joined relation returns all columns. - Use
searchfor AND/OR composition;filteris AND-only. Raw:?s={"$or":[{"name":{"$contL":"a"}},{"email":{"$contL":"a"}}]} - Set
maxLimitserver-side — never trust clientlimitalone. Client omittinglimitreturns all rows withoutmaxLimit. @UseGuards()on controller class, not individual routes. Runs beforeCrudRequestInterceptorresolvespropertyfromreq.
Common Issues
| Symptom | Cause + 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] }). |
@CrudAuth filter not applying |
Auth guard must run BEFORE CrudRequestInterceptor. Place @UseGuards() on class. |
Flat array instead of { data, count, total, page, pageCount } |
Set alwaysPaginate: true inside query: (NOT top-level) or via CrudConfigService.load(). |
TypeError: repo.createQueryBuilder is not a function / Cannot find module 'typeorm' |
Adapter peer not installed. v1 doesn't declare peers — install per §Install list manually. |
| Swagger metadata empty | @nestjs/swagger not installed. safeRequire skips Swagger setup; install + restart. |
@CrudAuth filter applies but persisted writes have wrong values (or missing) |
v1 silently drops typos in @CrudAuth({ persist: {...} }) against entity columns. Auth-filter bypass on writes. Audit every persist block by hand. v2 throws RequestQueryException. |
MikroORM: stale entity across requests / em.flush() doesn't persist |
Subclass cached em at constructor time, breaking per-request identity-map isolation. Resolve em fresh inside every method. v2 enforces structurally via getEm thunk. |
@Crud({ query: { cache: N } }) does nothing on Drizzle/MikroORM |
Only TypeORM honors cache in v1. Drizzle/MikroORM silently no-op. Use ORM's native cache primitive at app layer. v2: unified CacheStrategy interface across all 4 adapters. |
Pinning v1.0.x
{
"dependencies": {
"@nestjs-crud/core": "^1.0.2",
"@nestjs-crud/typeorm": "^1.0.2"
}
}
npm update continues to track v1.0.x. Bugfix patches still land. New features land in v2 only — see nestjs-crud-migration skill for upgrade path.