← 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.

How to Name Parameters

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

GET /orders?customerId=123&dateFrom=2026-01-01     ✓
GET /orders?customer_id=123                        ✗ — snake_case
GET /orders?CustomerID=123                         ✗ — PascalCase

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.

GET /orders?page=1&size=20     ← first page
GET /orders?page=3&size=50     ← third page

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".

GET /orders?size=20                                ← first page
GET /orders?size=20&cursor=eyJpZCI6MTAwfQ==        ← next

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:

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
}

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.
  • URLs and Resource Structure — how to build paths.
  • JSON and Response Format — the structure of a paginated response.
  • HTTP Methods and Statuses — when to use which method.