REST API
Learn about REST architecture style.
REST (Representational State Transfer) is an architecture style. A set of guidelines or rules that tells you how to build an API.
REST is based on resources and uses HTTP methods or verbs on top of to perform actions.
Example
Imagine walking to the library and instead of talking to a librarian, you use a simple machine with buttons.
Press 1) to get details of a book. Press 2) to add a book. Press 3) to remove a book.
The machine works the same way no matter what button pressed or book chose, you will expect the action on any book to have the expected action result.
Everything in REST API is treated like a resource, just like each book is a resource in the library. To interact with these resources, you use actions like GET, ADD, REMOVE. These are HTTP methods or verbs in REST.
REST consists of three parts:
- Resource: The resources are plural nouns and used in the URL. For example: users, orders, comments.
GET https://mywebsite.com/users/26 # get user with id 26
GET /orders # get all orders
GET /comments # get all comments - Action: The actions are the verbs of internet (HTTP verbs) that acts on a resource. The ones most used are:
GET # read resource
POST # create new resource
PUT # update resource
PATCH # update resource partially
DELETE # delete resource- Return State: The client sends a request to a resource URL. The request travels to the server. The server handles the request, reading the database and other operations, then sends a response back. This response back is the return state that can be either: JSON, HTML, XML, pdf, and other formats.
Building RESTful APIs
Resource Naming Convention
Following best practices in resource naming helps clients understand and interact with your API more effectively.
Guidelines for designing URI structures:
- Use nouns, not verbs to name endpoints.
/users ✅
/getUsers ❌-
Organize URIs to reflect resource hierarchy.
For example, use
/users/{userId}/orders/{orderId}to indicate that orders belong to users. -
Use plural nouns for resources.
For example, use
/usersfor a collection of user resources. For a single user, use/users/{userId} -
Use hyphens for multi-word URIs
For example, use
/user-profilesinstead of/userProfiles. -
Consistent naming conventions
Stick to a single format:
snake_caseorcamelCase, and use it consistently.
Filtering, sorting, pagination
Filtering
Filtering narrows a collection based on input values (called filters) typically found in the URL's query parameters.
Client request
GET /orders?status=paid&customerId=42Backend example (Hono + Drizzle)
import { db } from '@/db'
import { orders } from '@/db/schema'
import { and, eq } from 'drizzle-orm'
app.get('/orders', async (c) => {
const status = c.req.query('status')
const customerId = c.req.query('customerId')
const results = await db
.select()
.from(orders)
.where(
and(
status ? eq(orders.status, status) : undefined,
customerId ? eq(orders.customerId, Number(customerId)) : undefined,
),
)
return c.json(results)
})Sorting
Sorting controls result order.
Use one parameter for the field and one for direction, or a single combined value.
Client request
GET /users?sort=createdAt:desc
# /users?sort={field}:{direction}Backend example (Hono + Drizzle)
import { desc, asc } from 'drizzle-orm'
app.get('/users', async (c) => {
const sort = c.req.query('sort') ?? 'createdAt:asc'
const [field, direction] = sort.split(':')
const orderBy =
direction === 'desc'
? desc(users[field as keyof typeof users])
: asc(users[field as keyof typeof users])
const results = await db
.select()
.from(users)
.orderBy(orderBy)
return c.json(results)
})Pagination
Pagination limits response size, preventing slow down on heavy loads.
There are two approaches: limit/offset and cursor-based.
Limit and offset pagination
This approach is simple and familiar. It breaks when rows are inserted or deleted during pagination.
Client request
GET /products?limit=20&offset=40Backend example (Drizzle)
app.get('/products', async (c) => {
const limit = Number(c.req.query('limit') ?? 20)
const offset = Number(c.req.query('offset') ?? 0)
const results = await db
.select()
.from(products)
.limit(limit)
.offset(offset)
return c.json(results)
})Cursor-based pagination
Cursor pagination uses a stable reference point, usually an ID or timestamp. It survives deletes and inserts.
This is the correct default for production APIs.
Client request
First page:
GET /orders?limit=20Next page:
GET /orders?limit=20&cursor=105Backend example (Drizzle)
import { lt, desc } from 'drizzle-orm'
app.get('/orders', async (c) => {
const limit = Number(c.req.query('limit') ?? 20)
const cursor = c.req.query('cursor')
const results = await db
.select()
.from(orders)
.where(
cursor ? lt(orders.id, Number(cursor)) : undefined,
)
.orderBy(desc(orders.id))
.limit(limit + 1)
const hasNextPage = results.length > limit
const data = hasNextPage ? results.slice(0, limit) : results
const nextCursor = hasNextPage
? data[data.length - 1].id
: null
return c.json({
data,
nextCursor,
})
})Versioning
Versioning is essential because it ensures backward compatibility for clients. If your API is public and may change in the future, then you must version your API endpoints.
Here are some common methods for versioning APIs:
-
URL Versioning
Example:
/v1/users
Pros: Easy to understand and implement. Clear separation of versions.
Cons: Can lead to URI clutter if many versions are maintained. -
Header Versioning
Example: GET
/userswithAccept: application/vnd.example.v1+json
Pros: Cleaner URLs. Versioning information is abstracted from the URI.
Cons: Less visible and harder to test with simple tools like cURL. -
Query Parameter Versioning
Example:
/users?version=1
Pros: Easy to implement and test.
Cons: Clutters query parameters and mixes versioning with other query options.
My recommendation is go for #1.
Status Code
Use status code to know if a request failed or succeeded.
Don't inspect JSON body to know if a request failed.
Why? Status code tells you whether a response was successful or not. Status code lives in the header of a response, and for convenience, the header is one of the first bytes to arrive to the client. The JSON body arrives to the client later.
// Do
const response = await fetch("/users") // <- first bytes with success or fail response
response.ok // boolean - success or fail
response.status // number - status code
// Don't
const json = await response.json() // <- JSON body arrives after the response's header
json.errorThe popular status codes are:
Number Label — Description
200 OK — request succeeded
201 Created — resource created
204 No Content — success without response body
400 Bad Request — client sent invalid data
401 Unauthorized — missing or invalid auth
403 Forbidden — auth valid, access denied
404 Not Found — resource does not exist
409 Conflict — state conflict
422 Unprocessable Entity — validation failed
500 Internal Server Error — server bugIf clients must inspect JSON to know if a request failed, your API hides reality.
Stateless Requests
Every request contains all the information needed to handle it.
For example:
- No server-side sessions tied to memory. No hidden context.
- Authentication, locale, and permissions arrive with every request.
This constraint enables horizontal scaling without coordination.