Tips & Tricks (Updated: 6/3/2026)

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.

Build Your First REST API with Claude Code: CRUD, Validation, and Tests

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.

TermPlain meaningExample here
EndpointThe URL entry point of an APIGET /todos
MethodThe HTTP verb that says what you want to doGET, POST, PUT, DELETE
Status codeA number that reports the result200, 201, 400, 404
JSONA lightweight text format for sending data{ "title": "Learn REST" }
ValidationChecking whether input is acceptableRejecting an empty title
IdempotencyRepeating the same request leaves the resource in the same final stateSending 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.

ActionMethod and URLSuccess status
Health checkGET /health200 OK
List TodosGET /todos200 OK
Read one TodoGET /todos/:id200 OK
Create TodoPOST /todos201 Created
Replace TodoPUT /todos/:id200 OK
Delete TodoDELETE /todos/:id204 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.

#claude-code #rest-api #beginner #typescript #backend #tutorial
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.