NestJS – Setting Up a Project

July 13, 20268 min readUpdated 9/5/2026

Starting a Nest project takes one command. Understanding what that command produced — and which of the settings it wrote are load-bearing rather than taste — takes a little longer, and it is the difference between a project that works and one that fails at startup with an error nobody can read.

Creating the project

npm i -g @nestjs/cli
nest new contractor-nestjs-backend

The CLI asks for a package manager and then scaffolds a working application: a module, a controller, a service, a bootstrap file, TypeScript configuration, and a test setup. Run it:

npm run start:dev

Nest starts on port 3000 in watch mode. The startup log is worth reading rather than scrolling past, because it is a live description of what the framework found:

[NestFactory] Starting Nest application...
[InstanceLoader] AppModule dependencies initialized
[InstanceLoader] TypeOrmCoreModule dependencies initialized
[RoutesResolver] AuthController {/api/v1/auth}:
[RouterExplorer] Mapped {/api/v1/auth/register, POST} route
[RouterExplorer] Mapped {/api/v1/auth/login, POST} route
[RouterExplorer] Mapped {/api/v1/auth/me, GET} route
[NestApplication] Nest application successfully started

Every route Nest believes exists is listed. When a route 404s that you are sure you wrote, this log settles it in two seconds — either it is not there, in which case the controller is not in a module, or it is there at a path you did not expect.

What is in the folder

The scaffold is deliberately small. What matters is the shape it suggests, because it is the shape every Nest codebase ends up with:

src/
  main.ts              bootstrap: create the app, apply globals, listen
  app.module.ts        the root module - composition only
  common/              guards, pipes, interceptors, filters, decorators
  config/              every environment variable, parsed once
  database/            entities, migrations, the DataSource
  auth/                one folder per area of the domain
  projects/            controller + service + dto/ + module
  quotes/
test/
  rules.e2e-spec.ts    tests that boot the whole application

Two conventions in there are worth adopting on purpose. common/ holds the pieces of the request pipeline that are not specific to any one feature — everything the middle third of this track is about. And each domain folder is self-contained: controller, service, DTOs and the module that binds them, so a feature is one directory rather than four parallel trees you have to navigate in step.

Nest does not enforce any of this. The CLI generates into it, which is a strong enough nudge that most projects look alike — and that sameness is a real benefit when you join one.

What the CLI gives you afterwards

The scaffolding command is a small part of it. The generator is the part you keep using:

nest g module projects
nest g controller projects
nest g service projects

# or all three plus a DTO folder, wired together
nest g resource projects

These are not just file templates. nest g controller projects also edits projects.module.ts to add the controller to its controllers array — which is the step people forget when creating files by hand, and the reason their new route does not appear in the startup log.

The generated configuration

nest-cli.json is small and rarely needs changing:

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true
  }
}

deleteOutDir earns its place. Without it, a file you delete from src/ leaves its compiled output in dist/ forever, and since the running app loads dist/, a module you deleted keeps running.

main.ts is where the application is assembled

Everything that applies to the whole application, rather than to one route, is applied in the bootstrap function:

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule)
  const config = app.get(ConfigService<AppConfig>)
  // ...
  const port = config.getOrThrow<number>('port')
  await app.listen(port)
  // ...
}

await bootstrap()

NestFactory.create does the expensive work: it walks the module graph from the root, instantiates every provider in dependency order, and builds the routing table. All of that happens once, at startup — which is why a dependency that cannot be resolved is a startup failure rather than a runtime one, and why the per-request cost of injection is close to nothing.

app.get(...) is the escape hatch for reaching into the container from outside it. It is the right tool here, in bootstrap code that is not itself a provider, and the wrong tool almost everywhere else — reaching for it inside a service means doing by hand what the constructor would have done for you.

The tsconfig flags that are not optional

Most of a generated tsconfig.json is preference. Two entries are not:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

experimentalDecorators is what makes @Controller and @Injectable legal syntax. emitDecoratorMetadata is the one worth understanding, because it is why dependency injection works at all.

TypeScript types are erased at compile time. So when Nest sees this constructor, there is nothing left at runtime to say what authService is:

constructor(private readonly authService: AuthService) {}

With emitDecoratorMetadata on, the compiler emits an extra call alongside the class recording the constructor's parameter types as real runtime values. Nest reads that list and resolves each entry against its container. Turn the flag off and every injection fails with a message about being unable to resolve a dependency at index 0 — the type information the container needed was never emitted.

The full configuration this project uses is worth reading once:

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "resolvePackageJsonExports": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2023",
    "sourceMap": true,
    "outDir": "./dist",
    "incremental": true,
    "skipLibCheck": true,
    "strict": true,
    "strictPropertyInitialization": false,
    "types": ["vitest/globals", "node"]
  }
}

strictPropertyInitialization: false deserves a note, because turning off a strict check normally deserves suspicion. An entity or DTO class declares email: string with no initialiser, because the value is assigned by TypeORM or class-transformer rather than by a constructor. With the check on, every one of those properties needs a ! suffix, on every field of every entity, to assert something the framework guarantees. This is the rare case where the check is measuring the wrong thing.

The ESM trap

This project sets "type": "module" in package.json and moduleResolution: nodenext above. That combination is increasingly the default, and it has one consequence that catches everybody exactly once:

import { AppModule } from './app.module.js'
import { TrimPipe } from './common/pipes/trim.pipe.js'
import type { AppConfig } from './config/configuration.js'

Every relative import ends in .js, even though the file sitting next to it is app.module.ts. That is correct, and it is not a workaround.

Node's ESM resolver does not guess extensions the way CommonJS did. An import specifier has to name the file that will exist at runtime — and at runtime you are running compiled JavaScript out of dist/, so the file is app.module.js. TypeScript understands this and resolves the .js specifier back to the .ts source when type-checking.

Leave the extension off and nothing complains. It type-checks. It builds. Then it dies on startup:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/app/dist/app.module' imported from /app/dist/main.js
Did you mean to import "./app.module.js"?

The message is unusually helpful. The confusion is that it appears after a clean build, in a file you did not touch, and only when you run the compiled output — so it never shows up while you are editing.

Two more ESM consequences

The same choice shapes two things you will meet later in this track. First, TypeORM entities and migrations are listed as imported classes rather than glob patterns:

export const ENTITIES = [
  User,
  ContractorProfile,
  ServiceCategory,
  PortfolioImage,
  Project,
  Quote,
  Review,
]

Every TypeORM tutorial shows a glob like dist/**/*.entity.js. Globs do not resolve reliably under ESM, and the failure is not an error — it is an empty entity list, which surfaces much later as "No metadata for User was found". Explicit imports also mean a renamed file breaks the build rather than the app.

Second, the TypeORM CLI runs against dist/:

{
  "scripts": {
    "build": "nest build",
    "start:dev": "nest start --watch",
    "start:prod": "node dist/main",
    "migration:run": "npm run build && node ./node_modules/typeorm/cli.js migration:run -d dist/database/data-source.js",
    "test": "vitest run",
    "test:e2e": "vitest run --config ./vitest.config.e2e.ts"
  }
}

Note npm run build && at the front of the migration script. There is no working ts-node loader for ESM plus decorators plus emitDecoratorMetadata, and without that metadata TypeORM cannot read the entities at all. Building first is the price. It is also why npm run migration:run feels slow.

Which build runs when

There are two tsconfig files, and the split is not decoration:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": "./src"
  },
  "include": ["src"],
  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

tsconfig.build.json is what nest build uses, and it excludes the tests. Without that exclusion rootDir would have to widen to cover test/, every compiled path would shift down a directory, and node dist/main would stop finding anything. Your editor and tsc --noEmit keep using the wider tsconfig.json, so test files are still type-checked — they are simply not shipped.

Three startup errors and what they mean

Almost every failure in a fresh Nest project is one of these, and each has a single cause.

ERR_MODULE_NOT_FOUND, naming a file that plainly exists. A relative import is missing its .js extension. The message usually suggests the fix itself.

Nest can't resolve dependencies of the X (?). Either the provider is not in any module's providers array, or it is in one that has not exported it. The message names the context it looked in, which tells you which of the two it is.

A route that 404s and is absent from the startup log. The controller is not in a module's controllers array — creating the file is not enough. This is what nest g controller does for you and doing it by hand does not.

Configuration lives in the environment

The last piece of a working setup is an .env.example that documents every variable the app reads:

CONTRACTOR_PORT=3001
CONTRACTOR_DB_HOST=localhost
CONTRACTOR_DB_PORT=5434
CONTRACTOR_DB_USER=contractor
CONTRACTOR_DB_NAME=contractor
CONTRACTOR_JWT_EXPIRES_IN=7d
CONTRACTOR_UPLOAD_MAX_BYTES=5242880
CONTRACTOR_CORS_ORIGINS=http://localhost:5177

In this project every one of those values is also a default in code, so a fresh clone runs with no .env file to create first. That is a deliberate choice and lesson 14 covers what it costs and what it buys.

One habit worth forming now: keep npm run start:dev running in a terminal you can see. Nest's watch mode restarts on every save and reprints the route table, so a controller that stops being registered, a provider that stops resolving, or a path you typed wrong shows up within a second of the save that caused it — rather than the next time you happen to call the endpoint. Almost every error in this lesson is one you find immediately or half an hour later, and the difference is whether that log is on screen.

With a project that starts, the next thing to understand is how its pieces are grouped — which is modules.