Learn the REST conventions for naming URLs, picking HTTP methods and status codes, nesting resources and shaping JSON responses, with a complete Express 5 router.

Bishal Biswas
Self Learner
What you will learn
- What REST means in plain words and why most APIs follow it
- How to name URLs around resources (nouns), not actions (verbs)
- Which HTTP method and status code to use for each operation
- How to handle nested resources and actions that do not fit neatly
- How to build a clean, complete REST router in Express 5
What is REST?
REST (Representational State Transfer) is a style for designing web APIs. It is not a library or a standard you install. It is a set of conventions that most developers recognise, so they can guess how your API works before reading the docs.
The core idea is simple:
- Your API exposes resources: things like books, users or orders.
- Each resource has a URL.
- You use HTTP methods (
GET,POST,PATCH,DELETE…) to say what to do with it.
Think of it like a library. The shelves are labelled with things (“Books”, “Magazines”). You do not walk up to a shelf labelled “Borrow”. Instead, you go to the right shelf and do something: look, take, return. In REST, the URL is the shelf (a noun), and the HTTP method is what you do (the verb).
Resources and URLs
A resource is any “thing” your API manages. A collection is the list of all of them (/books), and an item is one of them (/books/42).
| Good | Avoid | Why |
|---|---|---|
GET /books |
GET /getAllBooks |
The method already says “get” |
POST /books |
POST /books/create |
POST to a collection means “create” |
GET /books/42 |
GET /book?id=42 |
Item ids belong in the path |
DELETE /books/42 |
POST /deleteBook/42 |
Use the method that matches the action |
/books |
/book |
Use plural nouns, consistently |
/reading-lists |
/readingLists, /reading_lists |
Lowercase, hyphen-separated paths are the common choice |
A few more conventions:
- No trailing slashes:
/books, not/books/. - No file extensions:
/books/42, not/books/42.json. TheContent-Typeheader says it is JSON. - Use
camelCasefor JSON field names (createdAt), because that is what JavaScript clients expect.
HTTP methods map to actions
Most resources need the same five operations, often called CRUD: Create, Read, Update, Delete.
| Method and path | Action | Success status |
|---|---|---|
GET /books |
List books | 200 OK |
GET /books/42 |
Read one book | 200 OK |
POST /books |
Create a book | 201 Created |
PUT /books/42 |
Replace a book completely | 200 OK |
PATCH /books/42 |
Change some fields of a book | 200 OK |
DELETE /books/42 |
Delete a book | 204 No Content |
PUT vs PATCH
Both update, but differently.
PUTreplaces the whole resource. The client sends every field. Missing fields are cleared or rejected.PATCHchanges only the fields the client sends. Everything else stays as it was.
PATCH /books/42 HTTP/1.1
Content-Type: application/json
{ "pages": 320 }
That request changes only pages. Most modern APIs offer PATCH, and many skip PUT entirely. It is fine to do the same.
Safe and idempotent methods
Two words appear a lot in API design.
- A safe method does not change anything on the server.
GETandHEADare safe. - An idempotent method has the same effect whether you send it once or ten times. Deleting book 42 twice still leaves book 42 deleted.
| Method | Safe? | Idempotent? |
|---|---|---|
GET |
Yes | Yes |
PUT |
No | Yes |
PATCH |
No | Not guaranteed |
DELETE |
No | Yes |
POST |
No | No (two POSTs create two books) |
Why care? Browsers, proxies and retry logic assume these rules. If your GET /books/42/delete route deletes things, a link preview or web crawler could wipe your data just by visiting the URL. Never change data in a GET handler.
Status codes that tell the truth
Pick the status code that matches what happened. Clients rely on it more than on your message text.
| Situation | Status |
|---|---|
| Request worked, here is the data | 200 OK |
| New resource created | 201 Created (plus a Location header) |
| Worked, nothing to send back | 204 No Content |
| Body or query is invalid | 400 Bad Request |
| Not logged in | 401 Unauthorized |
| Logged in, but not allowed | 403 Forbidden |
| Resource does not exist | 404 Not Found |
| Conflicts with current state (duplicate email) | 409 Conflict |
| Server bug | 500 Internal Server Error |
The Location header on a 201 response tells the client the URL of the thing it just created:
HTTP/1.1 201 Created
Location: /books/43
Content-Type: application/json; charset=utf-8
{ "data": { "id": 43, "title": "Dune", "author": "Frank Herbert" } }
Nested resources
Some resources belong to others. A review belongs to a book. Show that relationship in the URL:
GET /books/42/reviews list reviews of book 42
POST /books/42/reviews add a review to book 42
GET /reviews/7 read review 7 directly
DELETE /reviews/7 delete review 7
Tip: Keep nesting to one level.
/authors/3/books/42/reviews/7is long and forces the client to know three ids. Once an item has its own id, give it a short top-level URL like/reviews/7.
In Express, nested routers need mergeParams: true to read the parent’s params, as you saw in the routers lesson:
// routes/reviews.js
import { Router } from 'express';
const router = Router({ mergeParams: true }); // see :bookId from the parent
router.get('/', (req, res) => {
res.json({ data: [], bookId: req.params.bookId });
});
export default router;
// routes/books.js
import reviewsRouter from './reviews.js';
router.use('/:bookId/reviews', reviewsRouter);
Actions that do not fit CRUD
Sometimes you need an action, like “cancel an order” or “publish a post”. You have two good options.
Option 1: change a field with PATCH. If “publish” just means status becomes published, let the client send that:
PATCH /posts/9
Content-Type: application/json
{ "status": "published" }
Option 2: a sub-resource for the action. If the action has side effects (sending emails, charging money), a dedicated POST is clearer:
POST /orders/15/cancel
POST /users/3/password-reset
Both are widely used. Avoid inventing verbs at the top level, like POST /cancelOrder.
Consistent response shapes
Clients write code against your response format, so keep it the same everywhere. One common pattern wraps results in a data property and errors in an error property:
{ "data": { "id": 42, "title": "Dune" } }
{ "data": [{ "id": 42, "title": "Dune" }], "meta": { "page": 1, "total": 1 } }
{ "error": { "message": "Book not found" } }
The wrapper leaves room to add things later, such as pagination info in meta, without breaking clients.
Filtering, sorting and pages use the query string
The path identifies which resource. The query string refines a list:
GET /books?author=herbert&sort=-year&page=2&limit=20
You will build exactly this in Pagination, Filtering and Sorting. For now, just remember: do not create separate routes like /books/by-author/herbert.
Putting it together: a books router
Here is a complete REST router with an in-memory store. A store here is just a Map that holds data while the server runs. The next two lessons replace it with a real database.
// routes/books.js
import { Router } from 'express';
const router = Router();
const books = new Map();
let nextId = 1;
// Small helper: find the book or send 404
const findBook = (req, res) => {
const book = books.get(Number(req.params.id));
if (!book) res.status(404).json({ error: { message: 'Book not found' } });
return book;
};
const isValidBook = (body) =>
typeof body?.title === 'string' && body.title.trim() !== '' &&
typeof body?.author === 'string' && body.author.trim() !== '';
router.route('/')
.get((req, res) => {
res.json({ data: [...books.values()] });
})
.post((req, res) => {
if (!isValidBook(req.body)) {
return res.status(400).json({ error: { message: 'title and author are required' } });
}
const book = { id: nextId++, title: req.body.title, author: req.body.author };
books.set(book.id, book);
res.status(201).location(`/books/${book.id}`).json({ data: book });
});
router.route('/:id')
.get((req, res) => {
const book = findBook(req, res);
if (book) res.json({ data: book });
})
.put((req, res) => {
const book = findBook(req, res);
if (!book) return;
if (!isValidBook(req.body)) {
return res.status(400).json({ error: { message: 'PUT needs title and author' } });
}
const replaced = { id: book.id, title: req.body.title, author: req.body.author };
books.set(book.id, replaced);
res.json({ data: replaced });
})
.patch((req, res) => {
const book = findBook(req, res);
if (!book) return;
const { title, author } = req.body ?? {};
if (title !== undefined) book.title = title;
if (author !== undefined) book.author = author;
res.json({ data: book });
})
.delete((req, res) => {
const book = findBook(req, res);
if (!book) return;
books.delete(book.id);
res.status(204).end();
});
export default router;
router.route(path) lets you chain several methods on one path, so each URL appears only once. Mount it in your app:
// app.js
import express from 'express';
import booksRouter from './routes/books.js';
const app = express();
app.use(express.json());
app.use('/books', booksRouter);
app.use((req, res) => {
res.status(404).json({ error: { message: 'Route not found' } });
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('API on http://localhost:3000');
});
Note: The checks above are deliberately simple. In a real API, use the Zod middleware from Request Validation with Zod, and only copy fields you expect. The
PATCHhandler here, for example, does not check types.
Trying every route
# Create
curl -i -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-d '{"title":"Dune","author":"Frank Herbert"}'
# List and read
curl http://localhost:3000/books
curl http://localhost:3000/books/1
# Partial update
curl -X PATCH http://localhost:3000/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"Dune (Deluxe Edition)"}'
# Delete, then confirm it is gone
curl -i -X DELETE http://localhost:3000/books/1
curl -i http://localhost:3000/books/1
The first command shows the 201 status and Location: /books/1 header. The last shows 404 Not Found.
Note: These commands use bash syntax. On Windows, run them in Git Bash or WSL. In PowerShell, type
curl.exeinstead ofcurland adjust the quotes.
Common mistakes
- Verbs in URLs, like
/getBooksor/books/delete/42. Let the HTTP method be the verb. - Returning
200for everything, with{ "success": false }in the body. Use real status codes. - Changing data in a
GETroute. Crawlers and prefetching browsers will trigger it. - Mixing styles:
/bookshere,/Authorthere,user_idin one response anduserIdin another. Pick conventions and stick to them. - Deep nesting that forces clients to know many ids.
Key takeaways
- REST organises an API around resources (plural nouns in URLs) and HTTP methods (the verbs).
GETreads,POSTcreates,PATCHupdates part,PUTreplaces,DELETEremoves.- Return honest status codes:
201plusLocationon create,204on delete,404when missing. - Nest one level at most, and use
PATCHor aPOSTsub-resource for special actions. - Keep your URL style and response shape consistent across the whole API.
Practice
- Design (on paper) the routes for a blog with posts, comments and tags. Which ones are nested?
- Add an
authorsresource to the books API with all five CRUD routes. - Add
GET /books/:bookId/reviewsandPOST /books/:bookId/reviewsusing a nested router withmergeParams: true. - Rewrite these bad routes the REST way:
GET /fetchUser?id=3,POST /users/3/delete,POST /updateUserEmail.
What’s next
Your routes are designed, but the data disappears when the server restarts. Store it for real in CRUD API with MongoDB and Mongoose.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready