What you will build
By the end of this tutorial you will have a working REST API for a task list, built with Express and TypeScript. It has five routes: list every task, create one, read one by id, update one, and delete one. That is the full create-read-update-delete (CRUD) set that sits under most real backends.
Data lives in a plain array in memory, so there is no database to install and nothing to configure. That keeps setup under five minutes and lets you focus on the routing and the HTTP status codes, which is the part worth learning first. You will test every route with curl from your terminal. When the routing is solid, swapping the array for a real database is a small, contained change, and the last section points you at that step.
Every file you need is in this page. Paste each one into a fresh project, follow the setup steps, and it runs. No repo to clone.
Setup
Create a fresh project and install the tooling. Express is the only runtime dependency; TypeScript, tsx, and the @types/* packages are dev tooling for building and running the .ts files.
mkdir my-api
cd my-api
npm init -y && npm install express && npm install -D typescript tsx @types/express @types/nodeAdd a tsconfig.json at the project root. These settings run cleanly with tsx in development and compile with tsc for a production build:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}Then open package.json, add "type": "module", and set these three scripts. dev runs the app directly with tsx (no build step while you work), build compiles to dist/, and start runs the compiled output the way a production host would:
{
"type": "module",
"scripts": {
"dev": "tsx src/app.ts",
"build": "tsc",
"start": "node dist/app.js"
}
}The two source files below both go in a src/ directory you create in the project root.
Define the Task type
Start with the shape of the data. One Task interface, shared by every route, keeps the whole API consistent: the route that creates a task and the route that returns it agree on the exact fields, and TypeScript flags any place you drift. This is the payoff of TypeScript on the server. The shape is written down once and checked everywhere.
Create src/types.ts:
export interface Task {
id: number;
title: string;
done: boolean;
createdAt: Date;
}Build the Express app
Now the app itself. Tasks live in a Task[] array and a nextId counter hands out ids. Holding data in memory is fine for learning the routing: it resets every time the server restarts, which the last section addresses, but it means zero setup and instant feedback while you build.
A few things to notice in the code. express.json() parses incoming JSON bodies so req.body is a real object. Each handler is typed with Request and Response, so your editor autocompletes the response methods and catches typos. The import reads from "./types.js" even though the file is types.ts: that .js suffix is the ES module convention, and it is what lets the same import work under tsx in development and under plain Node after you build.
Create src/app.ts:
import express, { type Request, type Response } from "express";
import type { Task } from "./types.js";
const app = express();
app.use(express.json());
const tasks: Task[] = [];
let nextId = 1;
// GET /tasks: return every task.
app.get("/tasks", (_req: Request, res: Response) => {
res.json(tasks);
});
// POST /tasks: create a task from { title }. 400 if the title is missing.
app.post("/tasks", (req: Request, res: Response) => {
const { title } = req.body ?? {};
if (typeof title !== "string" || title.trim() === "") {
res.status(400).json({ error: "title is required and must be a non-empty string" });
return;
}
const task: Task = { id: nextId++, title, done: false, createdAt: new Date() };
tasks.push(task);
res.status(201).json(task);
});
// GET /tasks/:id: return one task or 404.
app.get("/tasks/:id", (req: Request, res: Response) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
res.status(404).json({ error: "task not found" });
return;
}
res.json(task);
});
// PATCH /tasks/:id: update title and/or done on an existing task, or 404.
app.patch("/tasks/:id", (req: Request, res: Response) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
res.status(404).json({ error: "task not found" });
return;
}
const { title, done } = req.body ?? {};
if (title !== undefined) {
if (typeof title !== "string" || title.trim() === "") {
res.status(400).json({ error: "title must be a non-empty string" });
return;
}
task.title = title;
}
if (done !== undefined) {
if (typeof done !== "boolean") {
res.status(400).json({ error: "done must be a boolean" });
return;
}
task.done = done;
}
res.json(task);
});
// DELETE /tasks/:id: remove a task. 204 on success, 404 if it was not there.
app.delete("/tasks/:id", (req: Request, res: Response) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
res.status(404).json({ error: "task not found" });
return;
}
tasks.splice(index, 1);
res.status(204).end();
});
const port = process.env.PORT ?? 3000;
app.listen(port, () => {
console.log(`API listening on http://localhost:${port}`);
});Run and test
Start the server in dev mode, then hit each route with curl in a second terminal:
npm run dev
# -> API listening on http://localhost:3000
# List tasks (empty at first)
curl http://localhost:3000/tasks
# Create a task
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Write the tutorial"}'
# Read it back by id
curl http://localhost:3000/tasks/1
# Mark it done
curl -X PATCH http://localhost:3000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"done":true}'
# Delete it (returns 204 No Content)
curl -i -X DELETE http://localhost:3000/tasks/1The status codes are the point of this exercise, so read them on purpose. A successful create returns 201 Created with the new task as JSON. A POST with no title returns 400 Bad Request: the client sent something wrong, so you reject it before touching the data. Asking for a task id that does not exist returns 404 Not Found. A successful delete returns 204 No Content, which means it worked and there is nothing to send back, so the body is empty on purpose. Getting these right is most of what separates a real API from a function that happens to be reachable over HTTP.
What to build next
You have a working API with correct HTTP semantics. Three moves take it toward something you would run for real.
Add a database. The in-memory array loses everything on restart. Swap it for PostgreSQL with Drizzle or Prisma, and the route handlers barely change: the tasks.find and tasks.push calls become queries. Because the Task type is already defined in one place, the migration stays contained.
Add authentication. Install jsonwebtoken, add a login route that returns a signed token, and write an Express middleware that checks the Authorization header and returns 401 when it is missing or invalid. That one pattern protects any route you want.
Deploy it. Push the build to a free tier like Fly.io or Render so you have a live URL to put on your resume. A deployed API with a database is exactly the project backend interviewers want to see.
For the full path around this project, our backend developer learn path lines up the free courses in order, and the backend roadmap shows what comes after. If you want the more opinionated, less code-heavy version of this build, read the companion guide on building a REST API with Node.js and Express.
Next step: test this API
A working API is step one; the next thing a real project needs is tests so you can change it without breaking it. Our follow-up, test an Express + TypeScript API with Vitest and Supertest, takes this exact task API and adds a test suite covering the happy path, validation errors, and 404s, without ever binding a port.
Frequently asked questions
Do I need a database for this tutorial?
No. A plain TypeScript array works fine here. The tutorial keeps setup fast by staying in-memory; the "What to build next" section points toward adding PostgreSQL once the routing is solid.
Why Express and not Fastify or Hono?
Express is still the most-used Node.js web framework and the one you will find in most job postings and existing codebases. Fastify and Hono are solid too, but learning Express first means the pattern transfers everywhere.
Can I run this in production?
The in-memory store loses its data on restart, so it is not production-ready as written. Add a database (PostgreSQL plus Drizzle is a natural next step) and the structure holds up.
What is tsx and why use it instead of ts-node?
tsx runs TypeScript files directly without a separate compile step, starts up faster than ts-node, and handles the ESNext module config without extra flags. It is the current default for dev-mode TypeScript in Node.
How do I add authentication to this API?
The standard approach is JWT: install jsonwebtoken and @types/jsonwebtoken, add a POST /login route that returns a signed token, then write an Express middleware that reads the Authorization header and calls next() or returns 401. Our follow-up tutorial at /tutorials/build-jwt-auth-api-express-typescript walks through the whole thing (register, login, and a protected route) file by file.
Keep going on FreeCodingCourses
- Tutorial: add a real database with Prisma and PostgreSQL
- Tutorial: test this API with Vitest and Supertest
- Tutorial: add JWT authentication to an Express API
- Tutorial: build a web scraper with Node.js, TypeScript, and Cheerio
- Backend developer learn path (free)
- Learn TypeScript free path
- Free Node.js courses
- Backend developer roadmap
- Guide: build a REST API with Node.js and Express