Build Your First REST API with Claude Code: CRUD, Validation, and Tests
Build a beginner Express REST API with Claude Code: CRUD, validation, error design, tests, and runnable code.
The hardest part of building your first REST API is not typing Express routes. It is knowing what a usable API must include: clear URLs, the right HTTP methods, validation, predictable error responses, and at least a few checks that prove the code works.
This guide uses Claude Code to build a small Todo API with Express. You will create the project, add CRUD endpoints, validate JSON input, return useful status codes, and test the API with curl and Node.js’s built-in test runner. The code targets current Node.js 22/24 LTS or newer.
If you are still getting comfortable with Claude Code itself, start with the Claude Code getting started guide. For deeper testing patterns, continue with Claude Code API testing and Zod validation with Claude Code.
The terms beginners need
A REST API is a design style for working with resources over HTTP. In this article, the resource is a todo. In plain terms, an API is a set of URLs that another app can call and receive JSON back.
| Term | Plain meaning | Example here |
|---|---|---|
| Endpoint | The URL entry point of an API | GET /todos |
| Method | The HTTP verb that says what you want to do | GET, POST, PUT, DELETE |
| Status code | A number that reports the result | 200, 201, 400, 404 |
| JSON | A lightweight text format for sending data | { "title": "Learn REST" } |
| Validation | Checking whether input is acceptable | Rejecting an empty title |
| Idempotency | Repeating the same request leaves the resource in the same final state | Sending the same PUT twice |
The official references worth bookmarking are MDN’s HTTP request methods, MDN’s HTTP status codes, Express routing, Express error handling, and the Node.js test runner.
What we will build
The API stores Todos in memory. That means it resets when the server restarts. This is intentional: beginners can focus on REST shape before adding a database.
| Action | Method and URL | Success status |
|---|---|---|
| Health check | GET /health | 200 OK |
| List Todos | GET /todos | 200 OK |
| Read one Todo | GET /todos/:id | 200 OK |
| Create Todo | POST /todos | 201 Created |
| Replace Todo | PUT /todos/:id | 200 OK |
| Delete Todo | DELETE /todos/:id | 204 No Content |
This tiny API maps to real work. Use case one is an internal task API: replace Todo with review requests, support tickets, or small operations tasks. Use case two is a mock backend for frontend work, so React, Vue, or Astro screens can be tested before the real backend exists. Use case three is a small lead or newsletter API: replace title with email and message, then keep the same validation and error response ideas.
Prompt Claude Code with completion criteria
Give Claude Code the stack, endpoints, failure behavior, and verification command. A vague “build an API” prompt often produces code that runs but leaves design decisions unclear.
Create a beginner Todo REST API with Express 5 and Node.js ES Modules.
Requirements:
- Provide package.json and server.js
- Add GET /health, GET /todos, GET /todos/:id, POST /todos, PUT /todos/:id, DELETE /todos/:id
- Accept JSON input
- title must be 1 to 120 characters; completed must be boolean
- Return JSON errors for 400, 404, and 500
- Keep PUT idempotent: repeating the same request should not change the final resource state
- Include curl requests and a node:test example
The idempotency line matters. PUT should represent a replacement. If you send the same body twice, the Todo should end in the same state. If your code changes updatedAt every time even when nothing changed, the behavior and the explanation no longer match.
Create the project
Run these commands in an empty folder.
mkdir claude-rest-api
cd claude-rest-api
npm init -y
npm install express
Replace package.json with this:
{
"name": "claude-rest-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch server.js",
"start": "node server.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.0.0"
}
}
server.js
import express from "express";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const PORT = Number(process.env.PORT ?? 3000);
function createHttpError(status, message, details) {
const error = new Error(message);
error.status = status;
error.details = details;
return error;
}
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function parseId(rawId) {
const id = Number(rawId);
return Number.isInteger(id) && id > 0 ? id : null;
}
function validateTodoInput(body, { partial = false } = {}) {
const errors = [];
if (!partial || hasOwn(body, "title")) {
if (typeof body.title !== "string" || body.title.trim().length === 0) {
errors.push({ field: "title", message: "title is required" });
} else if (body.title.trim().length > 120) {
errors.push({ field: "title", message: "title must be 120 characters or fewer" });
}
}
if (!partial || hasOwn(body, "completed")) {
if (typeof body.completed !== "boolean") {
errors.push({ field: "completed", message: "completed must be a boolean" });
}
}
return errors;
}
export function createApp() {
const app = express();
let nextId = 3;
const todos = [
{
id: 1,
title: "Read MDN HTTP status docs",
completed: false,
updatedAt: "2026-06-03T00:00:00.000Z"
},
{
id: 2,
title: "Ask Claude Code to review API errors",
completed: true,
updatedAt: "2026-06-03T00:00:00.000Z"
}
];
app.use(express.json({ limit: "32kb" }));
app.get("/health", (req, res) => {
res.json({ status: "ok", uptime: process.uptime() });
});
app.get("/todos", (req, res) => {
res.json({ data: todos, count: todos.length });
});
app.get("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const todo = todos.find((item) => item.id === id);
if (!todo) return next(createHttpError(404, "todo not found"));
res.json({ data: todo });
});
app.post("/todos", (req, res, next) => {
const errors = validateTodoInput(req.body);
if (errors.length > 0) {
return next(createHttpError(400, "invalid request body", errors));
}
const todo = {
id: nextId,
title: req.body.title.trim(),
completed: req.body.completed,
updatedAt: new Date().toISOString()
};
nextId += 1;
todos.push(todo);
res.status(201).location(`/todos/${todo.id}`).json({ data: todo });
});
app.put("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const errors = validateTodoInput(req.body);
if (errors.length > 0) {
return next(createHttpError(400, "invalid request body", errors));
}
const index = todos.findIndex((item) => item.id === id);
if (index === -1) return next(createHttpError(404, "todo not found"));
const previous = todos[index];
const nextTodo = {
...previous,
title: req.body.title.trim(),
completed: req.body.completed
};
const changed =
previous.title !== nextTodo.title || previous.completed !== nextTodo.completed;
todos[index] = {
...nextTodo,
updatedAt: changed ? new Date().toISOString() : previous.updatedAt
};
res.json({ data: todos[index] });
});
app.delete("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const index = todos.findIndex((item) => item.id === id);
if (index === -1) return next(createHttpError(404, "todo not found"));
todos.splice(index, 1);
res.status(204).send();
});
app.use((req, res, next) => {
next(createHttpError(404, `route not found: ${req.method} ${req.originalUrl}`));
});
app.use((err, req, res, next) => {
const status = Number.isInteger(err.status) && err.status >= 400 ? err.status : 500;
const body = {
error: {
status,
message: status === 500 ? "Internal Server Error" : err.message
}
};
if (err.details) {
body.error.details = err.details;
}
res.status(status).json(body);
});
return app;
}
const currentFile = fileURLToPath(import.meta.url);
const startedFile = process.argv[1] ? resolve(process.argv[1]) : "";
if (startedFile === currentFile) {
createApp().listen(PORT, () => {
console.log(`REST API listening on http://localhost:${PORT}`);
});
}
The important detail is not just that routes exist. The API validates input, creates consistent JSON errors, and returns the right status code for each situation. The error middleware uses four arguments because Express recognizes error handlers by that signature.
Test with curl
Start the server:
npm run dev
Then run:
curl -i http://localhost:3000/health
curl -i http://localhost:3000/todos
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"Write API tests","completed":false}'
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"","completed":false}'
curl -i -X PUT http://localhost:3000/todos/1 \
-H "Content-Type: application/json" \
-d '{"title":"Read MDN HTTP status docs","completed":true}'
curl -i -X DELETE http://localhost:3000/todos/2
Check both success and failure responses. An API that only works on the happy path is not ready for a frontend.
Add a small node:test file
Create server.test.js:
import assert from "node:assert/strict";
import test from "node:test";
import { createApp } from "./server.js";
function listen(app) {
return new Promise((resolve) => {
const server = app.listen(0, () => resolve(server));
});
}
async function request(baseUrl, path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
headers: { "Content-Type": "application/json", ...options.headers },
...options
});
const text = await response.text();
return {
response,
body: text ? JSON.parse(text) : null
};
}
test("creates, updates, and deletes a todo", async (t) => {
const server = await listen(createApp());
t.after(() => server.close());
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const created = await request(baseUrl, "/todos", {
method: "POST",
body: JSON.stringify({ title: "Test the API", completed: false })
});
assert.equal(created.response.status, 201);
assert.equal(created.body.data.title, "Test the API");
const id = created.body.data.id;
const updated = await request(baseUrl, `/todos/${id}`, {
method: "PUT",
body: JSON.stringify({ title: "Test the API", completed: true })
});
assert.equal(updated.response.status, 200);
assert.equal(updated.body.data.completed, true);
const deleted = await request(baseUrl, `/todos/${id}`, { method: "DELETE" });
assert.equal(deleted.response.status, 204);
});
test("rejects invalid todo input", async (t) => {
const server = await listen(createApp());
t.after(() => server.close());
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const result = await request(baseUrl, "/todos", {
method: "POST",
body: JSON.stringify({ title: "", completed: false })
});
assert.equal(result.response.status, 400);
assert.equal(result.body.error.details[0].field, "title");
});
Run:
npm test
Now Claude Code has a safety net. When you later ask it to add SQLite, PostgreSQL, OpenAPI, or authentication, tell it to keep this test suite green.
Common pitfalls
Do not name endpoints like /getTodos and /createTodo. Let the HTTP method carry the verb: GET /todos and POST /todos.
Do not return 200 for every outcome. Use 400 for bad input, 404 for missing resources, 201 for creation, and 204 when delete succeeds without a response body.
Do not rely only on frontend validation. Anyone can call the API directly, so the server must validate input and return details the client can show or log.
Do not leak stack traces in production responses. Log the real error on the server, but return a controlled JSON message to the client.
Finally, do not break idempotency accidentally. If a PUT request changes timestamps or random fields every time, repeating the same request no longer leaves the resource in the same final state.
Next steps and CTA
The safest next step is one change at a time: add a database, then stronger schema validation, then API documentation, then authentication. For broader design ideas, read Claude Code error handling patterns.
If you want to practice this with your own project, keep the free cheat sheet open while prompting Claude Code. For reusable prompts, review checklists, and CLAUDE.md templates, use the Claude Code product library. For team onboarding, API review rules, and permission design, use Claude Code training and consulting.
After trying the content from this article in practice, the biggest beginner breakthrough was not the CRUD code itself. It was seeing success and failure side by side: empty title returns 400, unknown IDs return 404, and repeated PUT requests preserve the same final state. That makes REST feel less like a pile of URLs and more like a contract between frontend and backend.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
Claude Code Quick Reference Cheatsheet
A free one-page reference for daily Claude Code work.
Keep the essential commands, file-reference patterns, CLAUDE.md reminders, prompting habits, review cues, and debugging workflow notes next to your editor.
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.