- Published on
Build a REST API with Node.js and Express - Complete Guide
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Express remains the most popular Node.js framework for building REST APIs — and for good reason. It's minimal, flexible, and has a massive ecosystem. In this guide, we'll build a complete, production-ready REST API from scratch.
- Project Setup
- Basic Express App
- MVC Structure
- Routes and Controllers
- Input Validation with Zod
- JWT Authentication
- Global Error Handler
- Putting It All Together
- Conclusion
Project Setup
mkdir my-api && cd my-api
npm init -y
npm install express dotenv
npm install --save-dev typescript @types/express @types/node ts-node nodemon
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
}
Basic Express App
src/index.ts
import express from 'express'
import dotenv from 'dotenv'
dotenv.config()
const app = express()
const PORT = process.env.PORT || 3000
// Middleware
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() })
})
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
})
MVC Structure
src/
├── controllers/
│ └── userController.ts
├── routes/
│ └── userRoutes.ts
├── middleware/
│ ├── auth.ts
│ └── errorHandler.ts
├── models/
│ └── User.ts
├── services/
│ └── userService.ts
└── index.ts
Routes and Controllers
src/routes/userRoutes.ts
import { Router } from 'express'
import {
getAllUsers,
getUserById,
createUser,
updateUser,
deleteUser,
} from '../controllers/userController'
import { authenticate } from '../middleware/auth'
const router = Router()
router.get('/', getAllUsers)
router.get('/:id', getUserById)
router.post('/', createUser)
router.put('/:id', authenticate, updateUser)
router.delete('/:id', authenticate, deleteUser)
export default router
src/controllers/userController.ts
import { Request, Response, NextFunction } from 'express'
import { UserService } from '../services/userService'
const userService = new UserService()
export async function getAllUsers(req: Request, res: Response, next: NextFunction) {
try {
const page = Number(req.query.page) || 1
const limit = Number(req.query.limit) || 10
const users = await userService.findAll({ page, limit })
res.json({ data: users, page, limit })
} catch (error) {
next(error)
}
}
export async function getUserById(req: Request, res: Response, next: NextFunction) {
try {
const user = await userService.findById(req.params.id)
if (!user) {
return res.status(404).json({ message: 'User not found' })
}
res.json(user)
} catch (error) {
next(error)
}
}
export async function createUser(req: Request, res: Response, next: NextFunction) {
try {
const user = await userService.create(req.body)
res.status(201).json(user)
} catch (error) {
next(error)
}
}
export async function updateUser(req: Request, res: Response, next: NextFunction) {
try {
const user = await userService.update(req.params.id, req.body)
if (!user) return res.status(404).json({ message: 'User not found' })
res.json(user)
} catch (error) {
next(error)
}
}
export async function deleteUser(req: Request, res: Response, next: NextFunction) {
try {
await userService.delete(req.params.id)
res.status(204).send()
} catch (error) {
next(error)
}
}
Input Validation with Zod
npm install zod
src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express'
import { AnyZodObject, ZodError } from 'zod'
export const validate = (schema: AnyZodObject) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params,
})
next()
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({
message: 'Validation error',
errors: error.errors,
})
}
next(error)
}
}
src/schemas/userSchema.ts
import { z } from 'zod'
export const createUserSchema = z.object({
body: z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
password: z.string().min(8),
}),
})
JWT Authentication
npm install jsonwebtoken bcryptjs
npm install --save-dev @types/jsonwebtoken @types/bcryptjs
src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
interface JwtPayload {
userId: string
email: string
}
declare global {
namespace Express {
interface Request {
user?: JwtPayload
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return res.status(401).json({ message: 'No token provided' })
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload
req.user = decoded
next()
} catch {
res.status(401).json({ message: 'Invalid token' })
}
}
Global Error Handler
src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
export class AppError extends Error {
statusCode: number
constructor(message: string, statusCode: number) {
super(message)
this.statusCode = statusCode
}
}
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({ message: err.message })
}
console.error(err.stack)
res.status(500).json({ message: 'Internal server error' })
}
Putting It All Together
src/index.ts
import express from 'express'
import userRoutes from './routes/userRoutes'
import { errorHandler } from './middleware/errorHandler'
const app = express()
app.use(express.json())
app.use('/api/users', userRoutes)
app.use(errorHandler) // Must be last!
app.listen(3000)
Conclusion
Building a REST API with Express and TypeScript gives you a flexible, well-understood foundation that scales from prototypes to production. Add validation with Zod, secure routes with JWT, and centralize your errors — and you've got a rock-solid API that's easy to maintain and extend.