← Back to the section

When a user opens a list of orders, they want to see only their own, only from last month, only the cancelled ones — and preferably sorted by date. All of that is passed through query parameters: the part of the URL after the ? sign.

Let's look at how to name them properly, how to build filters, and how to make large lists load page by page.

The most expensive decision here is the way pages are loaded: it determines what a person sees if the list grew while they were reading.

order feed by date, size=20, 243 total … GET /orders?page=1&size=20 page 1: #243 … #224 #224 while you read, order #244 arrived — the feed shifted right … new #244 GET /orders?page=2&size=20 “skip 20, take 20” #224 again page 2: #224 … #205 — #224 already seen GET /orders?size=20&cursor=eyJpZCI6MjI0fQ== “next 20 after #224” … next 20: #223 … #204 — no duplicate offset — page numbers and totalElements, cursor — no duplicates

While the first page was being read, a new order arrived at the head of the feed — and page=2 with the same size returned #224 a second time: offset counts positions, and the positions shifted. A cursor remembers the record itself rather than a position, so it returns exactly the next twenty — at the cost of page numbers and a total counter.

How to Name Parameters

Query parameter names are written in camelCase — the same way as fields in JSON.

RequestFitsWhy
GET /orders?customerId=123&dateFrom=2026-01-01yescamelCase — same as JSON fields
GET /orders?customer_id=123nosnake_case
GET /orders?CustomerID=123noPascalCase

A single convention across the whole API saves clients from confusion: no need to remember where there's an underscore and where there isn't.

Filtering

The simplest way to filter is to pass the field name and value directly:

GET /orders?status=CONFIRMED
GET /orders?customerId=550e8400-e29b-41d4-a716-446655440000

For ranges, add the From and To suffixes:

GET /orders?dateFrom=2026-01-01&dateTo=2026-12-31
GET /orders?amountFrom=100&amountTo=500

The interval includes both ends: from dateFrom inclusive to dateTo inclusive. You can pass only one bound — for example, dateFrom without dateTo means "from this date onward".

Two Kinds of Pagination

When there are thousands of records in the database, you can't return them all in a single request. You need page-by-page loading. There are two approaches, and each has its own area of application.

Offset Pagination — for a Classic UI with Pages

The client says: "give me page number 3, 20 items each". The server skips the first 40 and returns the next 20.

RequestWhat comes back
GET /orders?page=1&size=20the first page, 20 records
GET /orders?page=3&size=50the third page, 50 records

An important detail: the first page is page=1, not page=0. Zero-based page numbering inside the code is an implementation detail that must not leak into the public contract.

Spring Data has a ready-made setting for this:

spring:
  data:
    web:
      pageable:
        one-indexed-parameters: true

The server's response contains the data itself and pagination information:

{
  "content": [
    { "orderId": "...", "status": "CREATED" }
  ],
  "page": 1,
  "size": 20,
  "totalElements": 243,
  "totalPages": 13
}

totalElements and totalPages let the UI draw the "1 2 3 … 13" buttons.

When to use: you need page numbers in the interface, the user wants to jump to page 7, the data changes rarely.

Limitations: with active insertion/deletion of records the pages "drift" — an item may appear twice or disappear. On very large OFFSET values the SQL query slows down.

Cursor Pagination — for Feeds and Infinite Scroll

Instead of a page number, the client receives an opaque token (cursor) and passes it in the next request: "give me 20 records after this point".

RequestWhat comes back
GET /orders?size=20the first 20 records plus a cursor to the next chunk
GET /orders?size=20&cursor=eyJpZCI6MTAwfQ==the next 20 after the record the cursor points at

The client doesn't know what's inside the cursor, and shouldn't — it's a Base64 string that the server reads itself. There's no need to construct the cursor yourself: you take the nextCursor value from the response and plug it into the next request.

{
  "content": [...],
  "size": 20,
  "nextCursor": "eyJpZCI6MTIwfQ==",
  "prevCursor": "eyJpZCI6MTAwfQ==",
  "hasNext": true,
  "hasPrev": true
}

When to use: the data changes often (a message feed, notifications), you need infinite scroll, large volumes of data.

Limitations: you can't jump straight to page 7, and you can't find out the total number of records without a separate request.

Sorting

The sort parameter takes a field name and a direction separated by a comma:

GET /orders?sort=createdAt,desc
GET /orders?sort=totalAmount,asc

If you need multi-level sorting, the parameter is repeated:

GET /orders?sort=totalAmount,asc&sort=createdAt,desc

Multi-level sorting should be applied with care: composite indexes in the database must match the order of the fields.

For free-form text search the q parameter is used:

GET /products?q=keyboard
GET /orders?q=Smith

One parameter, no magic. If the search is complex — see the section below.

Multiple Values for the Same Filter

To pass an array of values, the parameter is simply repeated:

GET /orders?status=CREATED&status=CONFIRMED&status=PAID     ✓
GET /orders?status=CREATED,CONFIRMED,PAID                   ✗

Passing values comma-separated in a single parameter is a common mistake. It breaks if a value itself contains a comma, and it requires manual parsing on the server. Repeating the parameter is the standard behavior that Spring and most frameworks support out of the box.

In OpenAPI this is described like so:

parameters:
  - name: status
    in: query
    schema:
      type: array
      items:
        type: string
    style: form
    explode: true

When GET Is Not Enough: POST /search

A GET request has physical limits. A URL can't be infinitely long — proxy servers usually cut it off at 2000–8000 characters. You can't pass nested objects in a query string.

When a query is too complex for a URL, use POST /resources/search with a JSON body:

live example

POST /api/v1/orders/search
Content-Type: application/json

{
  "statuses": ["CONFIRMED", "PAID", "SHIPPED"],
  "dateRange": { "from": "2026-01-01", "to": "2026-12-31" },
  "customer": { "regionIds": [1, 5, 12], "segment": "VIP" },
  "totalAmount": { "from": 1000, "to": 50000 },
  "sort": [
    { "field": "createdAt", "direction": "DESC" },
    { "field": "totalAmount", "direction": "ASC" }
  ],
  "page": 1,
  "size": 20
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

When to switch to POST:

  • you need nested objects in the filter;
  • an array of 10 or more values;
  • AND/OR combinations;
  • the query needs to be saved and reused.

Rules for POST search:

  • URL: /resources/search — not /query, not /find.
  • Response code: 200 OK, no resource is created.
  • The response format is the same as for GET /resources — the same paginated list.

Common Mistakes

page=0 in a public API. The zero page is an internal implementation detail (Java's zero-based indexing). Clients expect the first page to be 1.

Comma-separated values. ?status=CREATED,CONFIRMED looks compact, but it breaks with values that contain a comma and doesn't follow the standard. Repeat the parameter.

A business action in the query. ?action=cancel is not a filter, it's a command. Commands go through a separate endpoint: POST /orders/{id}/cancel.

The client parses the cursor. If the client decodes the Base64 and reads the cursor's fields — that's a breach of contract. The cursor format may change at any time; the client must treat it as an opaque string.

In Short

  • Parameter names — camelCase: customerId, dateFrom, not customer_id.
  • Filtering: field name = parameter (?status=CONFIRMED). Ranges: From/To suffixes.
  • Offset pagination: page (from 1) + size. Returns totalElements. For a UI with pages.
  • Cursor pagination: cursor (an opaque token) + size. For feeds and infinite scroll.
  • Sorting: sort=field,direction; repeated for multi-level.
  • Multiple values — repeat the parameter: ?status=A&status=B, not ?status=A,B.
  • Complex search — POST /resources/search with a JSON body, response 200 OK.