Building restful APIs with Node.js, express, and TypeScript

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).