You have reached the beginning of time!

Building Scalable APIs with Node.js and TypeScript

If you've ever tried building an API with plain JavaScript and found yourself drowning in bugs, weird errors, or spaghetti code, yo, you're not alone. That’s why so many devs are leveling up their backend game by mixing Node.js with TypeScript. It's like going from playing Minecraft in creative mode to building actual skyscrapers: more control, better structure, and way less chaos.

In this post, we’re gonna break down how to build scalable APIs using Node.js and TypeScript without overcomplicating things. Whether you’re a weekend hacker or just getting into backend development, this guide will show you how to keep your code clean, organized, and ready to grow.

But hold up, before you dive into the building, let’s talk performance and security. If you're serious about scaling your API and want real-time performance insights, secure observability, and runtime protection without drowning in logs, check out N|Solid. It's like having superpowers for your Node.js app, especially when you're heading into production. 💪

Let’s get into it.

Why TypeScript with Node.js?

Adding TypeScript to your Node.js workflow brings significant benefits beyond plain JavaScript, especially as projects grow:

  • Static Typing: TypeScript enforces type definitions, catching common bugs like undefined is not a function at compile time, leading to more reliable code and fewer runtime errors.
  • Enhanced Developer Experience: Features like IntelliSense provide autocomplete and real-time feedback, making coding faster and more intuitive.
  • Improved Scalability: Type contracts, clear interfaces, and modular code make large projects easier to manage, onboard new team members, and maintain over time.
  • Modern Tooling Compatibility: TypeScript integrates seamlessly with popular tools like ESLint, Prettier, and testing frameworks, streamlining your development environment.

Project Setup

Before we start slinging code, let’s get our dev environment locked and loaded. Setting up a clean, scalable project structure is key when you're trying to avoid future headaches.

What You’ll Need

Here’s the tech stack we’re rolling with:

  • **Node.js **– of course
  • TypeScript – static typing goodness
  • Express.js – lightweight web framework
  • ts-node – to run TypeScript directly

Make sure you’ve got Node.js installed. Then let’s kick things off:

Step 1: Initialize the Project

mkdir scalable-api-ts
cd scalable-api-ts
npm init -y

Step 2: Install Dependencies

npm install express
npm install -D typescript ts-node @types/node @types/express

This installs Express, along with TypeScript and all the type defs we’ll need for a smoother experience.

Step 3: Create a TypeScript Config

npx tsc --init

Now tweak your tsconfig.json for a solid dev experience:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "rootDir": "src",
    "outDir": "dist",
    "esModuleInterop": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  }
}

Step 4: Set Up Folder Structure

Here’s a basic layout to start with:

scalable-api-ts/
├── src/
│   ├── routes/
│   ├── controllers/
│   ├── services/
│   ├── index.ts
├── dist/
├── package.json
├── tsconfig.json

This structure separates concerns early—making things way easier to scale and maintain.

Step 5: Add Start Scripts

Update your package.json with these scripts:

"scripts": {
  "dev": "nodemon src/index.ts",
  "build": "tsc",
  "start": "node dist/index.js"
}

Now you can run your app in dev mode with npm run dev. Boom, you're set.

Creating a Simple API with Express + TypeScript

Now that we’ve got the project set up, it’s time to build something real. Let’s create a basic Express API that’s fully type-safe and cleanly structured.

Step 1: Create the Entry Point

In src/index.ts, start with the barebones server setup:

import express, { Application, Request, Response } from 'express';

const app: Application = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.get('/', (req: Request, res: Response) => {
  res.send('API is up and running! 🚀');
});

app.listen(PORT, () => {
  console.log(`Server is listening on port ${PORT}`);
});

Step 2: Add Your First Route

Let’s add a simple route that returns a list of users. Create a file: src/routes/userRoutes.ts

import { Router } from 'express';
import { getUsers } from '../controllers/userController';

const router = Router();

router.get('/users', getUsers);

export default router;

Step 3: Build a Type-Safe Controller

Now, in src/controllers/userController.ts:

import { Request, Response } from 'express';

interface User {
  id: number;
  name: string;
  email: string;
}

export const getUsers = (req: Request, res: Response) => {
  const users: User[] = [
    { id: 1, name: 'Jane Doe', email: 'jane@example.com' },
    { id: 2, name: 'John Smith', email: 'john@example.com' }
  ];

  res.json(users);
};

Step 4: Wire Up the Route

Go back to src/index.ts and plug in the user routes:

import userRoutes from './routes/userRoutes';
app.use('/api', userRoutes);

Test It Out

Run your dev server:

npm run dev

Then visit: http://localhost:3000/api/users
You should see your JSON array of users!

Scaling the Architecture

Okay, we’ve got a basic API running—but what happens when your app grows? More routes, more logic, more chaos. To keep things manageable and scalable, you’ve gotta start thinking in layers.

Let’s break it down.

Modularize Everything

Split your code into these core folders:

src/
├── routes/        // define routes & route groups
├── controllers/   // handle request logic
├── services/      // business logic layer
├── models/        // data structures or ORM models
├── middlewares/   // custom middleware (auth, error handling, etc)
├── utils/         // helper functions

This structure keeps things clean and helps you follow separation of concerns, which is just a fancy way of saying “put stuff where it belongs.”


Add a Service Layer

Let’s move logic out of the controller and into a userService.ts:

src/services/userService.ts
interface User {
  id: number;
  name: string;
  email: string;
}

export const getAllUsers = (): User[] => {
  return [
    { id: 1, name: 'Jane Doe', email: 'jane@example.com' },
    { id: 2, name: 'John Smith', email: 'john@example.com' }
  ];
};

Now update your controller to use the service:

src/controllers/userController.ts
import { Request, Response } from 'express';
import { getAllUsers } from '../services/userService';

export const getUsers = (req: Request, res: Response) => {
  const users = getAllUsers();
  res.json(users);
};

Boom—logic is separated. 🎉

Keep It DRY with Reusable Middleware

Start creating middlewares like logger.ts, auth.ts, or errorHandler.ts in a middlewares/ folder. These can be used across multiple routes to keep your code DRY (Don't Repeat Yourself).

Pro Tips for Scaling:

  • Use interfaces or types for everything—request bodies, responses, database models.
  • Keep routes thin, controllers medium, and services thick. (Like tacos 🌮)
  • Group related files together to keep context tight—e.g. user.routes.ts, user.controller.ts, user.service.ts.

Middleware & Error Handling

Middlewares in Express are like the bouncers of your app—they run before your route handlers and can do all sorts of useful stuff like logging, validating, authenticating, and error-catching.

Let’s break down how to create clean, reusable middleware and a centralized error handler.


What’s Middleware, Really?

Think of middleware as a function that has access to:

(req, res, next)

It does something before the request reaches your controller, and either ends the request or calls next() to pass control.


Example: Logger Middleware

Let’s create a simple request logger.

src/middlewares/logger.ts
import { Request, Response, NextFunction } from 'express';

export const logger = (req: Request, res: Response, next: NextFunction) => {
  console.log(`${req.method} ${req.path}`);
  next();
};

Then use it in index.ts:

import { logger } from './middlewares/logger';

app.use(logger);

Now every request logs to the console—nice.

Data Persistence Layer

So far we’ve been using fake data—cool for demos, but now it’s time to get real. Let’s integrate a database and create a clean, type-safe way to handle our data.


Choose Your Database

You’ve got two solid options:

  • PostgreSQL – great for relational data, works with ORMs like Prisma or TypeORM.
  • MongoDB – flexible and document-based, great with Mongoose.

For this guide, we’ll roll with Prisma + PostgreSQL, but you can totally swap it out based on your vibe.


Step 1: Install Prisma

npm install prisma --save-dev
npx prisma init

This creates a prisma/ folder with a schema.prisma file and .env for your DB connection.

Update .env with your connection string:

DATABASE_URL="postgresql://user:password@localhost:5432/yourdb"

Step 2: Define a Model

In prisma/schema.prisma:

prisma

model User {
  id    Int     @id @default(autoincrement())
  name  String
  email String  @unique
}

Then run:

npx prisma migrate dev --name init

Prisma creates your database tables automatically. ✨


Step 3: Use Prisma in Your Project

Install Prisma Client:

bash``` npm install @prisma/client


Then in `src/services/userService.ts`:

```ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export const getAllUsers = async () => {
  return await prisma.user.findMany();
};

And in your controller:

export const getUsers = async (req: Request, res: Response) => {
  const users = await getAllUsers();
  res.json(users);
};

Now your API is hitting a real database, and Prisma’s types keep everything tight and error-free. 🧠

Testing the API

Alright, you’ve got a clean, scalable API—now let’s make sure it actually works (and keeps working). Testing might sound boring, but it’s your best friend when your app grows or when other devs start collaborating with you.

We’ll use Jest (or Vitest if you want something faster + modern) for testing, and Supertest for making HTTP requests to our Express app.


Step 1: Install Testing Tools

npm install --save-dev jest ts-jest @types/jest supertest @types/supertest
npx ts-jest config:init

Update package.json with a test script:

"scripts": {
  "test": "jest"
}

Step 2: Basic Test Example

Create a test file: tests/user.test.ts

import request from 'supertest';
import app from '../src/app'; // if you separate Express setup from `index.ts`

describe('GET /api/users', () => {
  it('should return a list of users', async () => {
    const res = await request(app).get('/api/users');

    expect(res.statusCode).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
  });
});

📝 Pro Tip: If your Express setup is tightly coupled to the listen() call, move that part to index.ts and export your app from app.ts like so:

src/app.ts
import express from 'express';
import userRoutes from './routes/userRoutes';

const app = express();
app.use(express.json());
app.use('/api', userRoutes);

export default app;

Then in src/index.ts:

import app from './app';
app.listen(3000, () => console.log('Server running'));

Step 3: Mocking the Database

You don’t want tests hitting your real database. You can:

  • Use a mock database (e.g., SQLite in-memory for Prisma)

  • Use Jest to mock your service functions

Example:

jest.mock('../src/services/userService', () => ({
  getAllUsers: jest.fn(() => [
    { id: 1, name: 'Test User', email: 'test@example.com' }
  ])
}));

Types of Tests to Aim For

  • Unit tests – test individual functions/services.

  • Integration tests – test full request-response cycles (routes + middleware).

  • E2E tests – simulate actual usage (can come later).


Testing might feel like extra work now, but trust me—it saves you so much time when things break (and they will 😅).

API Versioning & Documentation

As your API grows and evolves, things will change—endpoints might be renamed, payloads updated, or features removed. But here’s the deal: you can’t break stuff for existing users. That’s where versioning and documentation come in clutch.


API Versioning 101

Versioning is like keeping the old versions of your mixtape so fans can still vibe, even if your new stuff drops.

🛣 Common Strategies:

URI-based (most popular):

/api/v1/users

/api/v2/users

Header-based:

GET /users

Accept: application/vnd.yourapi.v1+json

Stick with URI-based—it’s cleaner and easier to manage.

💡 Setup Example:

app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);

You can keep your newer logic in v2 and still support older apps using v1.


API Documentation with Swagger

If other devs (or future you) are gonna use your API, Swagger makes it ridiculously easy to generate docs from your code.

🛠 Step 1: Install Swagger UI

npm install swagger-ui-express swagger-jsdoc

📄 Step 2: Create Swagger Config

src/swagger.ts
import swaggerJSDoc from 'swagger-jsdoc';

export const swaggerSpec = swaggerJSDoc({
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'Scalable Node API',
      version: '1.0.0'
    }
  },
  apis: ['./src/routes/*.ts']
});

Then in src/index.ts:

import swaggerUi from 'swagger-ui-express';
import { swaggerSpec } from './swagger';

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));

Visit: http://localhost:3000/api-docs
Boom—interactive docs. 🧠


Add Comments for Swagger to Parse

In routes/userRoutes.ts:

/**
 * @openapi
 * /users:
 *   get:
 *     summary: Get all users
 *     responses:
 *       200:
 *         description: A list of users
 */

This lets Swagger auto-generate docs from your code with zero duplication.


TL;DR

  • Use /api/v1, /api/v2 to manage breaking changes
  • Generate live docs with Swagger for transparency
  • Update docs as part of your dev workflow

Deployment & Production Tips

You’ve built it, tested it, and documented it—now it’s time to ship it. Deploying a Node.js + TypeScript API doesn’t have to be scary. With the right setup, you can go live smoothly and scale without tears.


Environment Variable Management

Don’t hardcode secrets or config values (like your DB connection string, API keys, etc).

**✅ Use .env Files + dotenv

npm install dotenv

In src/index.ts:

import dotenv from 'dotenv';
dotenv.config();

Now you can safely use process.env.PORT, process.env.DATABASE_URL, etc.


Build for Production

Your TypeScript code needs to be compiled before going live.

🔨 Build the App

npm run build

This compiles your TS files to JS in the dist/ folder. Make sure your start script runs the compiled code:

"start": "node dist/index.js"

N|Solid for Production-Level Monitoring

If you’re deploying a mission-critical API, monitoring matters. N|Solid gives you pro-level insights like:

  • Real-time performance metrics
  • Security monitoring
  • CPU profiling and memory usage

It’s tailor-made for Node.js apps and way more focused than generic tools. Add it early to catch issues before your users do.


Final Pro Tips for Production

  • Use a reverse proxy like Nginx or Vercel Edge for handling HTTPS, routing, etc.

  • Enable request rate-limiting & basic DDoS protection with middleware like express-rate-limit.

  • Watch for memory leaks and keep logs centralized (e.g., Logtail, Datadog, etc).


🎉 Final Thoughts

You just went from zero to production-ready with a scalable, type-safe Node.js API—that’s a huge win.

You learned how to:

  • Set up a clean Node + TypeScript project
  • Create modular, maintainable code
  • Add middleware, error handling, and testing
  • Connect to a real database with Prisma
  • Version and document your API like a pro
  • Ship it to the world (and monitor it with tools like N|Solid)

Now you’ve got a real backend stack you can build on—for side projects, freelance gigs, or even your next startup. 💼🔥

The NodeSource platform offers a high-definition view of the performance, security and behavior of Node.js applications and functions.

Start for Free