Most Node.js back ends are APIs: servers that send and receive JSON so that web front ends, mobile apps and other services can work with data. The most common style is REST (Representational State Transfer). A REST API is organised around resources, the nouns of your system such as products, orders and users, and uses standard HTTP methods to act on them.
REST's power is its predictability. If you know an API has products, you can guess that GET /products lists them, GET /products/42 fetches one, POST /products creates one, PATCH /products/42 updates part of one and DELETE /products/42 removes it. Status codes report what happened, so 404 always means "not found" and 201 always means "created". Clients, documentation tools and other developers all benefit from this shared language.
In this lesson you'll build a complete products API with every CRUD operation, correct status codes, pagination, filtering and sorting, and a consistent format for errors. You'll also separate the data logic into a service module so routes stay short. Later lessons add validation and a real database.
Resources, methods and status codes
| Operation | Method and URL | Success status |
|---|---|---|
| List | GET /products | 200 with an array |
| Read one | GET /products/:id | 200, or 404 |
| Create | POST /products | 201 with the new item and a Location header |
| Replace | PUT /products/:id | 200 with the updated item |
| Update part | PATCH /products/:id | 200 with the updated item |
| Delete | DELETE /products/:id | 204 with no body |
- Use plural nouns for collections and IDs for single items; avoid verbs in URLs like
/getProducts. - PUT replaces the whole resource with what's sent; PATCH changes only the fields sent.
- Related data can be nested:
GET /orders/17/items. - Version public APIs with a prefix such as
/api/v1so you can change them later without breaking clients.
Pagination, filtering and sorting
Lists can be huge, so let clients ask for one page at a time and narrow results with query parameters:
// GET /products?category=kitchen&sort=-price&page=2&limit=10
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Number(req.query.limit) || 20);
const start = (page - 1) * limit;
const items = filtered.slice(start, start + limit);
res.json({ data: items, page, limit, total: filtered.length });- Always set a maximum limit so one request can't ask for a million rows.
- A common sorting convention is
sort=pricefor ascending andsort=-pricefor descending. - Return paging details such as
totalalongside the data, so clients can show "page 2 of 5".
Consistent responses and thin routes
Pick one shape for errors and use it everywhere, for example { "error": { "message": "Product not found" } }. Clients can then handle every failure the same way.
Keep route handlers short: they read the request, call a service function and send the response. The service holds the real logic (finding, creating, updating) and later talks to the database. This split makes logic easy to test without HTTP and lets you swap the storage without touching routes.
Node.js Web Syntax
REST routes
router.get('/', list); // GET /api/v1/products?page=1&limit=20
router.get('/:id', show); // GET /api/v1/products/42
router.post('/', create); // POST /api/v1/products -> 201
router.put('/:id', replace); // PUT /api/v1/products/42
router.patch('/:id', update); // PATCH /api/v1/products/42
router.delete('/:id', remove); // DELETE /api/v1/products/42 -> 204
res.status(201).location(`/api/v1/products/${p.id}`).json(p);
res.status(404).json({ error: { message: 'Product not found' } });
app.use('/api/v1/products', router);Node.js Web Summary
- REST APIs model resources with plural noun URLs and act on them with HTTP methods.
- GET reads, POST creates (201), PUT replaces, PATCH updates part, DELETE removes (204).
- Return 404 for missing items and a consistent JSON error shape for every failure.
- Support pagination with page and limit, capped at a maximum, plus filtering and sorting.
- Keep routes thin and put logic in a service module.
Node.js Web Examples
Each example shows the code, the output it produces and what each part does.
A complete CRUD API
import express from 'express';
const db = [
{ id: 1, name: 'Mug', price: 8.5, category: 'kitchen' },
{ id: 2, name: 'Lamp', price: 24, category: 'home' },
];
let nextId = 3;
const service = {
find: (id) => db.find((p) => p.id === id),
create: (data) => { const p = { id: nextId++, ...data }; db.push(p); return p; },
update: (id, changes) => Object.assign(service.find(id) ?? {}, changes),
remove: (id) => { const i = db.findIndex((p) => p.id === id); return i >= 0 && db.splice(i, 1).length > 0; },
};
const router = express.Router();
const notFound = (res) => res.status(404).json({ error: { message: 'Product not found' } });
router.get('/', (req, res) => res.json(db));
router.get('/:id', (req, res) => service.find(Number(req.params.id)) ? res.json(service.find(Number(req.params.id))) : notFound(res));
router.post('/', (req, res) => {
const p = service.create(req.body);
res.status(201).location(`/api/v1/products/${p.id}`).json(p);
});
router.patch('/:id', (req, res) => {
if (!service.find(Number(req.params.id))) return notFound(res);
res.json(service.update(Number(req.params.id), req.body));
});
router.delete('/:id', (req, res) => (service.remove(Number(req.params.id)) ? res.sendStatus(204) : notFound(res)));
const app = express();
app.use(express.json());
app.use('/api/v1/products', router);
const server = app.listen(0, async () => {
const base = `http://localhost:${server.address().port}/api/v1/products`;
const send = async (method, path = '', body) => {
const r = await fetch(base + path, { method, headers: { 'Content-Type': 'application/json' }, body: body && JSON.stringify(body) });
const text = await r.text();
console.log(method.padEnd(6), path || '/', r.status, r.headers.get('location') ?? '', text);
};
await send('POST', '', { name: 'Pen', price: 1.2, category: 'office' });
await send('PATCH', '/1', { price: 7.5 });
await send('GET', '/1');
await send('DELETE', '/2');
await send('DELETE', '/2');
await send('GET');
server.close();
});POST / 201 /api/v1/products/3 {"id":3,"name":"Pen","price":1.2,"category":"office"}
PATCH /1 200 {"id":1,"name":"Mug","price":7.5,"category":"kitchen"}
GET /1 200 {"id":1,"name":"Mug","price":7.5,"category":"kitchen"}
DELETE /2 204
DELETE /2 404 {"error":{"message":"Product not found"}}
GET / 200 [{"id":1,"name":"Mug","price":7.5,"category":"kitchen"},{"id":3,"name":"Pen","price":1.2,"category":"office"}]Line-by-line explanation
- Lines 9 to 14A small service object holds the data logic, separate from HTTP.
- Line 17One helper produces the same error shape everywhere.
- Lines 21 to 24Creating returns 201 with a Location header pointing at the new item.
- Lines 25 to 28PATCH changes only the fields sent; the rest of the product is kept.
- Line 29DELETE returns 204, or 404 if the item is already gone.
- Line 33The router is mounted under a versioned prefix.
Pagination, filtering and sorting
import express from 'express';
const products = Array.from({ length: 23 }, (_, i) => ({
id: i + 1,
name: `Item ${i + 1}`,
price: ((i * 7) % 30) + 5,
category: i % 3 === 0 ? 'kitchen' : 'home',
}));
const app = express();
app.get('/products', (req, res) => {
let list = products;
if (req.query.category) list = list.filter((p) => p.category === req.query.category);
if (req.query.maxPrice) list = list.filter((p) => p.price <= Number(req.query.maxPrice));
const sort = req.query.sort ?? 'id';
const field = sort.replace('-', '');
const dir = sort.startsWith('-') ? -1 : 1;
list = [...list].sort((a, b) => (a[field] - b[field]) * dir);
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(50, Number(req.query.limit) || 5);
const data = list.slice((page - 1) * limit, page * limit);
res.json({ page, limit, total: list.length, pages: Math.ceil(list.length / limit), data: data.map((p) => `${p.id}:${p.price}`) });
});
const server = app.listen(0, async () => {
const base = `http://localhost:${server.address().port}/products`;
for (const q of ['?page=2', '?category=kitchen&sort=-price&limit=3', '?maxPrice=10&limit=1000']) {
const body = await (await fetch(base + q)).json();
console.log(q, JSON.stringify(body));
}
server.close();
});?page=2 {"page":2,"limit":5,"total":23,"pages":5,"data":["6:10","7:17","8:24","9:31","10:8"]}
?category=kitchen&sort=-price&limit=3 {"page":1,"limit":3,"total":8,"pages":3,"data":["22:32","13:29","4:26"]}
?maxPrice=10&limit=1000 {"page":1,"limit":50,"total":5,"pages":1,"data":["1:5","6:10","10:8","14:6","23:9"]}Line-by-line explanation
- Lines 3 to 8Generated sample data stands in for a database table.
- Lines 13 to 14Each filter applies only when its query parameter is present.
- Lines 16 to 19A leading minus sign means descending order.
- Lines 21 to 22Page defaults to 1 and limit to 5, and limit is capped at 50.
- Line 24Paging details are returned with the data so clients can build page controls.
Testing an API from the command line
curl -i http://localhost:3000/api/v1/products/1
curl -X POST http://localhost:3000/api/v1/products \
-H "Content-Type: application/json" \
-d '{"name":"Notebook","price":3.5}'
curl -X DELETE -i http://localhost:3000/api/v1/products/1HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":1,"name":"Mug","price":8.5}
{"id":4,"name":"Notebook","price":3.5}
HTTP/1.1 204 No ContentLine-by-line explanation
- Line 1-i shows the status line and headers as well as the body.
- Lines 2 to 4-X sets the method, -H adds a header and -d sends a request body.
- Line 5A successful delete returns 204 with no body. Editors such as VS Code can also run requests saved in .http files.
Common Node.js Web Mistakes
Verbs in URLs
app.post('/createProduct', create);
app.get('/deleteProduct/:id', remove); // GET must never change datarouter.post('/', create);
router.delete('/:id', remove);200 for everything
res.json({ success: false, message: 'Not found' }); // status 200 hides the failureres.status(404).json({ error: { message: 'Product not found' } });Returning unlimited lists
app.get('/orders', async (req, res) => res.json(await db.orders.findAll())); // a million rows one dayapp.get('/orders', async (req, res) => {
const limit = Math.min(100, Number(req.query.limit) || 20);
res.json(await db.orders.findPage({ page: Number(req.query.page) || 1, limit }));
});Node.js Web in Real Projects
REST APIs in practice
Public APIs such as Stripe's and GitHub's follow these conventions closely: plural resources, standard status codes, pagination and a consistent error format. Teams describe their API in an OpenAPI file, generate interactive documentation from it, and test endpoints with tools like Postman, Insomnia or VS Code's REST Client. The service-layer structure in this lesson is what lets the same logic serve a REST API, background jobs and tests.
Node.js Web FAQ
Should a validation error return 400 or 422?
Both are common. 400 Bad Request is the general code for any request the server won't process because of the client's input, and it's what most APIs use. Some APIs use 422 Unprocessable Content specifically when the JSON is well formed but the values are invalid. Pick one, document it, and include the details of what was wrong in the response body.
What does idempotent mean for REST API methods?
A method is idempotent if making the same request several times has the same effect as making it once. GET, PUT and DELETE are idempotent: deleting product 42 twice still leaves it deleted. POST isn't, because sending it twice usually creates two items, which is why clients must be careful about retrying POST requests.
How do I return a Location header after creating a resource in Express?
Chain res.location before sending the response: res.location(/products/${product.id}).status(201).json(product). The header tells the client where the new resource lives, and returning the item in the body saves it a second request.
What is the difference between offset and cursor pagination?
Offset pagination uses page and limit (or offset), which is simple and lets users jump to any page, but gets slower on very large tables and can skip or repeat items when data changes between requests. Cursor pagination returns a token pointing at the last item seen, and the client asks for items after it. Feeds and large datasets usually use cursors; admin tables with page numbers usually use offsets.
What is the difference between REST and GraphQL?
A REST API has many URLs, one for each resource, and the server decides what each response contains. A GraphQL API usually has one endpoint, and the client sends a query describing exactly which fields it wants, including related data, in a single request. REST is simpler to build, cache and debug; GraphQL suits front ends that need flexible, nested data from many sources.
Node.js Web Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the line so a new product returns the Created status.
res.status().json(product);
Which method and URL should fetch product 42?
What's the difference between PUT and PATCH?
With 23 items and limit=5, how many pages are there?
Why cap the limit query parameter at a maximum?
Node.js Web Quiz
5 questions to check what you learned.
Which status code fits a successful DELETE with no body?
- 204
- 200
- 201
- 404
What should a POST response include besides the new item?
- A redirect
- A Location header with the new item's URL
- The full list
- Nothing
Why put data logic in a service module instead of in routes?
- Express requires it
- It keeps routes thin and lets the logic be tested and reused without HTTP
- It makes responses faster
- Services are needed for JSON
What does sort=-price usually mean?
- Sort by price, highest first
- Remove the price field
- Sort by price, lowest first
- Filter out negative prices
Why version an API with a prefix like /api/v1?
- It's faster
- Browsers require it
- To hide the API
- Breaking changes can go into v2 while existing clients keep using v1