Build a REST API from Scratch
Build a REST API from Scratch
In this lesson, you will build a fully functional REST API from an empty directory to a tested, production-ready codebase. The twist: you will do it entirely through conversation with Claude Code. Every step — scaffolding, coding, validation, database integration, error handling, and testing — will be driven by natural language prompts.
This is where everything you have learned about Claude Code comes together in a real project.
Why This Matters
Building an API from scratch is one of the most common tasks a developer faces. It is also one of the best demonstrations of what Claude Code can do. Instead of spending time looking up boilerplate, searching for library docs, or debugging configuration issues, you can focus on what you want the API to do and let Claude Code handle the how.
By the end of this lesson, you will have:
- A running Express.js server with TypeScript
- CRUD endpoints for a
/usersresource - Input validation with Zod
- A SQLite database for persistence
- Centralized error handling middleware
- A full test suite covering every endpoint
Let's begin.
Step 1: Create a New Express.js Project with TypeScript
Start by opening your terminal and navigating to where you want the project to live. Then launch Claude Code:
mkdir my-api && cd my-api
claude
Once inside the Claude Code session, give it your first instruction:
Create a new Express.js project with TypeScript. Set up the package.json,
tsconfig.json, and a basic src/index.ts that starts the server on port 3000.
Use ts-node-dev for development. Install all necessary dependencies.
Claude Code will:
- Initialize a
package.jsonwithnpm init - Install
express,typescript,ts-node-dev, and type definitions - Create a
tsconfig.jsonconfigured for Node.js - Write a minimal
src/index.tswith an Express server - Add
devandbuildscripts topackage.json
After Claude Code finishes, verify the setup:
npm run dev
You should see something like:
Server running on http://localhost:3000
Press Ctrl+C to stop the server and return to Claude Code.
What Just Happened
In a single prompt, Claude Code handled what normally takes 10-15 minutes of manual setup. It chose sensible defaults for the TypeScript configuration, picked compatible package versions, and wired everything together. This is the power of starting a project with Claude Code — no more copy-pasting boilerplate from old projects.
Step 2: Add a /users Endpoint with CRUD Operations
Now tell Claude Code what you want the API to do:
Add a /users resource with full CRUD operations:
- GET /users — list all users
- GET /users/:id — get a single user
- POST /users — create a new user (name and email fields)
- PUT /users/:id — update a user
- DELETE /users/:id — delete a user
Use a separate router file at src/routes/users.ts.
For now, store users in an in-memory array.
Claude Code will create the router file, define all five endpoints, set up proper HTTP status codes (200, 201, 404, etc.), and wire the router into the main src/index.ts file.
Watch how Claude Code structures the code. It will typically:
- Create a
Userinterface withid,name, andemailfields - Use an array as a temporary data store
- Generate unique IDs (often with a simple counter or
crypto.randomUUID()) - Return appropriate status codes for each operation
- Add JSON body parsing middleware to the Express app
After Claude Code finishes, test the endpoints:
# In another terminal, while the server is running:
curl http://localhost:3000/users
# Should return: []
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
# Should return the created user with an id
curl http://localhost:3000/users
# Should now return the array with Alice in it
Step 3: Add Input Validation with Zod
Raw user input should never be trusted. Tell Claude Code to add validation:
Add input validation using Zod for the POST and PUT /users endpoints.
Validate that:
- name is a string between 2 and 100 characters
- email is a valid email address
Return 400 with descriptive error messages if validation fails.
Install Zod if it is not already installed.
Claude Code will:
- Install the
zodpackage - Create a Zod schema for user input (likely in a
src/schemas/directory or inline) - Update the POST and PUT handlers to parse input through the schema
- Return structured error responses when validation fails
Test the validation:
# Missing email — should return 400
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Bob"}'
# Invalid email — should return 400
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Bob", "email": "not-an-email"}'
# Name too short — should return 400
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "B", "email": "bob@example.com"}'
Each of these should return a 400 status with a clear error message explaining what went wrong.
Step 4: Connect to a SQLite Database
In-memory storage disappears when the server restarts. Let's add real persistence:
Replace the in-memory array with a SQLite database using better-sqlite3.
Create a users table with id (integer primary key autoincrement), name (text),
email (text unique), and created_at (text default current_timestamp).
Update all CRUD operations to use the database.
Create the database file at ./data/database.sqlite.
Make sure the data directory is created if it does not exist.
Claude Code will handle a surprising amount of work here:
- Install
better-sqlite3and its TypeScript types - Create a database initialization module that ensures the
data/directory exists - Create the
userstable with the specified schema - Rewrite every route handler to use SQL queries instead of array operations
- Handle the
UNIQUEconstraint on email (returning 409 Conflict on duplicates)
This is a great example of Claude Code's ability to refactor across multiple files. It understands that switching from in-memory to database storage requires changes in the route handlers, the data models, and potentially the error handling.
Test persistence:
# Create a user
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Charlie", "email": "charlie@example.com"}'
# Stop the server (Ctrl+C) and restart it
npm run dev
# The user should still be there
curl http://localhost:3000/users
# Should return Charlie
Step 5: Add Error Handling Middleware
Right now, if something unexpected goes wrong, Express returns a raw error. Let's fix that:
Add centralized error handling middleware to the Express app.
Create a custom AppError class with statusCode and message.
The error handler should:
- Catch all errors thrown in route handlers
- Return JSON error responses with status code and message
- Log errors to the console in development
- Never expose stack traces in the response
Also wrap async route handlers so rejected promises are caught automatically.
Claude Code will typically create:
- A
src/errors/AppError.tsfile with the custom error class - An
src/middleware/errorHandler.tsfile with the Express error middleware - An async wrapper utility (sometimes called
asyncHandlerorcatchAsync) - Updates to all route handlers to use the async wrapper and throw
AppErrorinstances
The error handling middleware is registered after all routes in src/index.ts:
# Test with a non-existent user
curl http://localhost:3000/users/99999
# Should return: { "error": "User not found" } with status 404
# Test with duplicate email
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Charlie Again", "email": "charlie@example.com"}'
# Should return: { "error": "Email already exists" } with status 409
Step 6: Write Tests for All Endpoints
The final step is making sure everything works reliably:
Write tests for all /users endpoints using Jest and supertest.
Create a test file at src/__tests__/users.test.ts.
Test all CRUD operations including:
- Creating a user successfully
- Creating a user with invalid data (validation errors)
- Getting all users
- Getting a single user
- Getting a non-existent user (404)
- Updating a user
- Deleting a user
- Creating a user with a duplicate email (409)
Use a separate test database so tests do not affect development data.
Add a test script to package.json.
Claude Code will:
- Install
jest,ts-jest,supertest, and their type definitions - Create a Jest configuration (either in
jest.config.tsorpackage.json) - Write comprehensive tests covering happy paths and error cases
- Set up a test database (usually by overriding the database path via an environment variable)
- Add
beforeAllandafterAllhooks to set up and tear down the test database - Add a
"test"script topackage.json
Run the tests:
npm test
You should see output like:
PASS src/__tests__/users.test.ts
Users API
POST /users
✓ creates a user successfully (45 ms)
✓ returns 400 for missing name (12 ms)
✓ returns 400 for invalid email (8 ms)
✓ returns 409 for duplicate email (15 ms)
GET /users
✓ returns all users (10 ms)
GET /users/:id
✓ returns a single user (9 ms)
✓ returns 404 for non-existent user (7 ms)
PUT /users/:id
✓ updates a user successfully (14 ms)
✓ returns 404 for non-existent user (6 ms)
DELETE /users/:id
✓ deletes a user successfully (11 ms)
✓ returns 404 for non-existent user (5 ms)
Test Suites: 1 passed, 1 total
Tests: 11 passed, 11 total
The Full Conversation Flow
Here is what the entire session looked like — six prompts, one working API:
| Step | Your Prompt (summarized) | What Claude Code Did |
|---|---|---|
| 1 | "Create Express + TypeScript project" | Scaffolded project with all config |
| 2 | "Add /users CRUD endpoints" | Created router with 5 endpoints |
| 3 | "Add Zod validation" | Installed Zod, added schemas, wired validation |
| 4 | "Connect to SQLite" | Installed better-sqlite3, rewrote data layer |
| 5 | "Add error handling" | Created AppError class, middleware, async wrapper |
| 6 | "Write tests" | Installed Jest + supertest, wrote 11 tests |
Total time: approximately 15-20 minutes of conversation, compared to potentially 2-3 hours of manual work.
Tips for Guiding Claude Code Through Multi-Step Projects
Be Specific About File Locations
Claude Code makes better decisions when you tell it where to put things. "Create a router at src/routes/users.ts" is better than "add user routes somewhere."
Build Incrementally
Notice how each step built on the previous one. Starting with in-memory storage and then switching to SQLite is easier than asking for everything at once. Claude Code handles refactoring well, but smaller, focused changes are more predictable.
Verify Between Steps
After each major change, test that things work before moving on. If something is wrong, it is much easier to fix when Claude Code's context is still focused on that specific change.
Use Follow-Up Prompts for Adjustments
If Claude Code's output is not quite right, do not start over. Instead, tell it what to change:
The error response format should be { "error": { "code": "VALIDATION_ERROR", "message": "..." } }
instead of just { "error": "..." }. Update the error handler and tests.
Claude Code will adjust without losing the context of what it already built.
Ask Claude Code to Commit
When you are happy with a step, ask Claude Code to commit the work:
Commit the current changes with a descriptive message
This keeps your git history clean and makes it easy to revert if a later step goes wrong.
Key Takeaway
You went from an empty directory to a fully tested REST API with validation, database persistence, and error handling — all through natural language conversation. The project has proper structure, follows common patterns, and the tests prove it works.
This is the core promise of Claude Code for real-world development: you describe what you want, and Claude Code figures out how to build it. The more clearly you describe your requirements, the better the result.
In the next lesson, you will tackle the even more common scenario: adding features to a project that already exists.
Quiz
Discussion
Sign in to join the discussion.

