Setting Up NestJS to Fit Your Preferences

Getting Started with NestJS
“NestJS boilerplate”
It is a search term almost everyone new to NestJS has probably tried at least once.
But when you actually inspect the code, it is hard to tell what everything does or whether you need all of it.
On the other hand, starting with nothing more than nest new feels incomplete.
So I have collected only the settings you truly need, as if a senior developer beside you were saying, “This is all you need to configure.”
Basic Configuration
1. Configure NVM Automatically
Suppose you begin a project without agreeing on a Node version.
As everyone installs dependencies, mismatched Node versions can eventually cause errors.
NVM (Node Version Manager) solves this problem.
Installation (macOS)
Install NVM through Homebrew.
$ brew install nvm
Add the NVM configuration to .zshrc.
$ vim ~/.zshrc
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh"
[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm"
The script below loads the .nvmrc file and configures NVM automatically.
# Auto NVM
autoload -U add-zsh-hook
load-nvmrc() {
[[ -a .nvmrc ]] || return
local node_version="$(nvm version)"
local nvmrc_path="$(nvm_find_nvmrc)"
if [ -n "$nvmrc_path" ]; then
local nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")
if [ "$nvmrc_node_version" = "N/A" ]; then
nvm install
elif [ "$nvmrc_node_version" != "$node_version" ]; then
nvm use
fi
elif [ "$node_version" != "$(nvm version default)" ]; then
echo "Reverting to nvm default version"
nvm use default
fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc
Now apply the .zshrc file.
$ source ~/.zshrc
.nvmrc
Next, create an .nvmrc file in the project.
$ echo 'v22.18.0' > .nvmrc
Whenever you open a terminal in the project directory, the Node version will now be pinned to v22.18.0 automatically.
Tip!
If you use asdf, look up how to configure a.tool-versionsfile.
2. Type Settings (Basic tsconfig.json Configuration)
We use TypeScript for type safety.
But without strict mode enabled, much of the value of using TypeScript is lost.
I therefore strongly recommend enabling strict mode in tsconfig.json.
{
"compilerOptions": {
"strict": true,
}
}
Enabling strict mode activates the following settings:
- noImplicitAny: reports an error when an
anytype is not explicit - alwaysStrict: applies
'use strict'to every file - strictFunctionTypes: checks function types more rigorously
- strictBindCallApply: checks types for
bind,call, andapply - strictPropertyInitialization: checks class property initialization (declare every property in the constructor, use the non-null assertion operator
!, or setstrictPropertyInitializationto false) - strictNullChecks: requires explicit handling of
nullandundefined - noImplicitThis: reports an error when the type of
thisisany
It is also worth reviewing and enabling the additional settings below.
They prevent those later moments when you think, “I meant to remove this but forgot.”
{
"compilerOptions": {
"noUnusedLocals": true, // Error on unused variables
"noUnusedParameters": true, // Error on unused parameters
"noFallthroughCasesInSwitch": true, // Error when return or break is missing from a switch case
"noImplicitReturns": true // Require every code path to return
}
}
3. Configure Absolute Paths
Without path configuration, you will soon encounter relative-import hell.
import { UserService } from '../../../modules/user/user.service';
import { AuthGuard } from '../../../../common/guards/auth.guard';
You can configure these imports to use absolute aliases instead:
import { UserService } from '@app/user/user.service';
import { AuthGuard } from '@shared/guards/auth.guard';
The configuration is simple: update tsconfig.json.
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@app/*": ["src/*"],
"@shared/*": ["src/shared/*"],
"@config/*": ["src/config/*"]
}
}
}
If you use Jest, you must also configure path aliases in package.json or jest.config.js.
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1",
"^@config/(.*)$": "<rootDir>/config/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
}
4. Choose a Package Manager
Let us compare the three most common package managers.
npm
- Advantages
- No separate installation required
- Works in every environment
- Disadvantages
- Slow
- Uses a lot of disk space
- Phantom dependencies can occur
yarn
- Advantages
- Faster than npm
- Stable lockfile
- Disadvantages
- Requires a separate installation
- Yarn Berry has a learning curve
- Phantom dependencies can occur
pnpm
- Advantages
- Fast
- Uses disk space efficiently
- Prevents phantom dependencies by using symbolic links
- Disadvantages
- Requires a separate installation
- Occasional compatibility issues
Consider the tradeoffs and configure whichever package manager you prefer.
5. Standardize Code Style
If you have used JavaScript, you have probably heard of Prettier and ESLint.
Honestly, the exact configuration does not matter. What matters is consistency across the team.
Prettier Configuration
First, let us look at .prettierrc.
{
"singleQuote": true, // Use single quotes
"trailingComma": "all", // Always include trailing commas
"semi": true, // Use semicolons
"printWidth": 80, // Maximum line length
"tabWidth": 2, // Tab width
"useTabs": false, // Use spaces
"arrowParens": "always", // Always include parentheses around arrow-function parameters
"endOfLine": "lf" // Standardize line endings on LF
}
Use .prettierignore to exclude files that do not need code formatting.
dist
node_modules
and so on
ESLint Rules
Prettier handles style, while ESLint checks code quality.
The example below assumes the modern JavaScript module format and the ES Module-only eslint.config.mjs file.
import js from '@eslint/js'
export default tseslint.config(
{
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
);
Look up each rule in the official ESLint documentation and configure it to fit your team.
Installing an ESLint Configuration
There are preset configurations with rules already defined. Airbnb is one of the best known.
$ npm i -D eslint-config-airbnb-typescript
Pass the plugin to the configuration function to use Airbnb’s rules.
export default tseslint.config(
// ...
airbnbTypescript,
// ...
);
Resolving Conflicts Between Prettier and ESLint
eslint-config-prettier disables every ESLint rule that conflicts with Prettier.
It is useful for defining a clear boundary between the responsibilities of ESLint and Prettier.
$ npm i -D eslint-config-prettier
Add the following setting to eslint.config.mjs.
export default tseslint.config(
// ...
eslint.configs.recommended,
// ...
);
Using Prettier as a Linter
The eslint-plugin-prettier dependency lets you run Prettier as if it were a linter.
You must remove formatting-related ESLint rules through eslint-config-prettier to avoid errors.
$ npm i -D eslint-plugin-prettier
Then add the following configuration to eslint.config.mjs.
export default tseslint.config(
// ...
eslintPluginPrettierRecommended,
// ...
);
Tip!
You can also apply rules globally in the IDE as you type.
Look up.editorconfig.
6. Configure Husky
It is easy to forget to run the linter, commit and push, and then get an unpleasant surprise in the CI/CD pipeline.
Husky can solve this problem.
Using Git hooks, you can configure Husky to run the linter automatically before each commit.
$ npm i -D husky
$ npm exec husky init
$ npm i -D lint-staged
Tip!
You can also use commitlint to enforce commit-message rules.
Essential NestJS Code
1. Create a ConfigModule
You can build it by following the Nest documentation.
However, mistakes can occur when retrieving environment variables from ConfigService with string keys.
The article below shows how to build a TypedConfigService that provides string autocompletion and catches mistakes at compile time.
- Article: Typed ConfigService in NestJS
2. Configure Logging
If the application exits unexpectedly and you have not saved logs to files, it can be difficult to determine what went wrong.
Winston makes it easy to create file logs.
npm install --save nest-winston winston winston-daily-rotate-file
You can configure log-file retention with winston-daily-rotate-file.
Winston writes file logs asynchronously by default, so its performance overhead is low.
import DailyRotateFile from 'winston-daily-rotate-file';
export enum LogLevel {
ERROR = 'error',
WARN = 'warn',
INFO = 'info',
HTTP = 'http',
VERBOSE = 'verbose',
DEBUG = 'debug',
SILLY = 'silly',
}
const createFileTransports = (type: string, level?: LogLevel): winston.transport => {
return new DailyRotateFile({
level,
datePattern: 'YYYY-MM-DD',
dirname: `${process.cwd()}/logs`, // Storage path
filename: `%DATE%.${type}.log`, // File name
maxFiles: MAX_FILES, // Maximum retention in days
maxSize: MAX_SIZE, // Maximum log-file size
zippedArchive: true, // Whether to compress archives
});
};
You can also define the log format precisely:
import * as winston from 'winston';
const { combine, timestamp, label, printf, colorize } = winston.format;
const logFormat = printf(({ level, message, label, timestamp }) => {
return `${timestamp as string} [${label as string}] ${level}: ${message as string}`;
});
const createConsoleFormat = (applicationName: string) =>
combine(timestamp({ format: TIMESTAMP }), label({ label: applicationName }), colorize({ all: true }), logFormat);
Combine the log format and file settings into a LoggerOptions object, then pass it when creating the WinstonModule.
export function createWinstonConfig(configService: TypedConfigService): LoggerOptions {
const applicationName = configService.get('applicationName');
const nodeEnv = configService.get('nodeEnv');
const logLevel = configService.get('logLevel');
const consoleTransport = new winston.transports.Console({ format: createConsoleFormat(applicationName) });
const fileTransports = [LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO].map(level =>
createFileTransports(level, level),
);
return {
level: logLevel,
format: createFileFormat(applicationName),
defaultMeta: { environment: nodeEnv },
transports: [consoleTransport, ...fileTransports],
exceptionHandlers: [createFileTransports(FILE_NAME_EXCEPTION)],
exitOnError: false,
};
}
Adding a LoggingInterceptor makes the setup even better.
logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(
@Inject(WINSTON_MODULE_NEST_PROVIDER)
private readonly logger: LoggerService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const { method, url, ip } = request;
const requestBody = JSON.stringify(request.body);
this.logger.log({
context: 'HTTP',
message: `[Request] method=${method}, url=${url}, ip=${ip}, body=${requestBody}`,
});
return next.handle().pipe(
tap({
next: () => {
const { statusCode } = response;
this.logger.log({
context: 'HTTP',
message: `[Response] method=${method}, url=${url}: statusCode=${statusCode}`,
});
},
error: (error: Error) => {
this.logger.error({
context: 'HTTP',
message: `[Error] ${method} ${url}: ${error.message}`,
});
},
}),
);
}
}
shared.module.ts
@Module({
imports: [
WinstonModule.forRootAsync({
inject: [TypedConfigService],
useFactory: createWinstonConfig,
}),
],
providers: [
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
],
})
export class SharedModule {}
3. Configure Global Error Handling
You can implement this easily by following Nest’s Exception Filters documentation.
Rather than creating one AllExceptionFilter with a long if-else chain, I think it is better to create several ExceptionFilter implementations and arrange them so the most specific error is caught first. There are two reasons:
- The code is shorter and easier to read.
- You no longer need type casts when handling errors.
Code Examples
custom-exception.filter.ts
@Catch(CustomException)
export class CustomExceptionFilter implements ExceptionFilter {
catch(exception: CustomException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const errorResponse: ErrorResponse = {
statusCode: status,
errorCode: exception.errorCode,
message: exception.message,
timestamp: dayjs().toISOString(),
path: request.url,
};
response.status(status).json(errorResponse);
}
}
http-exception.filter.ts
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const errorResponse: ErrorResponse = {
statusCode: status,
errorCode: status,
message: exception.message,
timestamp: dayjs().toISOString(),
path: request.url,
};
response.status(status).json(errorResponse);
}
}
all-exception.filter.ts
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof Error ? exception.message : 'Internal server error';
const errorResponse: ErrorResponse = {
statusCode,
errorCode: statusCode,
message,
timestamp: dayjs().toISOString(),
path: request.url,
};
response.status(statusCode).json(errorResponse);
}
}
shared.module.ts
@Module({ // Put the most specific filter last
providers: [
{ provide: APP_FILTER, useClass: AllExceptionFilter },
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
{ provide: APP_FILTER, useClass: CustomExceptionFilter },
],
})
export class SharedModule {}
4. Health Checks
NestJS provides this feature for us.
$ npm i @nestjs/terminus
Nest’s Health checks documentation explains it well, so use that as your guide.
Ready to Go!
We began with the foundations for team collaboration.
NVM standardizes the Node version, while Prettier and ESLint handle code style. No matter who commits, Husky performs the checks and Commitlint enforces the commit rules.
We also covered the basic production configuration.
When an error occurs, it is logged in a consistent format and Winston keeps those logs organized. Health checks also make integration with a load balancer or Kubernetes straightforward.
You may also want to consider query logging and metrics collection.
Now it is time to start building the application in earnest!


