DEV Community

Dimas Prasetyo
Dimas Prasetyo

Posted on

How to Build a REST API with Express.js and TypeScript: A Practical Guide

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Initialize TypeScript:

npx tsc --init
Enter fullscreen mode Exit fullscreen mode

Update package.json with the following scripts:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/server.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  },
};
Enter fullscreen mode Exit fullscreen mode

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,
  });
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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",
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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",
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

Start the development server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Your API should now be available at http://localhost:3000.

10. Test the API

Check the health endpoint:

curl http://localhost:3000/health
Enter fullscreen mode Exit fullscreen mode

Expected response:

{
  "status": "ok"
}
Enter fullscreen mode Exit fullscreen mode

Create a task:

curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Write my first technical blog"}'
Enter fullscreen mode Exit fullscreen mode

You should receive a 201 Created response containing the new task.

Retrieve all tasks:

curl http://localhost:3000/api/tasks
Enter fullscreen mode Exit fullscreen mode

Test validation by submitting an empty title:

curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"   "}'
Enter fullscreen mode Exit fullscreen mode

The API should respond with 400 Bad Request.

You can also test an unknown task ID:

curl http://localhost:3000/api/tasks/unknown-id
Enter fullscreen mode Exit fullscreen mode

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)