Build a REST API with AI: Complete Intermediate Tutorial
You've built frontends. Now build the thing that powers them. In this project, you'll use AI to create a fully working REST API — a bookmarks manager with real endpoints, proper status codes, and everything testable from the command line. No database required.
TL;DR
Build a bookmarks REST API with Express.js — full CRUD: list, get one, create, update, delete. Data lives in memory (no database setup). You'll learn HTTP methods, status codes, middleware, and how to test everything with curl. Stack: Node.js + Express. Time: ~2 hours.
Why Build This?
Every app you've built so far lives in the browser. The to-do app, the weather app, the portfolio — they're all frontend. They might call someone else's API, but they don't have a backend of their own.
Building a REST API changes that. Now you're the server. You decide what data looks like, what URLs exist, what each one does, and what gets sent back. That's a completely different mindset — and it's how the products you actually use are built.
Here's why a bookmarks API is the right project to learn this:
- It's simple enough to finish. Bookmarks have a title, a URL, maybe a category. Not complicated. You can focus on the API structure, not the data model.
- It covers all four CRUD operations. Create, Read, Update, Delete — every API you'll ever build uses these same four actions. Learn them once here, apply them everywhere.
- No database setup required. Data lives in a JavaScript array. When you restart the server, it resets. That's fine for learning — you're here to understand the API shape, not the database layer.
- It's immediately testable. Unlike frontends, you can test every endpoint right from the terminal with curl. No browser, no UI — just you and the API.
- It's what your frontend would call. After this, when your React or vanilla JS app makes a
fetch()call, you'll understand exactly what's on the other end.
After this project, phrases like "I'll hook it up to the API" or "the backend returns a 404" will mean something concrete to you — because you'll have built it yourself.
What You'll Need
- Node.js installed — download from nodejs.org, pick the LTS version. This gives you both
node(the runtime) andnpm(the package manager). Check it works:node --versionin your terminal. - An AI coding tool — Cursor, Claude Code, Windsurf, or ChatGPT. You're going to use AI to write the code, then read this guide to understand what it generated.
- A terminal — Command Prompt on Windows, Terminal on Mac. You'll use it to run
npm install, start the server, and test endpoints with curl. - curl installed — already on Mac and most Linux distros. On Windows, it ships with Git Bash and PowerShell 5.1+. Run
curl --versionto check. - Basic JavaScript knowledge — you should be comfortable with variables, functions, arrays, and objects. You don't need to know Node.js — that's what you're learning here.
What about npm? npm (Node Package Manager) comes bundled with Node.js. It's how you install Express and other packages. You'll use it exactly twice: once to set up the project, once to install Express. That's it.
Step 1: Set Up the Project
Before you write a single line of API code, you need a project folder with Node.js initialized and Express installed. Tell your AI exactly this:
"Set up a new Node.js project for a REST API. Create a folder called bookmarks-api. Run npm init -y to create a package.json. Install express with npm install express. Create a file called index.js that: imports express, creates an app, starts the server on port 3000, and adds one test route GET / that returns JSON: { message: 'Bookmarks API is running' }. Show me each terminal command to run."
What AI Will Generate
Your AI will give you three terminal commands and one file:
# Terminal commands to run in order:
mkdir bookmarks-api
cd bookmarks-api
npm init -y
npm install express
// index.js
const express = require('express')
const app = express()
const PORT = 3000
app.use(express.json()) // Parses JSON request bodies
app.get('/', (req, res) => {
res.json({ message: 'Bookmarks API is running' })
})
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
})
The most important line: app.use(express.json()) is middleware that tells Express to read JSON from request bodies. Without it, when clients send data in POST and PUT requests, req.body will be undefined. Put it near the top, before your routes.
What to Check
Run the server: node index.js. You should see "Server running on http://localhost:3000" in the terminal. Then in a new terminal tab, run:
curl http://localhost:3000/
# Expected output: {"message":"Bookmarks API is running"}
If you get that JSON back, your server is alive and Express is working. Stop the server with Ctrl+C when you're ready to move on.
Step 2: Create the Data and GET All Bookmarks
Every API needs data to work with. Since we're skipping the database, we'll use a plain JavaScript array. Tell your AI to add this:
"Add a bookmarks array to index.js with 3 sample bookmarks. Each bookmark should have: id (number), title (string), url (string), and category (string). Then add a GET /bookmarks route that returns the entire array as JSON with a 200 status code."
What AI Will Generate
// In-memory data store (resets when server restarts)
let bookmarks = [
{ id: 1, title: 'MDN Web Docs', url: 'https://developer.mozilla.org', category: 'reference' },
{ id: 2, title: 'Node.js Docs', url: 'https://nodejs.org/docs', category: 'reference' },
{ id: 3, title: 'Express Guide', url: 'https://expressjs.com/guide', category: 'framework' }
]
// GET /bookmarks — return all bookmarks
app.get('/bookmarks', (req, res) => {
res.status(200).json(bookmarks)
})
What to Check
Restart the server and test:
curl http://localhost:3000/bookmarks
You should get back all three bookmarks as a JSON array. The status(200) is technically optional here (200 is the default), but making it explicit is good habit — it makes your intentions clear to anyone reading the code.
Why let not const? The array itself will be mutated — you'll push, splice, and update items inside it. let makes it clear the variable might change. If you use const and try to reassign the whole array later, you'll get an error.
Step 3: GET a Single Bookmark by ID
Getting all bookmarks is useful, but you also need to fetch one specific bookmark. That's where URL parameters come in — the :id syntax.
"Add a GET /bookmarks/:id route to index.js. It should: parse the id from req.params.id as a number, find the bookmark with that id in the array, return it with status 200 if found, or return a JSON error { error: 'Bookmark not found' } with status 404 if not found."
What AI Will Generate
// GET /bookmarks/:id — return one bookmark
app.get('/bookmarks/:id', (req, res) => {
const id = parseInt(req.params.id)
const bookmark = bookmarks.find(b => b.id === id)
if (!bookmark) {
return res.status(404).json({ error: 'Bookmark not found' })
}
res.status(200).json(bookmark)
})
Let's break down what's happening here:
req.params.id— Express extracts the:idpart from the URL and puts it inreq.params. If the request isGET /bookmarks/2, thenreq.params.idis the string"2".parseInt()— converts"2"(string) to2(number). This matters because the ids in your array are numbers. Comparing"2" === 2in JavaScript returnsfalse.bookmarks.find()— searches the array and returns the first item where the condition is true, orundefinedif nothing matches.return res.status(404).json()— thereturnis important. Without it, Express tries to send a second response after the 404, which causes an error.
What to Check
# Get bookmark with id 2
curl http://localhost:3000/bookmarks/2
# Try an id that doesn't exist
curl http://localhost:3000/bookmarks/99
The first should return the Node.js Docs bookmark. The second should return {"error":"Bookmark not found"} with a 404 status. To see the status code in curl, add -i:
curl -i http://localhost:3000/bookmarks/99
# You'll see: HTTP/1.1 404 Not Found
Step 4: POST — Create a New Bookmark
Reading data is only half the job. Now you need to let clients add new bookmarks. POST requests carry data in the request body — that's where the new bookmark's title, URL, and category come from.
"Add a POST /bookmarks route to index.js. It should: read title, url, and category from req.body, validate that title and url are both present (return 400 with an error message if either is missing), generate a new id by finding the max existing id and adding 1, push the new bookmark to the array, and return the new bookmark with status 201."
What AI Will Generate
// POST /bookmarks — create a new bookmark
app.post('/bookmarks', (req, res) => {
const { title, url, category } = req.body
// Validate required fields
if (!title || !url) {
return res.status(400).json({ error: 'title and url are required' })
}
// Generate new id
const newId = bookmarks.length > 0
? Math.max(...bookmarks.map(b => b.id)) + 1
: 1
const newBookmark = {
id: newId,
title,
url,
category: category || 'uncategorized'
}
bookmarks.push(newBookmark)
res.status(201).json(newBookmark)
})
Why 201 and not 200? HTTP status codes communicate intent. 200 means "here's what you asked for." 201 means "I created something new." APIs that use 201 for POST requests are clearer — clients can check the status code to know whether to update their local data or not.
What to Check
# Create a new bookmark
curl -X POST http://localhost:3000/bookmarks \
-H "Content-Type: application/json" \
-d '{"title":"GitHub","url":"https://github.com","category":"tools"}'
# Try to create one with missing fields
curl -X POST http://localhost:3000/bookmarks \
-H "Content-Type: application/json" \
-d '{"title":"No URL here"}'
The first should return your new bookmark with "id": 4 and status 201. The second should return {"error":"title and url are required"} with status 400.
After creating one, verify it was added:
curl http://localhost:3000/bookmarks
# Should now show 4 bookmarks
Step 5: PUT — Update an Existing Bookmark
PUT requests replace an existing resource. Think of it as "overwrite this bookmark with the data I'm sending." You need the ID in the URL (to find the right bookmark) and the new data in the body.
"Add a PUT /bookmarks/:id route to index.js. It should: find the bookmark by id from req.params.id, return 404 if not found, update the bookmark's title, url, and category with values from req.body (keep existing values for any fields that aren't provided), and return the updated bookmark with status 200."
What AI Will Generate
// PUT /bookmarks/:id — update a bookmark
app.put('/bookmarks/:id', (req, res) => {
const id = parseInt(req.params.id)
const index = bookmarks.findIndex(b => b.id === id)
if (index === -1) {
return res.status(404).json({ error: 'Bookmark not found' })
}
const { title, url, category } = req.body
// Merge: keep existing values for fields not provided
bookmarks[index] = {
...bookmarks[index],
...(title && { title }),
...(url && { url }),
...(category && { category })
}
res.status(200).json(bookmarks[index])
})
The spread operator pattern here is doing something clever:
...bookmarks[index]— copies all existing fields into the new object....(title && { title })— iftitlewas provided in the request body, overwrite the existing title. If it wasn't provided,titleisundefined, andundefined && { title }evaluates toundefined, so nothing is spread in.
The result: only the fields you send actually change. This is called a partial update, and it's much more user-friendly than requiring every field every time.
What to Check
# Update bookmark 1's title
curl -X PUT http://localhost:3000/bookmarks/1 \
-H "Content-Type: application/json" \
-d '{"title":"MDN Web Docs (updated)"}'
# Verify it changed
curl http://localhost:3000/bookmarks/1
# Try updating one that doesn't exist
curl -X PUT http://localhost:3000/bookmarks/99 \
-H "Content-Type: application/json" \
-d '{"title":"Ghost"}'
Step 6: DELETE — Remove a Bookmark
The last CRUD operation. DELETE removes a resource. There's nothing to send in the body — just the ID in the URL. When it works, you typically return status 204 (No Content) with no body at all.
"Add a DELETE /bookmarks/:id route to index.js. It should: find the bookmark by id, return 404 with an error message if not found, remove it from the array using splice, and return status 204 with no body."
What AI Will Generate
// DELETE /bookmarks/:id — remove a bookmark
app.delete('/bookmarks/:id', (req, res) => {
const id = parseInt(req.params.id)
const index = bookmarks.findIndex(b => b.id === id)
if (index === -1) {
return res.status(404).json({ error: 'Bookmark not found' })
}
bookmarks.splice(index, 1)
res.status(204).send()
})
Two things worth noting:
splice(index, 1)— removes 1 element atindex. Unlikefilter(), it modifies the array in place. That's what you want here — the in-memory array needs to actually change.res.status(204).send()— 204 means "success, nothing to return." Don't useres.json()here. By convention, 204 responses have no body. If you sendres.json({})with a 204, some HTTP clients will log a warning.
What to Check
# Delete bookmark 3
curl -X DELETE http://localhost:3000/bookmarks/3
# Verify it's gone
curl http://localhost:3000/bookmarks
# Try to delete it again
curl -X DELETE -i http://localhost:3000/bookmarks/3
# Should return 404 Not Found
Step 7: Add a 404 Catch-All and Error Handler
Right now, if someone hits a URL that doesn't exist — like /api/foo or /bookmarks/delete/all — Express returns its default HTML error page. That's fine for a website, but your API should return JSON. Always.
"Add two things to the bottom of index.js, after all the routes: (1) A 404 handler that catches any request to an unknown URL and returns JSON: { error: 'Not found' } with status 404. (2) A global error handler with (err, req, res, next) signature that logs the error and returns JSON: { error: 'Internal server error' } with status 500. Both handlers must come after all existing routes."
What AI Will Generate
// 404 — route not found (must be AFTER all other routes)
app.use((req, res) => {
res.status(404).json({ error: 'Not found' })
})
// 500 — global error handler (must have 4 params: err, req, res, next)
app.use((err, req, res, next) => {
console.error(err.stack)
res.status(500).json({ error: 'Internal server error' })
})
Order matters in Express. Middleware and routes are processed in the order they're defined. The 404 handler must come after all your routes — if you put it first, it catches everything. The error handler's (err, req, res, next) four-parameter signature is how Express recognizes it as an error handler vs. regular middleware.
What to Check
# Hit a URL that doesn't exist
curl -i http://localhost:3000/api/foo
# Should return: HTTP/1.1 404 Not Found
# Body: {"error":"Not found"}
# Test your whole API one more time end-to-end
curl http://localhost:3000/bookmarks
Your Complete index.js
Here's the full working file, all seven steps combined:
const express = require('express')
const app = express()
const PORT = 3000
// Middleware — parse JSON request bodies
app.use(express.json())
// In-memory data store
let bookmarks = [
{ id: 1, title: 'MDN Web Docs', url: 'https://developer.mozilla.org', category: 'reference' },
{ id: 2, title: 'Node.js Docs', url: 'https://nodejs.org/docs', category: 'reference' },
{ id: 3, title: 'Express Guide', url: 'https://expressjs.com/guide', category: 'framework' }
]
// Health check
app.get('/', (req, res) => {
res.json({ message: 'Bookmarks API is running' })
})
// GET /bookmarks — list all
app.get('/bookmarks', (req, res) => {
res.status(200).json(bookmarks)
})
// GET /bookmarks/:id — get one
app.get('/bookmarks/:id', (req, res) => {
const id = parseInt(req.params.id)
const bookmark = bookmarks.find(b => b.id === id)
if (!bookmark) return res.status(404).json({ error: 'Bookmark not found' })
res.status(200).json(bookmark)
})
// POST /bookmarks — create new
app.post('/bookmarks', (req, res) => {
const { title, url, category } = req.body
if (!title || !url) {
return res.status(400).json({ error: 'title and url are required' })
}
const newId = bookmarks.length > 0 ? Math.max(...bookmarks.map(b => b.id)) + 1 : 1
const newBookmark = { id: newId, title, url, category: category || 'uncategorized' }
bookmarks.push(newBookmark)
res.status(201).json(newBookmark)
})
// PUT /bookmarks/:id — update
app.put('/bookmarks/:id', (req, res) => {
const id = parseInt(req.params.id)
const index = bookmarks.findIndex(b => b.id === id)
if (index === -1) return res.status(404).json({ error: 'Bookmark not found' })
const { title, url, category } = req.body
bookmarks[index] = {
...bookmarks[index],
...(title && { title }),
...(url && { url }),
...(category && { category })
}
res.status(200).json(bookmarks[index])
})
// DELETE /bookmarks/:id — remove
app.delete('/bookmarks/:id', (req, res) => {
const id = parseInt(req.params.id)
const index = bookmarks.findIndex(b => b.id === id)
if (index === -1) return res.status(404).json({ error: 'Bookmark not found' })
bookmarks.splice(index, 1)
res.status(204).send()
})
// 404 — unknown route
app.use((req, res) => {
res.status(404).json({ error: 'Not found' })
})
// 500 — global error handler
app.use((err, req, res, next) => {
console.error(err.stack)
res.status(500).json({ error: 'Internal server error' })
})
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
})
Testing Your API
With all five routes in place, here's a complete test run — copy these one at a time. Each one tests a different endpoint and a different HTTP status code.
Full Test Sequence
# 1. Health check
curl http://localhost:3000/
# → {"message":"Bookmarks API is running"}
# 2. List all bookmarks
curl http://localhost:3000/bookmarks
# → array of 3 bookmarks
# 3. Get bookmark #2
curl http://localhost:3000/bookmarks/2
# → {"id":2,"title":"Node.js Docs",...}
# 4. Get one that doesn't exist
curl -i http://localhost:3000/bookmarks/99
# → 404 Not Found
# 5. Create a bookmark
curl -X POST http://localhost:3000/bookmarks \
-H "Content-Type: application/json" \
-d '{"title":"CSS Tricks","url":"https://css-tricks.com","category":"design"}'
# → {"id":4,"title":"CSS Tricks",...} with 201 status
# 6. Try creating without a URL
curl -X POST http://localhost:3000/bookmarks \
-H "Content-Type: application/json" \
-d '{"title":"Missing URL"}'
# → {"error":"title and url are required"} with 400 status
# 7. Update bookmark #1
curl -X PUT http://localhost:3000/bookmarks/1 \
-H "Content-Type: application/json" \
-d '{"title":"MDN (updated)"}'
# → updated bookmark with 200 status
# 8. Delete bookmark #3
curl -X DELETE -i http://localhost:3000/bookmarks/3
# → 204 No Content (no body)
# 9. Verify deletion
curl http://localhost:3000/bookmarks
# → only 3 bookmarks now (id 3 is gone)
# 10. Hit an unknown route
curl -i http://localhost:3000/api/foo
# → {"error":"Not found"} with 404
Prefer a visual tool? Postman and Insomnia are free apps where you can build and save API requests with a GUI. The VS Code extension "REST Client" lets you write and run requests in a .http file. Ask your AI to generate a test file: "Create a bookmarks-api.http file with REST Client requests for all 5 endpoints."
Deploying Your API
An API running on localhost:3000 only works on your machine. To make it callable from the internet — from a deployed frontend, a mobile app, or anywhere else — you need to deploy it to a server.
Railway is the fastest path from "works on my machine" to "works on the internet" for Node.js apps. It connects to your GitHub repo, detects that it's a Node.js project, and deploys it automatically. No configuration files, no Dockerfiles, no server management.
Deploy to Railway in 5 Steps
- Add a
startscript topackage.json:"scripts": { "start": "node index.js" } - Make PORT dynamic — Railway assigns its own port via an environment variable:
const PORT = process.env.PORT || 3000 - Push your code to GitHub. Create a new repo, add your files, commit and push.
- Connect to Railway — go to railway.app, create a new project, and connect your GitHub repo. Railway detects Node.js automatically.
- Get your URL — Railway gives you a public URL like
https://bookmarks-api-production.up.railway.app. Test it with the same curl commands, swappinglocalhost:3000for your Railway URL.
Remember: in-memory data resets on redeploy. Every time Railway deploys a new version of your app, the server restarts and your bookmarks array goes back to its initial state. That's fine for this project. When you're ready to persist data across restarts, the next step is adding a real database — ask your AI: "Add a SQLite database to this Express API using the better-sqlite3 package."
What Could Go Wrong
Issue: req.body is undefined
Cause: You forgot app.use(express.json()), or it's in the wrong place in the file.
Fix: Make sure this line appears near the top of index.js, before any routes:
app.use(express.json())
Also check that your curl requests include -H "Content-Type: application/json". Without this header, Express won't parse the body even if the middleware is there.
Issue: "Cannot GET /bookmarks" (404 from Express itself)
Cause: The route isn't defined, is spelled wrong, or the server hasn't restarted after you added the route.
Fix: Stop the server (Ctrl+C) and restart it (node index.js). Node.js doesn't hot-reload by default — you have to restart manually every time you change the code. Alternatively, install nodemon and use it instead: npm install -g nodemon then nodemon index.js. It watches your files and restarts automatically.
Issue: Bookmark ID math breaks after deletions
Cause: After deleting bookmark #3, the max ID is 3 again (since bookmark #3 no longer exists). Creating a new bookmark assigns ID 4, which is fine. But if you delete #4 and create again, you get another #4, which could collide.
Fix: Use a counter instead of recalculating from the array:
let nextId = 4 // Start after your seed data
// In POST route:
const newBookmark = { id: nextId++, title, url, category: category || 'uncategorized' }
This is still an in-memory solution (resets on restart), but it's cleaner. A real database handles ID generation automatically.
Issue: "Error: listen EADDRINUSE :::3000"
Cause: Something else is already using port 3000 — usually a previous instance of your server that you didn't stop properly.
Fix: On Mac/Linux: lsof -ti:3000 | xargs kill. On Windows: netstat -ano | findstr :3000, then taskkill /PID [number] /F. Or just change your port to 3001 and restart.
Issue: CORS errors when calling from a browser frontend
Cause: Your Express API and your frontend are on different origins (different ports or domains). Browsers block these requests by default.
Fix: Install the cors package and add it as middleware:
npm install cors
const cors = require('cors')
app.use(cors()) // Add this before your routes
This allows any origin to call your API. For production, restrict it to specific origins: app.use(cors({ origin: 'https://yourdomain.com' })).
What You Learned
You just built a backend. That's not a small thing. Here's a concrete list of what you now know how to do:
- Set up a Node.js project from scratch —
npm init, install packages, create a server file. - Use Express.js to create an HTTP server with routes. You understand what
app.get(),app.post(),app.put(), andapp.delete()actually do. - Read data from requests —
req.paramsfor URL parameters,req.bodyfor request bodies. - Return JSON responses with the right HTTP status codes: 200, 201, 204, 400, 404, 500.
- Use middleware — specifically
express.json(), which sits between the incoming request and your route handler, transforming the raw body into a usable object. - Handle errors properly — both expected ones (404 for missing resources, 400 for bad input) and unexpected ones (the global error handler).
- Test APIs with curl — making GET, POST, PUT, and DELETE requests from the command line, reading status codes with
-i. - Deploy a Node.js app to Railway — making your API accessible from anywhere on the internet.
The in-memory array is the only temporary thing here. Everything else — the route structure, the status codes, the middleware, the error handling — is exactly how production APIs work. When you're ready to add a real database, you swap the array operations for database queries, but the Express layer stays the same.
Frequently Asked Questions
Do I need a database to build a REST API?
No — at least not to get started. This tutorial uses an in-memory array to store bookmarks. Data resets every time you restart the server, but it lets you learn the API structure without setting up a database. Once you understand CRUD routes and HTTP methods, adding a real database (like SQLite or MongoDB) is a separate learnable step. Ask your AI: "Replace the in-memory array in this Express API with a SQLite database using better-sqlite3."
What is the difference between a REST API and a regular website?
A regular website serves HTML pages that browsers render visually. A REST API serves JSON data that other programs consume — whether that's your own frontend JavaScript, a mobile app, or another server. Your bookmarks API doesn't have a homepage or buttons. It has endpoints like GET /bookmarks that return structured data. The consumer decides how to display it.
Why does Express.js need express.json() middleware?
When a client sends data in the body of a POST or PUT request, Express receives it as raw bytes — not a JavaScript object. The express.json() middleware reads those bytes, parses them as JSON, and puts the result in req.body so your route handlers can use it. Without it, req.body is undefined. It's one line — app.use(express.json()) — but forgetting it is one of the most common bugs when starting with Express.
What HTTP status codes should my API return?
The most important ones for a CRUD API: 200 OK (successful GET or PUT), 201 Created (successful POST — something new was made), 204 No Content (successful DELETE — nothing to return), 400 Bad Request (client sent bad data), 404 Not Found (the resource doesn't exist), and 500 Internal Server Error (something broke on your end). Always be specific — returning 200 for an error confuses clients and breaks error handling in frontends. Read more in What Are HTTP Status Codes?
How do I test my API without building a frontend?
Use curl from your terminal — it's a command-line tool for making HTTP requests. curl http://localhost:3000/bookmarks sends a GET request. For POST with data: curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"title":"Google","url":"https://google.com"}'. You can also use Postman (free app) or the REST Client VS Code extension for a more visual experience. Ask your AI: "Generate a REST Client .http file with requests for all 5 endpoints of my bookmarks API."