Building an API is easy. Building one that remains maintainable as your application grows is a different challenge.
When developing fullstack applications, it's tempting to put all your routes, business logic, and database operations inside a single Express.js file. This approach works for small experiments, but it quickly becomes difficult to maintain as features increase.
In this tutorial, we'll build a small REST API using Node.js, Express.js, and TypeScript. We'll organize the code into separate modules, validate incoming requests, and implement centralized error handling.
By the end, you'll have a working task API and a foundation you can extend for real-world applications.
What You'll Build
Our API will support the following endpoints:
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/tasks |
Retrieve all tasks |
| GET | /api/tasks/:id |
Retrieve one task |
| POST | /api/tasks |
Create a task |
| GET | /health |
Check server health |
For simplicity, tasks will be stored in memory. This keeps the tutorial focused on API architecture rather than database configuration.
Important: In-memory storage is not persistent. Data will disappear when the server restarts, and this implementation is not intended for production use.
1. Prerequisites
Make sure you have:
- Node.js and npm installed.
- Basic knowledge of JavaScript and TypeScript.
- A terminal and code editor.
- An HTTP client such as curl, Postman, or Bruno.
Check your installation:
node --version
npm --version
2. Create the Project
Initialize a new project:
mkdir express-typescript-api
cd express-typescript-api
npm init -y
npm install express zod dotenv
npm install -D typescript tsx @types/node @types/express
Initialize TypeScript:
npx tsc --init
Update package.json with the following scripts:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js"
}
}
Configure tsconfig.json with these essential settings:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
This configuration uses strict TypeScript checking and compiles source files from src into dist.
Because we're using Node's native ES module conventions, add "type": "module" to the top-level package.json.
3. Organize the Project Structure
Create the following directory structure:
express-typescript-api/
├── src/
│ ├── controllers/
│ │ └── task.controller.ts
│ ├── middleware/
│ │ ├── error-handler.ts
│ │ └── not-found.ts
│ ├── routes/
│ │ └── task.routes.ts
│ ├── services/
│ │ └── task.service.ts
│ ├── app.ts
│ └── server.ts
├── package.json
└── tsconfig.json
Why separate these files?
- Routes define which endpoints are available.
- Controllers handle HTTP requests and responses.
- Services contain business logic.
- Middleware handles cross-cutting concerns such as errors.
This separation makes the application easier to test and extend without creating an unnecessarily complicated architecture.
4. Define the Task Service
Create src/services/task.service.ts:
import { randomUUID } from "node:crypto";
export interface Task {
id: string;
title: string;
completed: boolean;
}
const tasks: Task[] = [];
export const taskService = {
findAll(): Task[] {
return [...tasks];
},
findById(id: string): Task | undefined {
return tasks.find((task) => task.id === id);
},
create(title: string): Task {
const task: Task = {
id: randomUUID(),
title,
completed: false,
};
tasks.push(task);
return task;
},
};
The service owns the task operations. Our controllers don't need to know how tasks are stored.
Later, we can replace the array with a database repository without rewriting the HTTP routes.
5. Add Request Validation
Incoming requests should never be trusted automatically.
For example, our API should reject a task with an empty title or a title that exceeds a reasonable length.
We'll use Zod to validate the request body.
Create src/controllers/task.controller.ts:
import type {
Request,
Response,
NextFunction,
} from "express";
import { z } from "zod";
import { taskService } from "../services/task.service.js";
const createTaskSchema = z.object({
title: z.string().trim().min(1).max(120),
});
export const getTasks = (
_req: Request,
res: Response,
) => {
res.json({
data: taskService.findAll(),
});
};
export const getTaskById = (
req: Request,
res: Response,
next: NextFunction,
) => {
const task = taskService.findById(req.params.id);
if (!task) {
return res.status(404).json({
error: {
code: "TASK_NOT_FOUND",
message: "Task not found",
},
});
}
return res.json({ data: task });
};
export const createTask = (
req: Request,
res: Response,
next: NextFunction,
) => {
const result = createTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: {
code: "VALIDATION_ERROR",
message: "Invalid request body",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
},
});
}
const task = taskService.create(result.data.title);
return res.status(201).json({
data: task,
});
};
Notice that validation happens before the service creates a task.
This protects the business logic from invalid input and gives API consumers a predictable error response.
6. Define the Routes
Create src/routes/task.routes.ts:
import { Router } from "express";
import {
createTask,
getTaskById,
getTasks,
} from "../controllers/task.controller.js";
const router = Router();
router.get("/", getTasks);
router.get("/:id", getTaskById);
router.post("/", createTask);
export default router;
The router defines the API endpoints without containing the actual task logic.
As the application grows, you can add other modules such as users, products, payments, or invitations using the same pattern.
7. Implement Centralized Error Handling
Create src/middleware/not-found.ts:
import type {
Request,
Response,
NextFunction,
} from "express";
export function notFound(
_req: Request,
res: Response,
_next: NextFunction,
) {
res.status(404).json({
error: {
code: "ROUTE_NOT_FOUND",
message: "Endpoint not found",
},
});
}
Create src/middleware/error-handler.ts:
import type {
ErrorRequestHandler,
} from "express";
export const errorHandler: ErrorRequestHandler = (
err,
_req,
res,
_next,
) => {
console.error(err);
res.status(500).json({
error: {
code: "INTERNAL_SERVER_ERROR",
message: "An unexpected error occurred",
},
});
};
The error handler intentionally avoids returning internal exception details to clients.
In production, replace raw console logging with structured logging and configure monitoring so that errors can be investigated safely.
8. Configure the Express Application
Create src/app.ts:
import express from "express";
import taskRoutes from "./routes/task.routes.js";
import { notFound } from "./middleware/not-found.js";
import { errorHandler } from "./middleware/error-handler.js";
const app = express();
app.use(express.json());
app.get("/health", (_req, res) => {
res.status(200).json({
status: "ok",
});
});
app.use("/api/tasks", taskRoutes);
// Register these after the application routes.
app.use(notFound);
app.use(errorHandler);
export default app;
Middleware order matters. The JSON parser must run before handlers that read req.body, and error-handling middleware should be registered after the routes.
9. Start the Server
Create src/server.ts:
import "dotenv/config";
import app from "./app.js";
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => {
console.log(`API running on port ${port}`);
});
Start the development server:
npm run dev
Your API should now be available at http://localhost:3000.
10. Test the API
Check the health endpoint:
curl http://localhost:3000/health
Expected response:
{
"status": "ok"
}
Create a task:
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Write my first technical blog"}'
You should receive a 201 Created response containing the new task.
Retrieve all tasks:
curl http://localhost:3000/api/tasks
Test validation by submitting an empty title:
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":" "}'
The API should respond with 400 Bad Request.
You can also test an unknown task ID:
curl http://localhost:3000/api/tasks/unknown-id
This should return 404 Not Found.
11. What Should Change Before Production?
The example is intentionally small. Before using a similar architecture in a real application, consider the following improvements:
- Database persistence: Replace the in-memory array with MySQL, PostgreSQL, or another suitable database.
- Authentication and authorization: Protect private endpoints and verify that users can access only permitted resources.
- Request limits: Configure body-size limits, rate limiting, and appropriate timeouts.
- Configuration: Validate environment variables at application startup and keep secrets out of source control.
- Logging: Add structured logs, request IDs, and error monitoring.
- Testing: Write unit tests for services and integration tests for HTTP endpoints.
- API documentation: Document request schemas, responses, and error codes using OpenAPI.
- Deployment: Configure health checks, graceful shutdown, and a reliable CI/CD process.
Also consider database transactions and concurrency when your business logic updates multiple records.
Conclusion
A maintainable Express.js API doesn't require an elaborate framework or dozens of abstractions.
Start with clear module boundaries, validate input at the API boundary, keep business logic separate from HTTP handling, and return consistent errors.
As your application grows, introduce additional abstractions when they solve a real problem rather than adding complexity prematurely.
The next step is to replace the in-memory task store with a database and write integration tests around the API.
Your turn: How do you organize your Express.js applications? Do you prefer controllers and services, a feature-based structure, or a different approach? Share your experience in the comments.
If you found this tutorial useful, follow me for more practical articles about frontend engineering, fullstack development, web performance, and production-ready software architecture.
Next article: How to Connect an Express.js REST API to MySQL Using Sequelize
Top comments (0)