What is a REST API?
REST is a set of conventions for building web APIs, not a library you install. The core idea is that your API exposes resources — things, named with nouns — and that you act on them using the HTTP methods that already exist. A book is a resource living at /books/42; you read it with GET, replace it with PUT, remove it with DELETE.
The alternative, which you will still meet in older systems, is to invent a verb for every operation: /getBook, /updateBookTitle, /deleteBookById. That works, and it grows into a list of a hundred endpoint names nobody can guess and everybody must look up. REST's appeal is that once you know a resource's URL, you already know most of its API, because the verbs are fixed and shared across every API in the world.
Stateless is the word that appears in every definition and is rarely explained. It means each request must carry everything needed to understand it, and the server keeps no memory of what this client did a moment ago. In practice that is why an API sends a token with every single request rather than logging in once and being "inside". The payoff is that any server can answer any request, so three copies behind a load balancer need share nothing at all.
The R in REST stands for representational, which means the client receives a representation of the resource rather than the thing itself. The same book could be sent as JSON to a mobile app and as HTML to a browser; the book is the resource, JSON is one way of describing it. In practice nearly every API you write will speak JSON and nothing else, and that is perfectly fine.
One honest note. Very few real APIs are strictly RESTful, and most of the ones that are did not need to be. What matters for your project and your interview is the practical core: noun URLs, correct methods, correct status codes, stateless requests, JSON in and JSON out. Explain that clearly and you sound like somebody who has built an API rather than somebody who has memorised its definition.
- Resources are nouns —
/books,/users,/orders, never/getBooks - The verb is the HTTP method, not a word inside the URL
- Stateless — every request carries its own credentials and context
- JSON is the usual representation, sent with the right
Content-Type - The status code, not the body, tells the client what happened
- Predictable: knowing one resource's URL tells you most of the rest of its API
// RESTful URL conventions for a 'users' resource:
//
// GET /api/users — Get all users
// GET /api/users/42 — Get user with ID 42
// POST /api/users — Create a new user
// PUT /api/users/42 — Update user 42 (full replace)
// PATCH /api/users/42 — Partially update user 42
// DELETE /api/users/42 — Delete user 42
// Example JSON response:
{
"status": "success",
"data": {
"id": 42,
"name": "Alice",
"email": "alice@example.com"
}
} - "What does stateless mean?" is asked more often than any other REST question, and the weak answer is "the server does not store state". The strong answer says what follows from it: there is no server-side session to keep in sync, so any instance can serve any request and you scale by adding machines — and the price is that authentication must travel with every single request.
HTTP Methods and What They Promise
Each HTTP method carries a promise about what it does. Those promises are what let browsers, caches, proxies and retry logic behave sensibly without knowing anything at all about your application, which is why breaking them causes damage far away from your code.
GET is safe: it must not change anything. This is not a style preference. Browsers pre-fetch links, crawlers follow them, antivirus tools open them and chat apps expand them into previews. An endpoint like /deleteUser?id=5 reachable by GET will eventually be triggered by something that was only trying to be helpful, and the classic version of that story ends with a crawler quietly emptying a database.
GET, PUT and DELETE are idempotent: doing them twice has the same effect as doing them once. DELETE on a book that is already gone leaves it gone. That property is what makes it safe for a client to retry after a timeout, and on mobile networks clients retry constantly. POST is not idempotent — two POSTs create two records, which is exactly why a double-tapped submit button produces duplicate orders.
PUT and PATCH differ in a way that bites. PUT replaces the resource with what you sent, so a PUT that omits a field is asking for that field to be emptied. PATCH changes only the fields you included. Sending a partial object with PUT and then wondering why the description vanished is a common bug; pick one per endpoint, say which in your documentation, and validate accordingly.
Two practical notes. HTML forms can only send GET and POST, which is why fully server-rendered applications use POST for everything while APIs use the whole set. And in Express, a method you never defined produces a 404 rather than a 405 — if you want the more accurate answer, you have to write it yourself.
// Safe: changes nothing. Things you do not control will call this, repeatedly.
app.get('/api/books/:id', getBook);
// NEVER do this — a crawler or a link preview will eventually trigger it
// app.get('/api/books/delete/:id', deleteBook);
// Idempotent: two identical calls leave the same result behind
app.put('/api/books/:id', replaceBook); // full replacement
app.delete('/api/books/:id', deleteBook); // gone stays gone
// NOT idempotent: two calls create two books
app.post('/api/books', createBook);
// PUT replaces — an omitted field is a request to clear it
// PUT /api/books/42 { "title": "Dune" } -> author is now empty
// PATCH /api/books/42 { "title": "Dune" } -> only the title changed
app.patch('/api/books/:id', updateSomeFields);
// Making a create safe to retry: the client sends a key it generated
app.post('/api/orders', async (req, res) => {
const key = req.headers['idempotency-key'];
const existing = key && await findOrderByKey(key);
if (existing) return res.status(200).json(existing); // same answer as before
// ...create the order, storing `key` alongside it
}); - GET — safe: it changes nothing, because things you do not control will call it
- GET, PUT, DELETE — idempotent: repeating them is harmless, so clients may retry
- POST — not idempotent: two calls create two records
- PUT replaces the whole resource; PATCH changes only the fields you sent
- HTML forms can send only GET and POST; APIs can use them all
- An undefined method gives a 404 in Express, not a 405, unless you handle it
- The double-submit problem is worth solving deliberately the moment money or orders are involved. Disabling the button in the browser is not enough, because a flaky network resends requests without asking anyone's permission. The usual server-side answer is the one in the example: the client generates a unique key per attempt, the server stores it with the result, and a repeat of the same key returns the original result instead of creating a second record.
HTTP Status Codes
The status code is the part of your response that machines read. A browser decides from it whether to cache; a frontend decides whether to retry or show an error; a monitoring system decides whether to wake somebody up. Getting it right is not politeness — it is the difference between clients that can behave sensibly and clients that have to guess.
Three families cover almost everything. 2xx means it worked. 4xx means the request was wrong, and sending it again unchanged will fail again. 5xx means the server broke, and retrying later might well succeed. That last distinction is the one that matters most in practice, because it is how a client knows whether to give up or to try once more in a few seconds.
The ones you will use every day are few. 200 for a successful read or update. 201 when you created something, ideally with a Location header pointing at the new resource. 204 when you succeeded and have nothing to say, typically after a DELETE. 400 for a malformed or invalid request. 401 and 403 for the two kinds of "no". 404 when the resource does not exist. 409 for a conflict such as an email that is already registered.
401 and 403 are confused constantly, and the difference is simple once stated plainly. 401 means we do not know who you are — no token, or an expired one. 403 means we know exactly who you are, and you are not allowed to do this. Frontends treat them differently: a 401 sends the user to the login page, a 403 shows "you do not have permission". Return 401 where you meant 403 and a logged-in user gets bounced to a login screen forever.
Two habits to avoid. Returning 200 with an error field in the body, which forces every client to parse your prose instead of reading a number and quietly breaks anything automated. And reaching for 404 whenever something goes wrong because it feels safely vague — a validation failure is a 400, and disguising it wastes the next developer's afternoon, who is quite often you.
const express = require('express');
const app = express();
app.use(express.json());
// 200 OK — Successful GET request
app.get('/api/users', (req, res) => {
res.status(200).json({ users: [] });
});
// 201 Created — and say where the new thing now lives
app.post('/api/users', (req, res) => {
const user = { id: 1, name: req.body.name };
res.status(201)
.location(`/api/users/${user.id}`)
.json(user);
});
// 409 Conflict — the request was valid; the current state says no
app.post('/api/users/register', (req, res) => {
return res.status(409).json({ error: 'That email is already registered' });
});
// 401 vs 403 — the two different kinds of "no"
// 401: we do not know who you are -> the client should log in
// 403: we know, and you may not -> logging in again will not help
// 400 Bad Request — Invalid input
app.post('/api/login', (req, res) => {
if (!req.body.email) {
return res.status(400).json({ error: 'Email is required' });
}
res.json({ message: 'Logged in' });
});
// 404 Not Found
app.get('/api/users/:id', (req, res) => {
const user = null; // not found in DB
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
}); - 200 OK — the request succeeded and there is a body
- 201 Created — something new exists; add a
Locationheader if you can - 204 No Content — it worked and there is nothing to send, typically after a DELETE
- 400 Bad Request — malformed or invalid input
- 401 Unauthorized — we do not know who you are (despite the name, it means unauthenticated)
- 403 Forbidden — we know who you are, and you may not do this
- 404 Not Found — no such resource
- 409 Conflict — valid request, but the current state refuses it (duplicate email)
- 500 Internal Server Error — we broke; the client may reasonably retry later
- The 4xx/5xx split is a promise to your clients, so keep it honest. If a request fails because the user sent nonsense, that is a 4xx and no amount of retrying will help. If it fails because your database was briefly unreachable, that is a 5xx and retrying is exactly the right response. Mislabelling one as the other means clients either hammer you pointlessly or give up on something that would have worked.
Designing URLs and Response Bodies
Plural nouns, used consistently: /books for the collection and /books/42 for one of them, rather than /book in some places and /books in others. The choice is arbitrary and the consistency is the entire point — a client should be able to guess your second endpoint after being shown the first.
Nest only to express ownership, and only one level deep. /users/7/orders is a clear way to say "this user's orders". /users/7/orders/3/items/9/product is a URL nobody will ever type correctly. Once a resource has an identity of its own, give it a top-level path and let /orders/3 stand by itself.
Filtering, sorting and pagination belong in the query string, because none of them change which resource you are addressing: /books?author=orwell&sort=-createdAt&page=2&limit=20. Return enough metadata for the client to build its next request — a total count, or the current page — because a list that has no idea of its own size cannot render a pager.
For the body, send what the client needs and nothing that would embarrass you. A user row from your database very probably contains a password hash, an internal flag and a soft-delete column. Choose the fields deliberately instead of passing the row straight through: res.json(user) on a document fresh from the database is precisely how a password hash ends up in a public response, and it is a two-line mistake.
A few small consistencies make an API pleasant to use. One date format everywhere — ISO 8601 strings are the safe choice. One naming style for keys. A list endpoint that returns an empty array rather than null when there is nothing, so the client can always call .map. And the same field names in the object you accept as in the object you return. Deciding these once at the start is free; deciding them per endpoint costs a rewrite.
// URLs: nouns, plural, and the method carries the verb
// GET /api/books list
// POST /api/books create
// GET /api/books/42 read one
// PATCH /api/books/42 update some fields
// DELETE /api/books/42 remove
// GET /api/users/7/books this user's books (one level of nesting)
// Filtering and paging live in the query string
// GET /api/books?author=orwell&sort=-createdAt&page=2&limit=20
app.get('/api/books', async (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Number(req.query.limit) || 20);
const { items, total } = await findBooks({ page, limit, author: req.query.author });
res.json({
data: items, // an empty array when there is nothing, never null
page,
limit,
total, // without this the client cannot build a pager
totalPages: Math.ceil(total / limit)
});
});
// Choose the fields. Never hand a raw database row to res.json().
function toPublicUser(user) {
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString() // one date format, everywhere
};
// password, resetToken, isDeleted, internalNotes — deliberately absent
} - Plural nouns; the HTTP method supplies the verb
- Nest one level to show ownership —
/users/7/orders— and then stop - Filtering, sorting and pagination go in the query string
- Return
totalor page metadata, or the client cannot build a pager - Map database rows to a public shape — never
res.json(dbRow) - One date format, one naming style, empty arrays instead of
null
- Write the mapping function once per model and call it from every route that returns that model. The point is not tidiness — it is that the safe behaviour becomes the default. A field you add to the database next month is then invisible to clients until you deliberately expose it, which is the right way round.
Errors, Consistency and Not Breaking Your Clients
Errors deserve as much design as successes, because handling them is where a client spends most of its effort. Choose one error shape and use it everywhere: the correct status code, a short human message, and — the part beginners skip — a stable machine-readable code the frontend can branch on. error.code === 'EMAIL_TAKEN' survives a reword; error.message === 'That email is already registered' does not.
Validation errors should say what was wrong with which field. A bare 400 saying "invalid input" makes the user guess; a response listing email and password with a reason for each lets the frontend highlight both boxes at once. It is one of the cheapest ways to make an API feel like it was built by somebody who had used one.
Never return 200 for a failure. It is a small dishonesty that spreads: every client then has to inspect your body to discover what happened, your monitoring reports a perfectly healthy service, and automatic retry logic never fires because nothing looked wrong. If it failed, say so in the status line.
Do not send internal detail to clients. Stack traces, SQL fragments and raw database driver messages all describe how your system is built, and the useful version of that information belongs in your logs. Log the detail against a generated request id, return that id in the error body, and a user can quote it to you when they report the problem.
Finally, changing an API other people already use. Adding a field is safe, because clients ignore what they do not recognise. Renaming or removing a field is not, and neither is changing a type or a status code. When a break is genuinely necessary, do it under a new version prefix, keep the old one running for a while, and tell people. /api/v1 costs nothing on day one and is very awkward to introduce later.
// One error shape, used by every route in the project
function fail(res, status, code, message, details) {
return res.status(status).json({
error: { code, message, ...(details ? { details } : {}) }
});
}
// Validation: name the field, and say why
app.post('/api/users', (req, res) => {
const details = [];
if (typeof req.body.email !== 'string' || !req.body.email.includes('@')) {
details.push({ field: 'email', message: 'A valid email is required' });
}
if (typeof req.body.password !== 'string' || req.body.password.length < 8) {
details.push({ field: 'password', message: 'At least 8 characters' });
}
if (details.length) {
return fail(res, 400, 'VALIDATION_FAILED', 'Some fields need attention', details);
}
// ...create the user
});
// The frontend branches on the CODE, never on the message text:
// if (body.error.code === 'EMAIL_TAKEN') showEmailInUseHint();
// Versioning: add, do not rewrite
app.use('/api/v1/books', booksV1);
app.use('/api/v2/books', booksV2); // v1 keeps working for older clients - One error shape everywhere: correct status, short message, stable machine code
- Validation errors name the field and the reason, one entry per problem
- Never answer 200 for a failure — monitoring and retries both depend on the code
- Log stack traces and driver messages; return a request id instead
- Adding a field is safe; renaming, removing or retyping one is not
- Break things under a new version prefix and keep the old one alive for a while
- Put
/api/v1in your paths on the first day even if you never ship a v2. It costs one word in a mount line now. Retro-fitting a version prefix later means breaking every existing client at exactly the moment you were trying to avoid breaking them.
