Tips & Tricks

Building a REST API with Claude Code | A Practical Beginner's Guide

Learn REST API fundamentals with Claude Code. A hands-on guide covering endpoint design, validation, and error handling — all with copy-paste ready code.

“I have no idea where to start with REST APIs” — that was me at the beginning too.

Reading documentation felt too abstract. I couldn’t figure out what I was actually supposed to build. Then I started using Claude Code and realized that “build something that works first, then learn” is the fastest approach.

In this guide, we’ll go from zero REST API knowledge to a fully working API — step by step, together with Claude Code. Every code snippet here is copy-paste ready.


What Is a REST API? (In 3 Lines)

A REST API is a convention for manipulating resources (data) on the web via HTTP.

Browser/App  →  HTTP Request  →  Server (API)
             ←  JSON Response  ←

For a user management API, it looks like this:

GoalHTTP MethodURL
Get all usersGET/users
Get a specific userGET/users/123
Create a userPOST/users
Update a userPUT/users/123
Delete a userDELETE/users/123

Once you understand this, you’re ready to start building with Claude Code.


Environment Setup

We’ll use Hono — a lightweight TypeScript web framework. It’s more type-safe than Express and works great with Claude Code.

mkdir my-first-api
cd my-first-api
npm init -y
npm install hono
npm install -D typescript @types/node ts-node
npx tsc --init

Create src/index.ts and ask Claude Code:

claude -p "
Create a REST API boilerplate using Hono in src/index.ts.
- Start server on port 3000
- Add a GET /health endpoint for health checks
- Return JSON responses
Run command: npx ts-node src/index.ts
"

Claude Code will generate something like this:

// src/index.ts
import { Hono } from "hono";
import { serve } from "@hono/node-server";

const app = new Hono();

app.get("/health", (c) => {
  return c.json({ status: "ok", timestamp: new Date().toISOString() });
});

serve({ fetch: app.fetch, port: 3000 }, () => {
  console.log("Server running at http://localhost:3000");
});
npx ts-node src/index.ts
# → Server running at http://localhost:3000

curl http://localhost:3000/health
# → {"status":"ok","timestamp":"2026-04-27T07:30:00.000Z"}

Did it work? That’s your starting point for REST APIs.


Step 1: Build Basic CRUD

Next, let’s build an API to manage a Todo list. Ask Claude Code:

claude -p "
Add Todo CRUD endpoints to src/index.ts.
- GET  /todos         — List all todos
- GET  /todos/:id     — Get one todo
- POST /todos         — Create (title: string required)
- PUT  /todos/:id     — Update (title: string)
- DELETE /todos/:id   — Delete

Manage data in an in-memory array.
Todo type: { id: string, title: string, done: boolean, createdAt: string }
Generate id with crypto.randomUUID()
"

Generated code:

import { Hono } from "hono";
import { serve } from "@hono/node-server";

const app = new Hono();

// In-memory data store
type Todo = { id: string; title: string; done: boolean; createdAt: string };
let todos: Todo[] = [];

// Get all todos
app.get("/todos", (c) => c.json(todos));

// Get one todo
app.get("/todos/:id", (c) => {
  const todo = todos.find((t) => t.id === c.req.param("id"));
  if (!todo) return c.json({ error: "Not found" }, 404);
  return c.json(todo);
});

// Create
app.post("/todos", async (c) => {
  const body = await c.req.json();
  if (!body.title) return c.json({ error: "title is required" }, 400);
  const todo: Todo = {
    id: crypto.randomUUID(),
    title: body.title,
    done: false,
    createdAt: new Date().toISOString(),
  };
  todos.push(todo);
  return c.json(todo, 201);
});

// Update
app.put("/todos/:id", async (c) => {
  const idx = todos.findIndex((t) => t.id === c.req.param("id"));
  if (idx === -1) return c.json({ error: "Not found" }, 404);
  const body = await c.req.json();
  todos[idx] = { ...todos[idx], ...body };
  return c.json(todos[idx]);
});

// Delete
app.delete("/todos/:id", (c) => {
  const idx = todos.findIndex((t) => t.id === c.req.param("id"));
  if (idx === -1) return c.json({ error: "Not found" }, 404);
  todos.splice(idx, 1);
  return c.json({ message: "Deleted" });
});

serve({ fetch: app.fetch, port: 3000 }, () => {
  console.log("Server running at http://localhost:3000");
});

Try it out:

# Create a Todo
curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Try Claude Code"}'

# Get all todos
curl http://localhost:3000/todos

# Delete (use the id returned above)
curl -X DELETE http://localhost:3000/todos/<id>

Step 2: Add Validation

“An empty title still gets created” or “Any string works as an ID” — validation prevents these issues. Ask Claude Code:

claude -p "
Add validation using zod for POST /todos and PUT /todos/:id.
- title: string, 1-100 characters
- done: (PUT only) boolean
Return 400 error with a specific error message on validation failure
"
npm install zod

Claude Code adds zod schemas:

import { z } from "zod";

const CreateTodoSchema = z.object({
  title: z.string().min(1, "Title must be at least 1 character").max(100, "Title must be 100 characters or less"),
});

const UpdateTodoSchema = z.object({
  title: z.string().min(1).max(100).optional(),
  done: z.boolean().optional(),
});

// POST /todos (with validation)
app.post("/todos", async (c) => {
  const body = await c.req.json().catch(() => ({}));
  const result = CreateTodoSchema.safeParse(body);
  if (!result.success) {
    return c.json({ error: result.error.flatten().fieldErrors }, 400);
  }
  const todo: Todo = {
    id: crypto.randomUUID(),
    title: result.data.title,
    done: false,
    createdAt: new Date().toISOString(),
  };
  todos.push(todo);
  return c.json(todo, 201);
});

Check validation errors:

# Create without title → should error
curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{}'
# → {"error":{"title":["Title must be at least 1 character"]}}

Step 3: Unify Error Handling

Right now, each endpoint returns errors in different formats. Let Claude Code standardize them:

claude -p "
Unify error handling.
- Common error response format: { error: { code: string, message: string } }
- 404: NOT_FOUND
- 400: VALIDATION_ERROR
- 500: INTERNAL_SERVER_ERROR
Implement a global error handler using Hono's onError
"
// Error type definition
class AppError extends Error {
  constructor(
    public code: string,
    public message: string,
    public statusCode: number = 400
  ) {
    super(message);
  }
}

// Global error handler
app.onError((err, c) => {
  if (err instanceof AppError) {
    return c.json(
      { error: { code: err.code, message: err.message } },
      err.statusCode as any
    );
  }
  console.error(err);
  return c.json(
    { error: { code: "INTERNAL_SERVER_ERROR", message: "An unexpected error occurred" } },
    500
  );
});

// Usage
app.get("/todos/:id", (c) => {
  const todo = todos.find((t) => t.id === c.req.param("id"));
  if (!todo) throw new AppError("NOT_FOUND", "Todo not found", 404);
  return c.json(todo);
});

Step 4: Auto-generate Swagger UI Documentation

Once you’ve built your API, you need docs. Claude Code can set it up in minutes:

claude -p "
Use @hono/swagger-ui and @hono/zod-openapi to
add Swagger UI at /docs.
Add OpenAPI schemas to the existing endpoints.
"
npm install @hono/swagger-ui @hono/zod-openapi

Once done, visit http://localhost:3000/docs to see your API documentation.


The Claude Code API Development Workflow (Summary)

Here’s the actual flow I use day-to-day:

1. Tell Claude Code "I need an API that does X"
    ↓
2. Review and test the generated code
    ↓
3. Iterate: "Fix this", "Add validation", etc.
    ↓
4. git commit when tests pass

My first stumbling block (real experience)

When I built my first API, I skipped error handling entirely and had to add it all later — which took forever. Now I just add “include error handling” to my prompt upfront, and the code comes out solid from the start. The trick with Claude Code is giving all requirements at once from the beginning.


3 Common Pitfalls

Pitfall 1: Ignoring JSON parse errors

// ❌ Errors here go unnoticed
const body = await c.req.json();

// ✅ Catch parse failures
const body = await c.req.json().catch(() => null);
if (!body) return c.json({ error: "Invalid JSON" }, 400);

Pitfall 2: Confusing 404 and 400

400: The request itself is wrong (validation error, missing required field)
404: The resource doesn't exist (ID not found)
422: Request format is correct but can't be processed

Ask Claude Code “should I return 404 or 400 here?” and it will give you the right answer.

Pitfall 3: Storing everything in memory

The code in this guide uses an in-memory array for demo purposes. Data disappears when you restart the server. In production you’ll need a database like PostgreSQL or MongoDB. Just tell Claude Code “store this in SQLite instead of memory” and it’ll handle the migration.


Next Steps

Once you have the REST API basics down, try these:

StepWhat to Do
DatabaseSQLite → PostgreSQL (with Prisma)
AuthenticationAdd JWT token auth
TestingWrite API tests with vitest
DeployPublish to Vercel / Cloudflare Workers

For all of these, just tell Claude Code “add X” and the foundation will be ready in minutes. Build something that works first — that’s the fastest path forward.

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

Level up your Claude Code workflow

50 battle-tested prompt templates you can copy-paste into Claude Code right now.

Free

Free PDF: Claude Code Cheatsheet in 5 Minutes

Just enter your email and we'll send you the single-page A4 cheatsheet right away.

We handle your data with care and never send spam.

Masa

About the Author

Masa

Engineer obsessed with Claude Code. Runs claudecode-lab.com, a 10-language tech media with 2,000+ pages.