DEV Community

Cover image for Setting Up NestJS the Right Way
Aman Kumar Singh
Aman Kumar Singh

Posted on • Originally published at amanksingh.com

Setting Up NestJS the Right Way

Last time we set up the Next.js frontend in apps/web. Now the other half: the NestJS API in apps/api, the service that actually owns the data those shared types describe.

Like the frontend, there's a thirty-second version of this (nest new api, done) and a version that saves you real pain later. NestJS is interesting because it hands you a lot of structure on day one, so the setup work here is mostly about restraint. You turn on the few things every production API genuinely needs, and you resist scaffolding the enterprise patterns you won't touch for months.

This is part four of the Full Stack SaaS Masterclass, continuing from the Next.js app we set up last time. The NestJS app lives in apps/api and shares types with the frontend through the same packages/types.

Modules: the one concept to get right

Everything in NestJS hangs off modules, and getting your mental model right here is most of the battle. A module groups one feature: its controller, its service, and whatever providers that feature needs. The framework wires them together with dependency injection.

The mistake I see most often is at both extremes. Some people stuff the entire app into AppModule and end up with a tangle. Others create a module per file and drown in ceremony. The rule that works: one module per real domain. A UsersModule, an AuthModule, a BillingModule, each owning its own controller and service.

// src/modules/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService], // let other modules use it
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode

Start with one or two feature modules and add more as real domains appear. The structure grows out of the product, not out of a diagram you drew before writing anything.

The setup that matters

Inside apps/api, here's what I configure before writing a single endpoint.

TypeScript strict. Same rule as the frontend, non-negotiable. Turn on strict in tsconfig.json from the first file. A backend that grew up loose is even worse to fix than a frontend, because the bugs hide in data handling where they hurt most.

Global validation. This is the single most important production default in the whole setup. Every request body that reaches your handlers should already be validated and typed. NestJS makes this one block of code in main.ts, paired with DTOs that declare the rules.

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,           // strip properties not in the DTO
      forbidNonWhitelisted: true, // reject unknown properties outright
      transform: true,            // turn plain payloads into DTO instances
    }),
  );

  await app.listen(3000);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode
// src/modules/users/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(2)
  name: string;
}
Enter fullscreen mode Exit fullscreen mode

With whitelist and forbidNonWhitelisted on, a request carrying an extra isAdmin: true field it shouldn't have gets rejected before your code ever sees it. That's a security default, not just a tidiness one.

Config, loaded once. Reaching for process.env all over the codebase is how you end up shipping with an undefined secret and no idea which of forty files reads it. Use @nestjs/config to load and validate environment variables once, then inject them.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { UsersModule } from './modules/users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    UsersModule,
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

We'll do proper environment-variable handling, including validation and typed access, in its own article later in this module. For now, loading it in one place is the habit that matters.

Serve the shared types. This is the monorepo paying off on the backend. Your controller returns the same User type the frontend imports, so the contract between the two sides is a single definition rather than two that drift.

// src/modules/users/users.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import type { User } from '@my-saas/types';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Get(':id')
  getOne(@Param('id') id: string): Promise<User> {
    return this.users.findById(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

If the backend changes what a User is, both this file and the frontend that consumes it stop compiling until they're updated. The mismatch becomes a build error instead of a production bug.

Path aliases. Same as the frontend. Set up @/ so imports stay readable and survive file moves.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  }
}
Enter fullscreen mode Exit fullscreen mode

A sane module structure

Here's the layout I use inside apps/api. It scales from three endpoints to three hundred without a reorganization.

apps/api/src/
├── modules/          # one folder per feature (users, auth, billing)
│   └── users/
│       ├── dto/
│       ├── users.controller.ts
│       ├── users.service.ts
│       └── users.module.ts
├── common/           # cross-cutting: guards, filters, interceptors
├── config/           # config schema and helpers
├── app.module.ts
└── main.ts
Enter fullscreen mode Exit fullscreen mode

The common folder is where shared infrastructure lives: an exception filter that shapes error responses consistently, an auth guard, a logging interceptor. Keeping those out of feature modules stops the same boilerplate from being copy-pasted into every corner of the app.

What not to build yet

NestJS documents a lot of advanced machinery, and the temptation on day one is to wire up all of it. Resist that, the same way we resisted extra libraries on the frontend.

You don't need microservices. Start as a modular monolith and split a service out later if a boundary genuinely demands it, which for most SaaS products never happens. You don't need CQRS or event sourcing to save a user's name to a table. You don't need a queue until you have work that runs outside the request cycle, and when you do, BullMQ slots in cleanly later in the series. You don't need custom decorators everywhere; the built-in ones cover almost everything.

Every one of those patterns solves a real problem you probably don't have yet. Add them the day the problem shows up, with the context to use them well, rather than guessing now.

Next in the series, we give this API something to talk to: PostgreSQL, and how to model and connect it in a way you won't regret three migrations from now.

Key takeaways

  • Organize the app around one module per real domain; avoid both the god-module and the module-per-file extremes.
  • Turn on TypeScript strict from the first file, exactly as on the frontend.
  • A global ValidationPipe with whitelist and DTOs is the most important production default, and it doubles as a security control.
  • Load environment variables once with @nestjs/config instead of scattering process.env through the code.
  • Return the shared monorepo types from your controllers so the API contract can't silently drift from the frontend.

FAQ

How should I structure a NestJS project?
Group code by feature into modules, one per real domain such as users, auth, or billing, each with its own controller and service. Keep cross-cutting infrastructure like guards and exception filters in a shared common folder. Let new modules appear as new domains do.

What's the most important thing to configure in a new NestJS app?
A global ValidationPipe with whitelist: true, paired with DTOs using class-validator. It guarantees every request body is validated and stripped of unexpected fields before it reaches your handlers, which is both a correctness and a security win.

Should I use NestJS microservices from the start?
No. Begin with a modular monolith. Microservices add deployment and operational complexity you haven't earned at zero users. You can extract a service later if a clear boundary demands it.

How do I share types between NestJS and my frontend?
Put the types in a shared monorepo package and import them on both sides. When a controller returns that shared type, any change to it forces both the API and the frontend to update, so the contract stays in sync.

Why use @nestjs/config instead of process.env directly?
It loads and validates environment variables in one place and lets you inject typed config where you need it. Reading process.env throughout the codebase scatters that dependency and makes missing or misnamed variables hard to catch.

Further reading


About the Author

Hi, I'm Aman Singh — Senior Full Stack Engineer specializing in scalable SaaS products, distributed systems, cloud architecture, and AI-powered applications.

I write about System Design, Full Stack Engineering, Distributed Systems, Redis, PostgreSQL, AWS, Node.js, and NestJS.


Top comments (0)