A simple nestjs boilerplate that includes the basic and crucial features to help start your project quickly.
- NestJS 12 (Express 5)
- Mongoose 9
- Zod validation for request DTOs and environment variables (Standard Schema)
- Config Service
- Swagger (request bodies are generated from the Zod schemas)
- Generate client routes from swagger endpoints
- Authentication
- Google oauth (optional, enabled by the
GOOGLE_*variables) - Access Control (CASL)
- Filtering, searching and pagination from the query string
- Refresh and Access tokens
- Unit testing and E2E testing
- Seeder
- Github actions
- Docker
- K8S
- Node.js 24 LTS (recommended). The app itself runs on Node
^20.19.0 || >=22.12.0, but:- the Nest CLI generators (
nest generate,nest upgrade) need Node22.22.3+,24.15+or26+ - the test scripts rely on Jest's native
require(esm)support, which needs Node24.9+
- the Nest CLI generators (
- MongoDB
To get a local copy up and running follow these steps.
-
Click on use template and click new repository.
-
Navigate to the project directory.
cd <repository_directory>
-
Create a
.envfile and populate it with the required environment variables provided in the.env.examplefile. -
Install the dependencies.
npm install
The variables are validated on startup by the Zod schema in src/config.type.ts, and the app refuses to start if a required one is missing. EnvConfig is inferred from the same schema, so ConfigService<EnvConfig> is fully typed.
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
no | 3000 |
HTTP port |
DB_URL |
yes | MongoDB connection string | |
ACCESS_SECRET |
yes | Secret used to sign access tokens | |
REFRESH_SECRET |
yes | Secret used to sign refresh tokens | |
ACCESS_TOKEN_EXPIRATION |
yes | Access token lifetime, e.g. 10m |
|
REFRESH_TOKEN_EXPIRATION |
yes | Refresh token lifetime, e.g. 7d |
|
GOOGLE_CLIENT_ID |
no | Google OAuth client id (see Google login) | |
GOOGLE_CLIENT_SECRET |
no | Google OAuth client secret (see Google login) | |
GOOGLE_CALLBACK_URL |
no | Google OAuth callback url (see Google login) |
DTOs are Zod schemas. Each DTO file exports the schema and the TypeScript type inferred from it:
// src/modules/auth/dto/login.payload.ts
import { z } from 'zod';
export const loginSchema = z.strictObject({
email: z.string().min(1),
password: z.string().min(1),
});
export type LoginPayload = z.infer<typeof loginSchema>;Pass the schema to the route decorator with the schema option:
@Post('login')
login(@Body({ schema: loginSchema }) payload: LoginPayload) {
// payload is already validated here
}- The global
StandardSchemaValidationPipe(registered inmain.ts) validates every@Body(),@Query()and@Param()that declares aschema. z.strictObject()rejects unknown keys with a400. Usez.object()if you would rather strip them.- Swagger reads the same schemas, so there is no need for
@ApiProperty()on DTOs.
Google login is optional. It is enabled only when GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and GOOGLE_CALLBACK_URL are all set. Otherwise a warning is logged on startup and /api/auth/google returns 404.
- Create an OAuth client ID of type "Web application" in the Google Cloud console.
- Add
http://localhost:<port>/api/auth/google/redirectas an authorized redirect URI and use the same value forGOOGLE_CALLBACK_URL. - Copy the client id and secret into
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET.
Open http://localhost:<port>/api/auth/google in a browser to sign in. Google redirects back to /api/auth/google/redirect, which:
- creates the user on the first login (Google users have no password)
- sets the
access_tokenandrefresh_tokencookies - returns the tokens as JSON
QueryMiddleware parses the query string of every request into req.queryObj and req.pagination, and buildQueryFilter() turns that into a mongoose filter.
The querying system works by combining the searched field, operator, and the value. the format looks like following:
field-operator=valueFor example:
https://url/?fullName-contains=lee ['equals', '$eq'],
['notEquals', '$ne'],
['lessThan', '$lt'],
['lessThanOrEqual', '$lte'],
['greaterThan', '$gt'],
['greaterThanOrEqual', '$gte'],
['in', '$in'],
['notIn', '$nin'],
['contains', '$regex'],
['notContains', '$not'],inandnotIntake a comma separated list:?email-in=a@b.c,d@e.fcontainsandnotContainsare case insensitive- numeric values are converted to numbers, so
?views-greaterThan=10compares numbers - an unknown operator is rejected with a
400
For a field of an embedded document use "." between the nested fields:
?address.city-contains=erbilFor a field of a referenced document (a ref in the schema) add -ref- before the operator:
?author.fullName-ref-contains=leeThe referenced collection is queried first and the matching ids are applied with $in. It works both for a single reference and for an array of references, and it fails with a 400 when the field is not a reference, or when no field is given (?author-ref-contains=lee).
?search=leeMatches case insensitively across every text field of the model, except the ones declared with select: false. The term is matched literally, so regex characters are escaped.
page, limit, sort and sortBy (asc | desc) default to page=1&limit=10&sort=createdAt&sortBy=desc and are available on req.pagination.
Add QueryTypes() to the controller for the Swagger parameters, then build the filter from the parsed query:
async findAll(req: IRequest): Promise<TResponse<TPost>> {
const posts = this.postModel
.find(await buildQueryFilter(this.postModel, req.queryObj))
.sort({ [req.pagination.sort]: req.pagination.sortBy === 'desc' ? -1 : 1 });
const count = await posts.clone().countDocuments();
posts.limit(req.pagination.limit).skip(req.pagination.skip);
return {
result: await posts.exec(),
count,
limit: req.pagination.limit,
page: req.pagination.page,
};
}To start the development server, run the following command:
npm run devThe API is served under http://localhost:<port>/api and the Swagger UI under http://localhost:<port>/api/docs.
To start unit testing, run the following command:
npm run testand to run E2E tests (they need a running MongoDB and the variables from .env):
npm run test:e2eto run the seeder, pass how many users to generate as an argument (defaults to 10):
npm run seed -- 50Seeders are registered in SeederModule (src/seeder.module.ts), which only seed.ts loads. That keeps them, and faker, out of the running app and the production image. Add new seeders there.
to generate the client routes from the swagger run:
npm run generate:api-clientthis will run npm run generate:swagger && openapi-generator-cli generate -i swagger.json -g typescript-fetch -o ./src/api-client
adjust it to your needs for example if you dont want it to compile to ./src/api-client
to run the Dockerfile:
docker compose up -dto run the K8S, navigate to k8s directory and run:
kubectl apply -f backend-config.yaml
kubectl apply -f backend-deployment.yaml
kubectl apply -f mongodb-deployment.yamlps: make sure you have a k8s cluster running, I use minikube.
The server should now be running at http://localhost:<port>/api. You can access the endpoints using a tool like Postman or any web browser.
The template follows the NestJS 12 migration guide. If you started from an older copy, these are the changes that affect your own code:
- CommonJS stays CommonJS. The
@nestjs/*packages are ESM-only now and are loaded through Node'srequire(esm), which is why the Node versions above are required.tsconfig.jsonuses"module": "nodenext"and TypeScript 6. TypeScript 7 is not supported yet by the Nest CLI andtypescript-eslint. - class-validator, class-transformer and Joi are gone. DTOs are Zod schemas validated by
StandardSchemaValidationPipe, and the env schema inConfigModule.forRoot({ validationSchema })is Zod as well. - Express 5. Wildcard routes must be named (
forRoutes('{*splat}')instead offorRoutes('*')), andreq.queryis a read-only getter, so middleware must not assign to it. Pagination values live onreq.pagination. - Mongoose 9. Use
{ returnDocument: 'after' }instead of{ new: true }infindByIdAndUpdate/findOneAndUpdate. - Passport. Nest 12 only reads
@Optional()from a class's own constructor, so guards that extendAuthGuard()re-declareconstructor(@Optional() options?: AuthModuleOptions)(seesrc/common/guards). Without it, every module that uses the guard would have to importPassportModule. Strategies return the user fromvalidate()instead of callingdone(). - Queries. Reference filters (
-ref-) andsearchare applied bybuildQueryFilter(), so services passreq.queryObjto it instead of spreadingreq.queryObj.regularintofind(). - Jest. The test scripts run Jest with
node --experimental-vm-modulesso it can load the ESM Nest packages. Callnpm run testrather thannpx jest. - Husky 9. Hooks are plain shell files in
.husky/, andpreparerunshusky.
Author Lhon Rafaat.