Set Up a Production Node.js Project with TypeScript

Setting up TypeScript in a Node.js project isn’t difficult. The difficult part is deciding which setup you actually want to live with six months later. A lot of tutorials stop after installing TypeScript, creating a tsconfig.json, and compiling one file. That proves TypeScript works, but it doesn’t answer the questions I care about when setting…

Nodejs TypeScript
guide.md READY

Setting up TypeScript in a Node.js project isn’t difficult.

The difficult part is deciding which setup you actually want to live with six months later.

A lot of tutorials stop after installing TypeScript, creating a tsconfig.json, and compiling one file. That proves TypeScript works, but it doesn’t answer the questions I care about when setting up a real backend project:

  • Should the project use ESM or CommonJS?
  • Should TypeScript run directly during development?
  • Should production run .ts files or compiled JavaScript?
  • Which tsconfig options are actually useful?
  • How should environment variables be handled?
  • Where does TypeScript stop protecting us?
  • How should the application be built and deployed?

This guide shows the Node.js TypeScript setup I would use as a starting point for a modern backend application.

I’m deliberately keeping the framework out of the first part. The same foundation can sit underneath Fastify, Express, Hono, NestJS, workers, CLI applications or background services.

What I Want from a Node.js TypeScript Setup

Before installing packages, I like to decide what the setup should give me.

For a normal backend project, my list is fairly simple:

  • fast development startup
  • automatic restart while coding
  • strict TypeScript checking
  • modern ES modules
  • predictable production builds
  • useful stack traces
  • environment configuration outside the source code
  • no unnecessary build complexity

That last point matters.

I don’t want Babel, three loaders and four build plugins just because the application happens to use TypeScript.

For most Node.js backend projects, TypeScript’s own compiler plus a good development runner is enough.

Use a Supported Node.js Version First

Before configuring TypeScript, make sure the project is using a supported Node.js release.

At the time I’m updating this article, Node.js 24 is an LTS release. For production applications, Node.js recommends using an Active LTS or Maintenance LTS release rather than an unsupported version.

You can check the current status on the official Node.js release page.

node --version
npm --version

I also like defining the expected runtime in package.json so the requirement isn’t hidden in a README that nobody reads.

{
  "engines": {
    "node": ">=24"
  }
}

Create the Node.js TypeScript Project

Start with an empty project:

mkdir node-typescript-api
cd node-typescript-api

npm init -y

Now install TypeScript, Node’s type definitions and tsx:

npm install -D typescript tsx @types/node

I use tsx during development because it lets me execute TypeScript directly without adding a separate compile-and-run cycle every time I change a file.

Interestingly, Node.js itself can now execute a useful subset of TypeScript directly too. I’ll come back to that because it changes the conversation around TypeScript runners, but it doesn’t remove the need for type checking.

Use ES Modules for a New Project

For a new backend project, I generally prefer ECMAScript modules unless I have a dependency or existing codebase that gives me a good reason to stay on CommonJS.

Add this to package.json:

{
  "type": "module"
}

Now the project uses standard import and export syntax:

import { createServer } from 'node:http';

export function startServer() {
  // ...
}

The important thing is consistency.

Mixing require(), ESM imports, incompatible compiler options and package types is where a simple TypeScript setup starts becoming frustrating.

Configure TypeScript for Modern Node.js

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",

    "outDir": "./dist",

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,

    "verbatimModuleSyntax": true,

    "sourceMap": true,

    "types": ["node"],

    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

I prefer starting fairly strict rather than enabling strictness halfway through a large project.

The TypeScript documentation currently recommends nodenext for modern Node.js projects. It understands Node’s ESM and CommonJS behaviour instead of pretending the application is running inside a browser bundler.

You can read the current guidance in the TypeScript module configuration documentation.

Why strict: true?

This is one setting I rarely want to disable.

{
  "compilerOptions": {
    "strict": true
  }
}

Without strict checking, it’s surprisingly easy to write TypeScript that looks safe but still allows values such as null or undefined to move through the application unchecked.

If I’m adding TypeScript to a legacy JavaScript project, I may migrate gradually. For a new project, I prefer beginning strict.

Why noUncheckedIndexedAccess?

Consider this:

const users = ['Jay', 'Sam'];

const user = users[10];

JavaScript returns undefined.

With noUncheckedIndexedAccess, TypeScript makes that possibility visible instead of assuming every index lookup succeeds.

Why exactOptionalPropertyTypes?

This makes optional properties behave more precisely.

type UpdateUser = {
  displayName?: string;
};

There is a real semantic difference between a property not existing and a property explicitly containing undefined. This option helps TypeScript preserve that distinction.

A Small ESM Detail That Causes Plenty of Confusion

With Node’s ESM rules and NodeNext, relative imports should represent what Node will actually execute after compilation.

Suppose we have:

src/
├── config.ts
└── server.ts

Inside server.ts, I’ll write:

import { config } from './config.js';

Yes, the source file is config.ts.

The import uses .js because that is the file Node will load from the compiled output.

This feels strange the first time you encounter it, but once the project consistently follows Node’s actual module rules, imports become much more predictable.

Add Useful Development Scripts

My basic scripts would look something like this:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "start": "node --enable-source-maps dist/server.js"
  }
}

Each command has one job.

  • npm run dev starts TypeScript directly and restarts when files change.
  • npm run typecheck checks the project without generating output.
  • npm run build creates production JavaScript.
  • npm start runs the compiled application.

I like keeping type checking as a separate command because it is easy to run in CI before deploying anything.

npm run typecheck
npm run build

But Node.js Can Run TypeScript Directly Now

This is one of the biggest changes since the original version of this article was written.

Modern Node.js has built-in TypeScript support through type stripping.

For TypeScript syntax that can simply be erased, Node can run a .ts file directly:

node script.ts

That’s genuinely useful.

For small scripts, build utilities and some lightweight applications, I would absolutely consider it.

But there is an important detail: Node’s built-in type stripping does not type-check your application.

Node also intentionally ignores tsconfig.json features that would require TypeScript compilation behaviour.

The Node.js documentation explains the distinction between its lightweight built-in support and full TypeScript support on the official TypeScript-in-Node.js documentation.

So I think of it this way:

Use caseApproach I Would Consider
Small utility scriptNode’s native TypeScript support
Build scriptNative TypeScript or tsx
Backend application during developmenttsx + TypeScript type checking
Production backendCompile with TypeScript and run JavaScript
Published libraryExplicit build and declaration strategy

I don’t see native TypeScript support as a reason to stop type-checking a serious application.

TypeScript without the type checker would be missing a fairly important part of TypeScript.

Create a Simple Server

Now let’s prove the setup works without introducing a framework yet.

Create:

src/server.ts
import { createServer } from 'node:http';

const port = 3000;

const server = createServer((request, response) => {
  if (request.url === '/health') {
    response.writeHead(200, {
      'content-type': 'application/json'
    });

    response.end(
      JSON.stringify({
        status: 'ok'
      })
    );

    return;
  }

  response.writeHead(404, {
    'content-type': 'application/json'
  });

  response.end(
    JSON.stringify({
      error: 'Not found'
    })
  );
});

server.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

Start development mode:

npm run dev

Then open:

http://localhost:3000/health

You should receive:

{
  "status": "ok"
}

Keep Configuration Outside the Application

Hardcoding ports, database URLs, API keys and environment-specific behaviour into TypeScript source files doesn’t scale well.

Create an .env file:

PORT=3000
NODE_ENV=development

Current Node.js releases have built-in support for loading environment files, so a separate dotenv dependency is no longer necessary for the simple case.

For a compiled application we can run:

node --env-file=.env dist/server.js

Node’s .env support is documented in the official environment variables documentation.

And of course:

.env

should normally be excluded from Git.

node_modules/
dist/
.env
*.log

TypeScript Does Not Validate Environment Variables

This is an important distinction.

I can write:

const port = Number(process.env.PORT);

and TypeScript will happily compile it.

But what if someone deploys:

PORT=hello

Now Number(process.env.PORT) becomes NaN.

Static types cannot verify data that only exists when the application starts.

I prefer validating configuration during startup and failing immediately if something important is missing or invalid.

For example:

function getPort(): number {
  const value = process.env.PORT ?? '3000';
  const port = Number(value);

  if (!Number.isInteger(port) || port < 1) {
    throw new Error(
      `Invalid PORT environment variable: ${value}`
    );
  }

  return port;
}

export const config = {
  port: getPort(),
  nodeEnv: process.env.NODE_ENV ?? 'development'
};

Now a bad deployment fails during startup instead of behaving strangely later.

TypeScript Does Not Validate API Requests Either

This is one of the biggest mistakes I try to avoid in TypeScript backend projects.

Imagine an API expects:

type CreateUserRequest = {
  name: string;
  email: string;
};

That type protects code we control.

It does not force an HTTP client to send that shape.

A client can still send:

{
  "name": 123,
  "email": false
}

or:

{
  "completely": "different"
}

That is runtime data. It must be validated at runtime.

For this reason, I often use a schema validation library at application boundaries.

One option is Zod:

npm install zod
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1),
  email: z.email()
});

type CreateUserRequest =
  z.infer<typeof createUserSchema>;

const result = createUserSchema.safeParse(input);

if (!result.success) {
  // Return 400
}

This is where TypeScript becomes much more useful: runtime validation confirms the input, and TypeScript then carries that validated shape through the rest of the code.

I use the same idea for HTTP request bodies, environment variables, queue messages and third-party API responses.

Don’t Turn Every JavaScript Object into an Interface

Adding TypeScript does not mean every five-line object needs a separately named interface.

I prefer types where they clarify a boundary or important domain concept.

For example:

type OrderStatus =
  | 'pending'
  | 'paid'
  | 'cancelled';

type Order = {
  id: string;
  customerId: string;
  status: OrderStatus;
  total: number;
};

That tells me something meaningful about the business.

Compare it with this:

interface ConsoleLogArguments {
  message: string;
}

Types should reduce ambiguity, not create ceremony.

Avoid any as an Escape Hatch

If a project contains this everywhere:

function processData(data: any): any {
  // ...
}

we are paying the TypeScript cost without getting much of the TypeScript benefit.

When I genuinely don’t know the type of external data yet, I prefer unknown.

function processData(data: unknown) {
  if (
    typeof data === 'object' &&
    data !== null
  ) {
    // Narrow it safely
  }
}

unknown forces me to prove what the value is before using it.

any tells TypeScript to stop asking questions.

A Project Structure I Can Grow with

For a small API, I don’t start with twenty directories.

A simple structure might be:

node-typescript-api/
│
├── src/
│   ├── config/
│   │   └── config.ts
│   │
│   ├── modules/
│   │   └── users/
│   │       ├── user.controller.ts
│   │       ├── user.service.ts
│   │       └── user.schema.ts
│   │
│   ├── shared/
│   │   └── errors/
│   │
│   └── server.ts
│
├── .env
├── .gitignore
├── package.json
├── tsconfig.json
└── package-lock.json

I prefer organising larger backend applications around business features rather than creating one giant controllers directory, one giant services directory and one giant models directory.

As the codebase grows, keeping related functionality together usually makes navigation easier.

Sharing Types Between Frontend and Backend

The original version of this article was mainly about TypeScript “bridging frontend and backend.”

There is value in that idea, but I would be careful about what gets shared.

Sharing an API contract can be useful.

export type UserResponse = {
  id: string;
  name: string;
  email: string;
};

Sharing your entire database model with the frontend is usually not what I want.

A database entity may contain fields such as:

passwordHash
refreshTokenHash
internalNotes
deletedAt
billingCustomerId

Those details belong to the backend.

I prefer thinking in terms of explicit contracts:

Database model
      |
      v
Backend domain
      |
      v
API response contract
      |
      v
Frontend

The fact that both sides use TypeScript should make contracts easier to maintain. It shouldn’t erase the boundary between them.

Source Maps Make Production Errors Easier to Read

Our tsconfig.json enables:

"sourceMap": true

and our production command uses:

node --enable-source-maps dist/server.js

Without useful source mapping, an error may point at the generated JavaScript in dist/.

With source maps, stack traces can point back to the TypeScript source that the developer actually works with.

That becomes much more valuable once an application is running outside your local machine.

Handle Shutdown Properly

A production process should also have a clean way to stop.

If the application receives a termination signal during deployment, I don’t want it to simply disappear while requests or database operations are still running.

function shutdown(signal: string) {
  console.log(`${signal} received`);

  server.close(error => {
    if (error) {
      console.error('Shutdown failed', error);
      process.exit(1);
    }

    console.log('Server stopped');
    process.exit(0);
  });
}

process.on('SIGTERM', () => {
  shutdown('SIGTERM');
});

process.on('SIGINT', () => {
  shutdown('SIGINT');
});

In a real application, shutdown may also include:

  • closing database pools
  • stopping message consumers
  • finishing active requests
  • closing Redis connections
  • flushing important telemetry

I discuss this kind of failure handling in more detail in my production Node.js error handling guide.

Build Before You Deploy

Before deployment, I want these commands to succeed:

npm ci

npm run typecheck
npm run build

The production output then lives in:

dist/

and Node runs the generated JavaScript:

node --enable-source-maps dist/server.js

I like this separation because the production runtime does not need to compile the application on startup.

Dockerizing the TypeScript Application

If I’m deploying the application as a container, I prefer a multi-stage build.

FROM node:24-alpine AS build

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src

RUN npm run build


FROM node:24-alpine AS production

WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev

COPY --from=build /app/dist ./dist

EXPOSE 3000

CMD ["node", "--enable-source-maps", "dist/server.js"]

The build stage contains TypeScript and development dependencies.

The final stage gets the production dependencies and compiled application.

I would also add a .dockerignore:

node_modules
dist
.git
.env
*.log

Mistakes I Try to Avoid in Node.js TypeScript Projects

The configuration itself is only half of the job. A TypeScript project can still become difficult to maintain if a few habits creep in.

1. Using any everywhere

It removes exactly the checks I introduced TypeScript to provide.

2. Trusting external data because it has a TypeScript type

HTTP requests, environment variables, queue messages and third-party API responses still need runtime validation.

3. Mixing module systems without a reason

A project containing a random mix of CommonJS and ESM configuration is usually harder to understand than one that makes an explicit choice.

4. Using path aliases without considering Node.js

Aliases can look convenient:

import { config } from '@/config';

but TypeScript understanding a path does not automatically mean Node understands that path at runtime.

I don’t introduce aliases until I know exactly how the runtime or build process will resolve them.

5. Sharing internal database types with every layer

Database models, domain objects and public API contracts serve different purposes. I prefer keeping those boundaries intentional.

6. Treating a successful TypeScript build as proof the application works

The compiler can catch an impressive number of mistakes.

It cannot tell me that PostgreSQL is unavailable, an API returned invalid data, a payment provider timed out, or my business rule is wrong.

Types, validation, tests, logging and monitoring solve different problems.

My Production Node.js TypeScript Checklist

Before I consider the foundation ready for a real project, I want most of these in place:

  • supported Node.js LTS version
  • consistent ESM or CommonJS strategy
  • strict TypeScript configuration
  • separate type-check command
  • fast development runner
  • predictable production build
  • environment variables outside source control
  • runtime configuration validation
  • runtime request validation
  • useful source maps
  • centralized error handling
  • graceful shutdown
  • CI type checking before deployment
  • production dependencies separated from development dependencies

Should You Run TypeScript Directly in Production?

Now that Node.js itself can execute TypeScript, this is a fair question.

For a small internal script, I wouldn’t object to it.

For a normal backend application, I still prefer an explicit build step today.

My reasoning is simple:

  • CI proves that type checking succeeds.
  • The deployment artifact is predictable JavaScript.
  • Production does not depend on development tooling.
  • I can see exactly what is being deployed.

That may continue to evolve as Node’s native TypeScript support matures, so this is an area I would review periodically rather than treating today’s setup as a permanent rule.

TypeScript 6.0 and What Comes Next

TypeScript itself is also changing.

TypeScript 6.0 is a transition release preparing the ecosystem for TypeScript 7.0 and the compiler’s native-port work.

That is another reason I avoid copying old tsconfig.json files from tutorials without checking what every option is doing.

Configuration that was common several years ago may now be unnecessary, deprecated or actively working against modern Node.js module behaviour.

The official TypeScript 6.0 release notes are worth checking when upgrading an existing project.

Final Thoughts

The part I like most about TypeScript isn’t that it lets me put types next to JavaScript variables.

It’s that a good type system makes assumptions visible.

Is this value optional?

Can this function fail?

What does an order actually look like?

Which states are valid?

What data is allowed to cross this API boundary?

Those questions matter much more to me than simply converting every .js file to .ts.

A good Node.js TypeScript setup should stay out of the way while making those decisions safer.

For most new backend projects, my starting point is straightforward: supported Node.js LTS, ESM, NodeNext, strict TypeScript, tsx for development, runtime validation at application boundaries, and compiled JavaScript for production.

Then I add complexity only when the project gives me a reason.

Continue Learning

If you’re building a Node.js backend with TypeScript, these guides continue from the same production-focused approach:

Share this guideLinkedInPost

ARTICLE TOOLKIT

Save or share this guide

Keep the reference nearby or send it to a teammate solving the same problem.

Share this guideLinkedInPost

QUALITY NOTE

Written from practical development experience and reviewed for clarity. Found an outdated step?

Report a correction →

Jaydip Barad

WRITTEN BY

Jaydip Barad

Senior full-stack developer sharing production-tested lessons from 14+ years of building backend systems, WordPress platforms and modern JavaScript applications.

Node.jsTypeScriptWordPressArchitecture
Next guide

THE PRACTICAL DEVELOPER LETTER

Get useful engineering lessons without the noise.

New tutorials, architecture notes and tools worth knowing—delivered occasionally.




    Occasional practical tutorials. Unsubscribe any time.