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-backendThe 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:devNest 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 startedEvery 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 applicationTwo 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 projectsThese 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:5177In 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.