mcpField
N
nestjs-crud

nestjs-crud

github.com/kodjunkieDevelopment1277 installsUpdated 5w ago
Repository

About nestjs-crud

@nestjs-crud is a library that generates CRUD endpoints for NestJS applications, aimed at developers building REST APIs for entities. It supports TypeORM, Drizzle, MikroORM, and Prisma, with configurable queries, pagination, and per-user scoping.

Key features

  1. 01Generate REST endpoints with @Crud()
  2. 02Support four ORM adapters
  3. 03Configure filters, sorting, and joins
  4. 04Provide offset and cursor pagination
  5. 05Scope data by user and configure caching

Use cases

  1. 01Create CRUD APIs for NestJS entities
  2. 02Configure filtering and pagination for list routes
  3. 03Restrict reads and writes to the current user

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/kodjunkie/nestjs-crud --skill nestjs-crud

After installing, type/nestjs-crudin Claude Code to confirm it loaded.

Details

From the project README

@nestjs-crud

Auto-generates RESTful CRUD endpoints from @Crud(). Four adapters: TypeORM, Drizzle, MikroORM, Prisma. Node 22.12+ (NestJS 12 is ESM-only → Node's require(esm), which arrives at 22.12). NestJS 10–12, TypeORM 0.3.30+ or 1.x (NestJS 12 + TypeORM 1 since v2.3). CHANGELOG.

Install

npm install @nestjs-crud/core @nestjs-crud/<adapter>     # typeorm | drizzle | mikro-orm | prisma
npm install @nestjs-crud/request                         # optional frontend QB

# All four adapters declare the DB driver as an optional peer — install whichever your backend uses:
npm install pg                                           # Postgres   (typeorm/drizzle/mikro-orm)
npm install mysql2                                       # MySQL      (typeorm/drizzle/mikro-orm)

# Prisma 7 needs a driver adapter:
npm install @prisma/adapter-pg pg                        # Postgres
npm install @prisma/adapter-mariadb mariadb              # MySQL
npm install -D prisma                                    # CLI

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); // (db instance, table reference)
  }
}
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)
import { PrismaCrudService, PrismaJoinResolver } from '@nestjs-crud/prisma';

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'],
      entityPrimaryColumns: ['id'],
      entityHasDeleteColumn: true,
      softDeleteColumn: 'deletedAt',
      onBadRequest: (msg) => { throw new BadRequestException(msg); },
      joinResolver: new PrismaJoinResolver({
        relationFields: ['company'],
        allowedColumnsByRelation: { company: ['id', 'name'] },
      }),
    });
  }
}

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; all fields above are required, logger and cacheStrategy are optional fields of it.

Generated Endpoints

MethodPathHandler
GET/usersgetManyBase
GET/users/:idgetOneBase
POST/userscreateOneBase
POST/users/bulkcreateManyBase
PATCH/users/:idupdateOneBase
PUT/users/:idreplaceOneBase
DELETE/users/:iddeleteOneBase
PATCH/users/:id/recoverrecoverOneBase (requires query.softDelete: true)

@Crud() Key Options

@Crud({
  model: { type: Entity },                                 // required
  dto:        { create, update, replace },                 // optional — see DTOs
  serialize:  { getMany, get },                            // per-route response DTOs

  query: {
    limit: 25, maxLimit: 100,
    pagination: 'offset' | 'cursor',                       // v2.2+ — default 'offset'
    cache: 2000,                                           // ms
    alwaysPaginate: false,
    softDelete: false,                                     // enables recoverOneBase
    relationLoadStrategy: 'join' | 'query',                // TypeORM only
    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',                         // v2.2+ — defaults 'service'; see §IntelliSense for typing
})

Full reference: Controllers wiki.

Nested joins (v2.3+). ?join=profile.licenses needs every ancestor requested in ?join= or eager, in any order and at any depth. Otherwise all 4 adapters return 400 Invalid join: 'profile.licenses'. An eager nested key whose ancestor is not eager makes @Crud() throw at startup. Only TypeORM and Drizzle load nested relations; MikroORM and Prisma join or include the top-level relation only. A ?join= relation missing from query.join is ignored, not rejected.

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.
  • Sort: ?sort= else route query.sort default (default honored v2.2.6+); single field + auto-PK tail. Multi-sort or no sort at all → 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 },                            // disable serialize globally for list responses
  queryParser: { delimiter: ',' },                            // passed to RequestQueryBuilder.setOptions
  auth:        { property: 'user' },                          // default user property for @CrudAuth
});

@CrudAuth — Per-User Scoping

@Crud({ model: { type: Post } })
@CrudAuth({
  property: 'user',
  filter:  (u: User) => ({ authorId: { $eq: u.id } }),    // appended to every read
  persist: (u: User) => ({ authorId: u.id }),             // set on every write
  // or: (u: User) => ({ isPublic: { $eq: true } }),     // alternative to filter: rows match `or` OR the request search; filter skipped when set
})
@UseGuards(JwtAuthGuard)
@Controller('posts')
export class PostsController { constructor(public service: PostsService) {} }

persist keys validated against entity columns at runtime — typos → 400 @CrudAuth persist: invalid key(s) "X" — not columns on the target entity.

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.

// ✗ Hand-rolled — group-scoped decorators silently skip
@Controller('special')
export class SpecialController {
  @Post('/users')
  createSpecial(@Body() user: User) { ... }  // CREATE-group validators DO NOT run
}

// ✓ Hand-rolled — wire the group pipe explicitly
@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 MUST be entity column OR allow-listed relation. Otherwise 400; wording varies by adapter and branch: Field "X" is not allowed, Invalid field: 'X', Invalid sort field: 'X', Unknown column: X. 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. Cursor routes never cache (§Cursor Pagination).

Strategies: TypeOrm | MikroOrm | Drizzle | PrismaRedis | PrismaAccelerate. Accept redis (node-redis v5, or v6 since v2.3) 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.

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. A nested join loads only when its full dotted path is a query.join key (v2.3+). Drizzle, MikroORM and Prisma ignore the option.

StrategyBehaviorWhen to pick
'join'Single SQL via leftJoin/innerJoinShallow joins; or you depend on JoinOption.allow
'query'Separate query per relation via setFindOptionsDeep 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 take logger?: LoggerService as the positional ctor arg before cacheStrategy? — (repo, logger?, cacheStrategy?), (db, table, relationsConfig?, logger?, cacheStrategy?), (emOrRepo, entityClass, logger?, cacheStrategy?). 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

Common Issues

SymptomFix
Relations not loadingAdd to query.join. Without it, client join requests silently ignored.
maxLimit exceeded → 400Raise maxLimit or set alwaysPaginate: false.
Validation always fails on updateFields need @IsOptional({ groups: [UPDATE] }).
Cannot find module 'typeorm' / Cannot find module 'pg' / Cannot find module 'mysql2' / repo.createQueryBuilder is not a functionAdapter 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).
400 Field "X" is not allowed / Invalid field: 'X' / Invalid sort field: 'X' / Unknown column: XAdd to entity columns or query.join allow-list.
400 @CrudAuth persist: invalid key(s) "X"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 installNode <22.12. Upgrade or pin to ^1.0.2.
CrudCacheNotConfiguredError@Crud cache set but no CacheStrategy wired (and no TypeORM DataSource.cache fallback).
Cursor: Cursor pagination supports a single sort field → 400Multi-sort unsupported — use one field. No sort at all: pass ?sort= or set query.sort (default honored v2.2.6+; earlier needs explicit ?sort=).
Cursor: Cursor pagination requires a limit → 400Cursor mode needs query.limit or maxLimit.
Cursor: Invalid cursor / Cursor token exceeds maximum length → 400Tampered or malformed cursor, or longer than 1024 characters. Re-fetch the first page.
Cursor: Cursor sort field mismatch → 400Cursor was issued under another ?sort= field. Send the same sort field.
400 Invalid join: 'a.b'Parent a not joined. Add a to ?join= or mark it eager (v2.3+).
Startup: @Crud: eager join 'a.b' on X needs 'a' to be eager tooMark a eager, or remove eager from a.b.
npm ERESOLVE on ioredis installing @nestjs-crud/typeormTypeORM 1.x peers ioredis@^5; NestJS 12 resolves 6. npm install ioredis@^5 or add "overrides": { "ioredis": "^5.0.4" }.
MikroORM: stale entity / em.flush() doesn't persistSubclass cached em in ctor. Use getEm() thunk; never store em as field.
Jest: Cannot use 'import.meta' outside a moduleMikroORM 7 and NestJS 12 are pure ESM. Run Jest with NODE_OPTIONS=--experimental-vm-modules and a ts-jest ESM preset.
Prisma: Unknown argument 'where' on includePrisma rejects where on to-one include. Filter at parent where. Adapter handles SCondition dotted paths automatically.
Outer tx + @Override() write: SERIALIZABLE persists, rollback cascadesAdapter inner tx is savepoint inside yours. Decide: remove the wrap or accept savepoint nesting.
Split-query: relation loads all columns despite allowsetFindOptions doesn't expose alias-level select. Stay on 'join' for sensitive relations.

Comments

Markdown supported