Learn / Backend fundamentals
LESSON 26 OF 84

Backend fundamentals Foundation

Designing a REST API

Turn the Task API into a well-designed REST API: resources and URLs, a written contract, filtering, sorting, offset and cursor pagination, versioning and one consistent error format.

  • 40 min to read and try
  • You need: The HTTP in depth lesson
  • You build: A versioned Task API list endpoint with filtering, sorting, pagination and problem-details errors
Test yourself

Resources, not actions

REST (REpresentational State Transfer) is a style for designing HTTP APIs. It is not a library or a standard you install. It is a set of habits that make an API predictable, so a developer who has used one REST API can guess how yours works.

The central idea is the resource: a thing your API knows about, like a task, a user or a project. Each resource has a URL. The URL is a noun, and the HTTP method from the previous lesson is the verb. What travels in the body, usually JSON, is a representation of the resource.

The four basic operations on stored data are called CRUD: create, read, update, delete. They map onto methods like this:

OperationRequestSuccess
List tasksGET /tasks200 with an array
Create a taskPOST /tasks201 with the new task and a Location header
Read one taskGET /tasks/7200 with the task
Replace a taskPUT /tasks/7200 with the task
Change part of a taskPATCH /tasks/7200 with the task
Delete a taskDELETE /tasks/7204 with no body

/tasks is a collection, and /tasks/7 is one item in it. Use plural nouns, lower case, and hyphens between words (/task-lists). Compare:

  • Not REST: POST /createTask, GET /getTask?id=7, POST /tasks/7/delete. The verb hides in the URL, so every endpoint has to be learned one by one.
  • REST: POST /tasks, GET /tasks/7, DELETE /tasks/7. Learn the pattern once and you know them all.

Nesting

When one resource belongs to another, the URL can show it: GET /projects/3/tasks lists the tasks of project 3, and POST /projects/3/tasks creates a task inside it. Keep it to one level. Once a task has its own id, address it directly as /tasks/7, not /projects/3/tasks/7/comments/2. A filter such as GET /tasks?projectId=3 is an equally good answer; pick one and use it everywhere.

Write the contract first

An API's contract is the promise it makes to its clients: which URLs exist, what they accept, what they return and which status codes they use. Write it down before you write code. Clients (a web app, a mobile app, another team) build against the contract, so changing it later breaks them. Here is the Task API's contract:

RequestBodyResponses
GET /v1/tasksnone; query: done, q, sort, limit, offset200 list, 400 bad query
POST /v1/tasks{"title": "Buy milk"}201 task, 400 invalid, 415 not JSON
GET /v1/tasks/{id}none200 task, 404
PATCH /v1/tasks/{id}{"done": true} or {"title": "…"}200 task, 400, 404
DELETE /v1/tasks/{id}none204, 404

A task looks like {"id": 7, "title": "Buy milk", "done": false, "createdAt": "2026-09-07"}. A list comes wrapped in an object, {"data": [...], "meta": {...}}, rather than as a bare array. That leaves room to add information such as the total count later without breaking anyone. Every error has the same shape, which you will build in the errors section.

TIP

Later in the course you will describe this contract in a machine-readable format called OpenAPI, and generate it from your code in the OpenAPI lesson.

Filtering and sorting with query parameters

A list endpoint gets its options from the query string, the part of the URL after ?. URLSearchParams reads it for you. Every value arrives as a string (or null when missing), and every value comes from outside, so check each one.

Sorting needs special care: never sort by whatever field name the client sends. Keep an allow-list of fields that may be sorted. A leading minus sign, sort=-createdAt, is the usual way to ask for descending order.

This module is the core of the list endpoint. It has no Node APIs in it, so you can run it in the browser:

tasks.js
export const tasks = [
  { id: 1, title: "Buy milk", done: true, createdAt: "2026-09-01" },
  { id: 2, title: "Write report", done: false, createdAt: "2026-09-02" },
  { id: 3, title: "Call Ada", done: false, createdAt: "2026-09-02" },
  { id: 4, title: "Book dentist", done: true, createdAt: "2026-09-04" },
  { id: 5, title: "Fix bike", done: false, createdAt: "2026-09-05" },
  { id: 6, title: "Read book", done: false, createdAt: "2026-09-06" },
  { id: 7, title: "Water plants", done: false, createdAt: "2026-09-07" },
];

const SORTABLE = ["id", "title", "createdAt"];

function wholeNumber(params, name, fallback, min, max, errors) {
  if (!params.has(name)) return fallback;
  const n = Number(params.get(name));
  if (!Number.isInteger(n) || n < min || n > max) {
    errors.push({ param: name, message: `must be a whole number from ${min} to ${max}` });
  }
  return n;
}

export function parseListQuery(params) {
  const errors = [];
  const limit = wholeNumber(params, "limit", 20, 1, 100, errors);
  const offset = wholeNumber(params, "offset", 0, 0, 1_000_000, errors);
  const done = params.get("done");
  if (done !== null && done !== "true" && done !== "false") {
    errors.push({ param: "done", message: "must be true or false" });
  }
  const sort = params.get("sort") ?? "id";
  const field = sort.replace(/^-/, "");
  if (!SORTABLE.includes(field)) {
    errors.push({ param: "sort", message: `must be one of ${SORTABLE.join(", ")}, with an optional leading -` });
  }
  const q = (params.get("q") ?? "").trim().toLowerCase();
  const query = { limit, offset, done: done === null ? null : done === "true", q, field, desc: sort.startsWith("-") };
  return { errors, query };
}

export function listTasks(all, query) {
  const matches = all.filter(
    (t) => (query.done === null || t.done === query.done) && t.title.toLowerCase().includes(query.q),
  );
  const sorted = matches.toSorted((a, b) => {
    const order = a[query.field] < b[query.field] ? -1 : a[query.field] > b[query.field] ? 1 : 0;
    return query.desc ? -order : order;
  });
  const data = sorted.slice(query.offset, query.offset + query.limit);
  return { data, meta: { total: matches.length, limit: query.limit, offset: query.offset } };
}

Try it with a few query strings, the same way the server will call it:

try-list.js
import { listTasks, parseListQuery, tasks } from "./tasks.js";

function show(queryString) {
  const { errors, query } = parseListQuery(new URLSearchParams(queryString));
  if (errors.length > 0) {
    console.log(queryString, "->", errors);
    return;
  }
  const { data, meta } = listTasks(tasks, query);
  console.log(queryString, "->", data.map((t) => `${t.id} ${t.title}`), meta);
}

show("done=false&sort=-createdAt&limit=3");
show("q=BOOK");
show("limit=500&sort=password");
Output of node try-list.js and of the browser terminal
done=false&sort=-createdAt&limit=3 -> [ '7 Water plants', '6 Read book', '5 Fix bike' ] { total: 5, limit: 3, offset: 0 }
q=BOOK -> [ '4 Book dentist', '6 Read book' ] { total: 2, limit: 20, offset: 0 }
limit=500&sort=password -> [
  { param: 'limit', message: 'must be a whole number from 1 to 100' },
  {
    param: 'sort',
    message: 'must be one of id, title, createdAt, with an optional leading -'
  }
]

The first call asked for open tasks, newest first, three at a time. meta.total is 5: five tasks match, but only three were sent. The search q=BOOK ignored upper and lower case. The last call broke two rules, and the client hears about both at once instead of fixing one, retrying and hitting the next.

Tasks 2 and 3 have the same createdAt. toSorted keeps equal items in their original order, so the result is the same every time. A database gives no such promise: always add a tie-breaker, such as the id, when you sort in SQL.

Pagination: offset and cursor

A list can grow to millions of rows. Pagination means sending it one page at a time. There are two common ways.

Offset pagination

?limit=3&offset=3 means "skip 3 items, then give me 3". It is what listTasks does. It is simple, and the client can jump straight to page 10. It has a weakness: if an item is added while someone is paging, everything shifts. Watch a client read the newest tasks, three per page, while a new task arrives between page 1 and page 2:

offset-problem.js
const tasks = [1, 2, 3, 4, 5, 6, 7].map((id) => ({ id, title: `Task ${id}` }));
const newestFirst = () => tasks.toSorted((a, b) => b.id - a.id);

const page1 = newestFirst().slice(0, 3);
console.log("page 1:", page1.map((t) => t.id));

tasks.push({ id: 8, title: "Task 8" });

const page2 = newestFirst().slice(3, 6);
console.log("page 2:", page2.map((t) => t.id));
Output of node offset-problem.js and of the browser terminal
page 1: [ 7, 6, 5 ]
page 2: [ 5, 4, 3 ]

Task 5 appears on both pages, because the new task pushed it from position 3 to position 4. With deletes, an item can be skipped instead. On big tables, a large offset is also slow: the database still has to walk past every skipped row.

Cursor pagination

A cursor remembers where the last page ended instead of how many items to skip. The server returns a nextCursor with each page, and the client sends it back to get the next one. Here the cursor says "continue with ids smaller than 5". It is encoded with btoa (Base64) so clients treat it as an opaque token and do not build their own:

cursor.js
const tasks = [1, 2, 3, 4, 5, 6, 7].map((id) => ({ id, title: `Task ${id}` }));

function decodeCursor(cursor) {
  try {
    const { before } = JSON.parse(atob(cursor));
    return Number.isInteger(before) ? before : null;
  } catch {
    return null;
  }
}

function page(limit, cursor) {
  const before = cursor ? decodeCursor(cursor) : Infinity;
  if (before === null) throw new Error("invalid cursor");
  const rows = tasks.filter((t) => t.id < before).toSorted((a, b) => b.id - a.id).slice(0, limit + 1);
  const data = rows.slice(0, limit);
  const nextCursor = rows.length > limit ? btoa(JSON.stringify({ before: data.at(-1).id })) : null;
  return { data: data.map((t) => t.id), nextCursor };
}

const p1 = page(3);
console.log("page 1:", p1);
tasks.push({ id: 8, title: "Task 8" });
const p2 = page(3, p1.nextCursor);
console.log("page 2:", p2);
console.log("page 3:", page(3, p2.nextCursor));
Output of node cursor.js and of the browser terminal
page 1: { data: [ 7, 6, 5 ], nextCursor: 'eyJiZWZvcmUiOjV9' }
page 2: { data: [ 4, 3, 2 ], nextCursor: 'eyJiZWZvcmUiOjJ9' }
page 3: { data: [ 1 ], nextCursor: null }

No duplicate this time, even though task 8 arrived in the middle. The server asks for limit + 1 rows: if the extra row exists, there is a next page. On the last page nextCursor is null, which tells the client to stop.

OffsetCursor
Jump to page 10YesNo, only next (and sometimes previous)
Stable while data changesNoYes
Fast on huge tablesNoYes, with an index on the sort column
Good forAdmin tables with page numbersFeeds, infinite scroll, syncing

One error format: problem details

Clients handle errors in code, so every error from your API should have the same shape. You do not have to invent one: RFC 9457, "Problem Details for HTTP APIs", is a standard for exactly this. An RFC is an official internet specification. A problem details body is JSON with these fields:

  • type: a URL that names the kind of problem. Clients compare it, so it never changes.
  • title: a short summary for humans, the same for every problem of this type.
  • status: the HTTP status code, repeated in the body.
  • detail: what went wrong this time.
  • instance: optional; which request or resource it was about.

You may add your own fields, such as an errors list. The response uses the media type application/problem+json, so a client knows the format from the header alone.

problem.js
export function problem(status, type, title, detail, extra = {}) {
  return { type: `https://example.com/problems/${type}`, title, status, detail, ...extra };
}

export function sendProblem(res, body) {
  res.writeHead(body.status, { "Content-Type": "application/problem+json" });
  res.end(JSON.stringify(body));
}

Never put a stack trace, an SQL query or an internal file path in detail. Log those on the server; attackers read error messages too.

Versioning

A breaking change is one that can make a working client fail: removing or renaming a field, changing a field's type, adding a required input, or changing what a status code means. When you must make one, publish a new version of the API and keep the old one running until clients have moved.

The most common way is a version in the path: /v1/tasks, later /v2/tasks. It is visible in every log line and easy to route. Some APIs use a header instead, such as Accept-Version: 2.

Additive changes do not need a new version: a new endpoint, a new optional query parameter, or a new field in a response. That only works if clients ignore fields they do not know, which is another reason to wrap lists in {"data": ...} and to write your own clients that way.

Build it: the list endpoint

Now put the pieces together in a server. It serves GET /v1/tasks with the query parsing and listing from tasks.js, returns problem details for a bad query and for unknown routes, and adds a Link header pointing at the next page, which is a common convention. As before, the example calls itself with fetch and then stops:

server.jsNode.js only
import http from "node:http";
import { listTasks, parseListQuery, tasks } from "./tasks.js";
import { problem, sendProblem } from "./problem.js";

const server = http.createServer((req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (req.method !== "GET" || url.pathname !== "/v1/tasks") {
    return sendProblem(res, problem(404, "not-found", "Not Found", `No route for ${req.method} ${url.pathname}`));
  }
  const { errors, query } = parseListQuery(url.searchParams);
  if (errors.length > 0) {
    return sendProblem(res, problem(400, "invalid-query", "Invalid query", "Some query parameters are invalid.", { errors }));
  }
  const page = listTasks(tasks, query);
  const headers = { "Content-Type": "application/json" };
  if (query.offset + query.limit < page.meta.total) {
    url.searchParams.set("offset", String(query.offset + query.limit));
    headers.Link = `<${url.pathname}${url.search}>; rel="next"`;
  }
  res.writeHead(200, headers);
  res.end(JSON.stringify(page));
});

server.listen(0, async () => {
  const base = `http://localhost:${server.address().port}`;
  for (const path of ["/v1/tasks?done=false&sort=title&limit=2", "/v1/tasks?limit=0", "/v2/tasks"]) {
    const res = await fetch(base + path);
    console.log(res.status, res.headers.get("content-type"), res.headers.get("link"));
    console.log(JSON.stringify(await res.json(), null, 2));
  }
  server.close();
});
Output of node server.js
200 application/json </v1/tasks?done=false&sort=title&limit=2&offset=2>; rel="next"
{
  "data": [
    {
      "id": 3,
      "title": "Call Ada",
      "done": false,
      "createdAt": "2026-09-02"
    },
    {
      "id": 5,
      "title": "Fix bike",
      "done": false,
      "createdAt": "2026-09-05"
    }
  ],
  "meta": {
    "total": 5,
    "limit": 2,
    "offset": 0
  }
}
400 application/problem+json null
{
  "type": "https://example.com/problems/invalid-query",
  "title": "Invalid query",
  "status": 400,
  "detail": "Some query parameters are invalid.",
  "errors": [
    {
      "param": "limit",
      "message": "must be a whole number from 1 to 100"
    }
  ]
}
404 application/problem+json null
{
  "type": "https://example.com/problems/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "No route for GET /v2/tasks"
}

Three requests, three well-formed answers:

  • The list has two open tasks sorted by title, total: 5, and a Link header with the exact URL of the next page, so the client does not have to compute it.
  • limit=0 got a 400 with application/problem+json and the list of what was wrong.
  • /v2/tasks does not exist, and the 404 has the very same shape. A client needs one error handler for your whole API.

Run it on your computer by saving tasks.js, problem.js and server.js in one folder (with "type": "module" in package.json, as in every project so far) and running node server.js.

Practice

TRY IT YOURSELF

Fix the URLs

Rewrite these endpoints in REST style: (a) GET /getAllTasks; (b) POST /tasks/7/markDone; (c) GET /task?id=7; (d) POST /deleteTask with {"id": 7}; (e) GET /projects/3/tasks/7.

Show a solution

(a) GET /tasks. (b) PATCH /tasks/7 with {"done": true}. (c) GET /tasks/7. (d) DELETE /tasks/7. (e) GET /tasks/7: the task has its own id, so it does not need the project in its URL.

TRY IT YOURSELF

Filter by date

Add a since parameter to parseListQuery and listTasks: only tasks with createdAt on or after that date. Accept only the form YYYY-MM-DD, and report anything else as an error. Test it with since=2026-09-05 and since=yesterday.

Show a solution
since.js
import { listTasks, parseListQuery, tasks } from "./tasks.js";

function parseWithSince(params) {
  const { errors, query } = parseListQuery(params);
  const since = params.get("since");
  if (since !== null && !/^\d{4}-\d{2}-\d{2}$/.test(since)) {
    errors.push({ param: "since", message: "must be a date like 2026-09-05" });
  }
  return { errors, query: { ...query, since } };
}

function listSince(all, query) {
  const recent = query.since === null ? all : all.filter((t) => t.createdAt >= query.since);
  return listTasks(recent, query);
}

for (const qs of ["since=2026-09-05", "since=yesterday"]) {
  const { errors, query } = parseWithSince(new URLSearchParams(qs));
  console.log(qs, errors.length ? errors : listSince(tasks, query).data.map((t) => t.id));
}
Output of node since.js and of the browser terminal
since=2026-09-05 [ 5, 6, 7 ]
since=yesterday [ { param: 'since', message: 'must be a date like 2026-09-05' } ]

Dates in the form YYYY-MM-DD sort correctly as plain strings, so >= works. The new code wraps the existing functions instead of changing them.

TRY IT YOURSELF

Offset or cursor?

Which pagination would you pick for: (a) an activity feed in a mobile app with infinite scroll; (b) an admin screen that shows "page 4 of 30"; (c) a job that copies every task to another system overnight?

Show a solution

(a) Cursor: new activity arrives all the time, and the user only scrolls forward. (b) Offset: people want page numbers, and the table is small enough. (c) Cursor: it must not skip or repeat a task, and the table may be large.

Recap

  • REST URLs name resources with plural nouns (/tasks, /tasks/7). The method is the verb. Nest at most one level.
  • Write the contract first: URLs, bodies, status codes. Wrap lists in {"data": ..., "meta": ...}.
  • Validate every query parameter, and sort only by allow-listed fields.
  • Offset pagination can jump to any page but shifts when data changes. Cursor pagination is stable and fast, but only moves forward.
  • Send every error as RFC 9457 problem details (application/problem+json) and never leak internals.
  • Put the version in the path (/v1). Additive changes need no new version; breaking changes do.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.