Developing robust and maintainable RESTful APIs is a critical skill in modern web development. In this post, we'll explore how to build a RESTful API using Node.js, Express, and TypeScript, covering everything from initial setup to deploying your application.

Introduction

Building a full-stack API allows you to create powerful back-end services that interact seamlessly with front-end applications. Node.js and Express are popular choices for back-end development due to their performance and scalability. Incorporating TypeScript adds static typing and advanced tooling, resulting in more maintainable and error-resistant code.

System requirements

Before you begin, ensure your system meets these requirements:

  • Node.js 18.0.0 or later (recommended for Express 5.x)
  • npm 8.0.0 or later
  • TypeScript 4.5 or later

Setting up the environment

Start by verifying your Node.js and npm versions:

node -v
npm -v

If Node.js isn’t installed, download it from the official Node.js website.

Create a new project directory and initialize it:

mkdir stack-api
cd stack-api
npm init -y

Install the necessary dependencies:

npm install express helmet cors dotenv
npm install typeorm pg reflect-metadata
npm install --save-dev typescript ts-node @types/node @types/express nodemon

Initialize a TypeScript configuration file:

npx tsc --init

Replace the contents of your generated tsconfig.json with the following configuration, which is recommended for modern Node.js projects:

{
  "compilerOptions": {
    "target": "ES6",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Create a .env file for environment variables:

NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_USER=your_user
DB_PASS=your_password
DB_NAME=your_database

Project structure

Organize your project using the following structure:

src/
├── config/
│   └── database.ts
├── controllers/
├── middleware/
├── models/
├── routes/
└── index.ts

Setting up the database connection

Create the file src/config/database.ts:

import { DataSource } from 'typeorm'
import { User } from '../models/User'
import dotenv from 'dotenv'

dotenv.config()

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT || '5432'),
  username: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,
  synchronize: process.env.NODE_ENV === 'development', // Disable in production
  logging: process.env.NODE_ENV === 'development',
  entities: [User],
  subscribers: [],
  migrations: [],
})

Creating the user model

Create src/models/User.ts:

import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id!: number

  @Column()
  name!: string

  @Column({ unique: true })
  email!: string

  @Column()
  password!: string

  @CreateDateColumn()
  createdAt!: Date

  @UpdateDateColumn()
  updatedAt!: Date
}

Error handling middleware

Create src/middleware/errorHandler.ts:

import { Request, Response, NextFunction } from 'express'

export class AppError extends Error {
  statusCode: number
  status: string

  constructor(message: string, statusCode: number) {
    super(message)
    this.statusCode = statusCode
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'
  }
}

export const errorHandler = (
  err: Error | AppError,
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      status: err.status,
      message: err.message,
    })
  }

  console.error('Error:', err.stack || err)
  return res.status(500).json({
    status: 'error',
    message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error',
  })
}

Setting up the Express Server

Create src/index.ts:

import 'reflect-metadata'
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import dotenv from 'dotenv'
import { AppDataSource } from './config/database'
import { errorHandler } from './middleware/errorHandler'
import userRoutes from './routes/userRoutes'

dotenv.config()

const app = express()
const port = process.env.PORT || 3000

// Security middleware
app.use(helmet())
app.use(cors())
app.use(express.json({ limit: '10kb' }))

// Routes
app.use('/api/v1/users', userRoutes)

// Global error handling
app.use(errorHandler)

// Database connection and server startup
AppDataSource.initialize()
  .then(() => {
    console.log('Database connected successfully.')
    app.listen(port, () => {
      console.log(`Server running on port ${port} in ${process.env.NODE_ENV} mode`)
    })
  })
  .catch((error) => {
    console.error('Error connecting to database:', error)
    process.exit(1)
  })

User controller

Create src/controllers/userController.ts:

import { Request, Response, NextFunction } from 'express'
import { AppDataSource } from '../config/database'
import { User } from '../models/User'
import { AppError } from '../middleware/errorHandler'

const userRepository = AppDataSource.getRepository(User)

export const getUsers = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const users = await userRepository.find({
      select: ['id', 'name', 'email', 'createdAt'],
    })
    res.json({ status: 'success', data: users })
  } catch (err) {
    next(err)
  }
}

export const getUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const user = await userRepository.findOne({
      where: { id: parseInt(req.params.id) },
      select: ['id', 'name', 'email', 'createdAt'],
    })
    if (!user) {
      throw new AppError('User not found', 404)
    }
    res.json({ status: 'success', data: user })
  } catch (err) {
    next(err)
  }
}

export const createUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const { name, email, password } = req.body
    if (!name || !email || !password) {
      throw new AppError('Please provide name, email and password', 400)
    }

    const user = userRepository.create({ name, email, password })
    await userRepository.save(user)

    res.status(201).json({
      status: 'success',
      data: {
        id: user.id,
        name: user.name,
        email: user.email,
      },
    })
  } catch (err) {
    next(err)
  }
}

export const updateUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const user = await userRepository.findOne({
      where: { id: parseInt(req.params.id) },
    })

    if (!user) {
      throw new AppError('User not found', 404)
    }

    userRepository.merge(user, req.body)
    const updatedUser = await userRepository.save(user)

    res.json({
      status: 'success',
      data: {
        id: updatedUser.id,
        name: updatedUser.name,
        email: updatedUser.email,
      },
    })
  } catch (err) {
    next(err)
  }
}

export const deleteUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const result = await userRepository.delete(req.params.id)

    if (result.affected === 0) {
      throw new AppError('User not found', 404)
    }

    res.status(204).json({
      status: 'success',
      data: null,
    })
  } catch (err) {
    next(err)
  }
}

User routes

Create src/routes/userRoutes.ts:

import { Router } from 'express'
import {
  getUsers,
  getUser,
  createUser,
  updateUser,
  deleteUser,
} from '../controllers/userController'

const router = Router()

router.route('/').get(getUsers).post(createUser)
router.route('/:id').get(getUser).patch(updateUser).delete(deleteUser)

export default router

Running the application

Add these scripts to your package.json:

{
  "scripts": {
    "start": "node dist/index.js",
    "dev": "nodemon src/index.ts",
    "build": "tsc",
    "lint": "eslint . --ext .ts",
    "test": "jest"
  }
}

Start the development server with the following command:

npm run dev

Your API will be available at http://localhost:3000/api/v1/users.

Testing the API

You can test the endpoints using tools like Postman or cURL:

# Get all users
curl -fsSL http://localhost:3000/api/v1/users

# Create a user
curl -fsSL -X POST http://localhost:3000/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"name":"John Doe","email":"john@example.com","password":"secret123"}'

# Get a specific user
curl -fsSL http://localhost:3000/api/v1/users/1

# Update a user
curl -fsSL -X PATCH http://localhost:3000/api/v1/users/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"John Smith"}'

# Delete a user
curl -fsSL -X DELETE http://localhost:3000/api/v1/users/1

API documentation

For better maintainability, consider integrating Swagger/OpenAPI into your project. You can use packages such as swagger-jsdoc and swagger-ui-express to automatically generate API documentation from your routes. Refer to the Swagger documentation for more details.

Deployment considerations

When preparing your API for production, keep in mind the following best practices:

  • Disable automatic schema synchronization by setting synchronize to false in your TypeORM configuration.
  • Configure secure HTTP headers and rate limiting.
  • Use environment variables to manage sensitive configurations and credentials.
  • Thoroughly test your API using unit and integration tests.

Consult the Express.js deployment guide and TypeORM documentation for further advice.

Conclusion

This tutorial has provided you with a comprehensive guide to building a secure and maintainable RESTful API using Node.js, Express, and TypeScript. We covered environment setup, database integration with TypeORM, detailed error handling, and essential security middleware. Additionally, we discussed documentation and deployment strategies to help you build production-ready applications.

If you require advanced file upload management capabilities, consider exploring Transloadit's robust upload API (https://transloadit.com).