Когда эндпоинт возвращает 10 записей — ни о какой пагинации не думаешь. Просто SELECT * FROM orders и отдал JSON’ом. Но проходит месяц. Orders набирает 50 000 записей. GET /orders начинает отвечать по 3 секунды, база стонет, а фронтенд рендерит бесконечный скролл на 10 мегабайт данных. Клиент в мобильной сети ждёт полминуты.
Я наступал на эти грабли дважды. Первый раз — когда мы просто добавили ?page=1&limit=50 задним числом и сломали всех, кто парсил ответ как «весь список». Второй раз — когда cursor-пагинацию прикрутили к отчёту, который никто не сортировал, и курсоры стали ссылаться в пустоту. Разберу, как делать нормально.
Если с правилами названий ручек вы уже знакомы — пагинация, фильтрация и сортировка следующий логичный шаг к взрослому API.
Offset-пагинация: простая, но с подвохом
Самый массовый способ. Клиент говорит «дай страницу 3 по 20 записей», сервер считает OFFSET 40 LIMIT 20:
GET /orders?page=3&limit=20В ответе, кроме самого массива данных, нужны метаданные. Как минимум — общее количество записей, чтобы фронтенд мог нарисовать «Страница 3 из 47»:
{ "data": [ ... ], "meta": { "page": 3, "limit": 20, "total": 934, "pages": 47 }}Плюс — Link-заголовки для навигации. Да, этот RFC читают реже, чем хотелось бы, но он решает проблему HATEOAS без переусложнения:
Link: </orders?page=1&limit=20>; rel="first", </orders?page=2&limit=20>; rel="prev", </orders?page=4&limit=20>; rel="next", </orders?page=47&limit=20>; rel="last"Проблема offset-пагинации не в реализации. Она в том, что происходит между запросом страницы 1 и страницы 2.
Представьте: пользователь читает первую страницу заказов. В этот момент кто-то создал новый заказ, и он встал первым в списке (сортировка по дате создания). Пользователь жмёт «Далее». Сервер делает OFFSET 20 LIMIT 20, но из-за вставки нового заказа строка, которая была 20-й, стала 21-й — и пользователь видит её повторно на второй странице. Дубликат. Наоборот тоже бывает: строка была 21-й, стала 20-й из-за удаления — и пользователь её пропускает.
Для админок, отчётов и внутренних тулов это терпимо. Для ленты новостей, где пользователь скроллит и видит одни и те же посты через каждые 20 записей — бесит невероятно.
Cursor-пагинация: без дубликатов, но без страниц
Здесь нет понятия «страница 3». Клиент получает курсор — непрозрачную строку, которая кодирует позицию в наборе — и передаёт её для следующего запроса:
GET /orders?cursor=eyJpZCI6MTAsImNyZWF0ZWRfYXQiOiIyMDI2LTA5LTE1In0&limit=20Ответ:
{ "data": [ ... ], "meta": { "limit": 20, "next_cursor": "eyJpZCI6MzAsImNyZWF0ZWRfYXQiOiIyMDI2LTA4LTAxIn0", "has_more": true }}Курсор — обычно base64-encoded JSON с полями сортировки последней записи страницы. Сервер берёт его, декодит и делает WHERE (created_at, id) < ('2026-08-01', 30) ORDER BY created_at DESC, id DESC LIMIT 20. Новая запись между страницами не ломает выдачу: условие WHERE привязано к конкретным значениям, а не к позиции.
Цена: нет общего количества (total), нет номеров страниц, нет кнопки «перейти на страницу 47». Клиенту доступна только последовательная навигация вперёд (и назад, если сервер отдаёт prev_cursor). Для infinite scroll’а ленты, чатов, real-time-стримов — идеально. Для дашборда с таблицей и фильтрами, где бухгалтер хочет прыгнуть на страницу 14 — мучение.
Keyset-пагинация: компромисс
Гибрид между offset и cursor. Вместо непрозрачного курсора клиент явно передаёт значение поля, по которому сортируется выдача:
GET /orders?after_id=42&limit=20&sort=idСервер делает WHERE id > 42 ORDER BY id ASC LIMIT 20. Прозрачно, предсказуемо, не ломается от вставок. Но работает только если сортировка идёт по одному полю и это поле уникально.
Подходит для простых CRUD-справочников, где id монотонно растёт и сортировка всегда по нему. Как только нужна сортировка «сначала дорогие заказы» или «по дате доставки» — keyset сыплется: поле total может повторяться, и условие WHERE total < 1000 пропустит второй заказ с той же суммой.
Какую стратегию выбрать
Вместо таблицы — дерево решений. Проходите от корня по стрелкам в зависимости от вашего контекста.
Жёлтые ромбы — развилки. Синие — offset (простой, но с дубликатами на изменяющихся данных). Зелёные — стратегии, устойчивые к изменениям. Если сомневаетесь — берите cursor. Переделать cursor в offset потом можно, наоборот — ломаете контракт.
Честно говоря, 90% проектов прекрасно живут на offset-пагинации. Проблема дубликатов реальна, но пользователи жалуются на неё раз в полгода, а трудозатраты на cursor с нуля — минимум спринт. Если у вас внутренняя админка — не парьтесь, offset достаточно.
Фильтрация: синтаксис, который не превращает URL в монстра
Правила названий ручек дают читаемый URL. Фильтрация может всё испортить.
Плохо:
GET /orders?statusFilter=active&orderMinTotal=100&lastCreatedFrom=2026-01-01Такое невозможно запомнить. Каждый параметр придуман с нуля, префиксы неконсистентны (Min, From, Filter означают одно и то же — ограничение).
Хорошо:
GET /orders?status=active&min_total=100&created_at[gte]=2026-01-01&created_at[lte]=2026-10-01Правила здесь простые:
- Имя параметра = имя поля в модели данных (
status,total,created_at) - Для диапазонов — суффиксы-операторы:
[gte],[lte],[gt],[lt], реже[eq],[ne],[in] - Множественный выбор:
status=active,pending(запятая как разделитель) илиstatus[]=active&status[]=pending(массив через квадратные скобки)
Операторная нотация (field[op]=value) позаимствована из LHS Brackets — неофициального, но де-факто стандарта, который понимает большинство библиотек парсинга query-параметров на бэкенде. Альтернатива — filter[field][op]=value — переусложняет URL без выигрыша, если у вас не десяток вложенных ресурсов.
Ещё одна крайность — полноценный язык запросов в query-строке:
GET /orders?q=status:active AND total>=100 AND created_at>=2026-01-01Выглядит мощно. На практике превращается в отдельный мини-проект: парсер, лексер, обработка ошибок синтаксиса, инъекции. Я участвовал в проекте, где такой q жил полгода, а потом его молча снесли, заменив плоскими параметрами — потому что каждое новое поле требовало доработки парсера и документации.
Для публичного API держите фильтры плоскими. Для внутренних аналитических дашбордов можно позволить DSL-подобный синтаксис — но только если есть ресурс его поддерживать.
Сортировка: порядок полей имеет значение
Минимальный синтаксис:
GET /orders?sort=-created_atМинус — по убыванию, плюс или без префикса — по возрастанию. Сортировка по нескольким полям — через запятую:
GET /orders?sort=-created_at,+statusЭто читается как «сначала свежие, затем по статусу от A до Z». Порядок полей в sort определяет приоритет: первое поле — первичная сортировка, второе — вторичная (при равных значениях первого).
Вариант с раздельными параметрами — ?sort_by=created_at&sort_order=desc — выглядит аккуратнее для одного поля, но ломается, когда полей два и больше: как передать, что первичная сортировка created_at desc, а вторичная status asc? Приходится городить ?sort_by[]=created_at&sort_order[]=desc&sort_by[]=status&sort_order[]=asc — и надеяться, что клиент не перепутает индексы.
Лично я пользуюсь синтаксисом с запятой. Он компактный, его гуглят, и клиенты не спорят.
Как это выглядит вместе: полный запрос на выборку
На схеме — проблема offset-пагинации в чистом виде. Между страницами вставили заказ — и id=8 съехал, появившись на обеих страницах. Cursor эту проблему обходит: запрос с WHERE id < 8 стартует строго от последней видимой записи, независимо от того, что вставилось выше.
Полный запрос с фильтрацией и сортировкой может выглядеть так:
GET /orders?status=active,pending&min_total=100&created_at[gte]=2026-01-01&sort=-created_at,+id&page=1&limit=20Между этим URL и его отсутствием — примерно год эволюции API. Сначала появляется пагинация, потому что данных стало много. Потом фильтрация — потому что клиентам надоело скроллить 47 страниц в поисках активных заказов. Потом сортировка — потому что дефолтный порядок по id перестал устраивать. Потом поддержка нескольких полей сортировки — потому что менеджеры хотят «сначала по дате, потом по сумме».
Не надо проектировать всё это сразу, если у вас 200 записей в таблице. Но когда API растёт — каждое из этих расширений должно ложиться в общую схему, а не плодить ad-hoc параметры.
Как это превращается в бэкенд-запрос
Три независимых оси обработки — фильтрация, сортировка, пагинация — собираются в один SQL-запрос на стороне базы. Это важно спроектировать на уровне контракта API, чтобы бэкенд-разработчику не приходилось изобретать свой FancyQueryBuilder для каждого эндпоинта.
Отдельный момент — безопасность. Если параметр фильтра sort напрямую подставляется в ORDER BY, ждите ?sort=(CASE WHEN ...) и SQL-инъекцию. Все поля для фильтрации и сортировки должны валидироваться по белому списку разрешённых колонок. Неприятно, да. Но ровно так случилось в одном проекте, где я консультировал: аналитик спроектировал гибкую фильтрацию, бэкендер быстро накидал на ORM без валидации, и через месяц пентест принёс инцидент.
Антипаттерны и частые ошибки
Собрал то, на чём спотыкался сам или видел у коллег.
Пагинация без total. Отдаёте ?page=1&limit=20 и массив из 20 записей. Клиент не знает, есть ли страница 2. Делает запрос — пустой массив. Делает ещё один — пусто. Клиент уходит в бесконечный цикл из вежливости. Всегда отдавайте has_more (для cursor) или total/pages (для offset).
Сортировка по полю, которого нет в индексе базы. Сделали ?sort=comment_text — база делает полное сканирование таблицы, время ответа выросло с 50 мс до 12 секунд. Проектируя параметры сортировки, сверьтесь с DBA: какие поля индексированы. Неиндексированные либо не добавляйте в sort, либо явно документируйте «медленно, до 10 секунд».
Фильтрация без учёта NULL. ?status=active исключает заказы с status IS NULL. Если половина legacy-заказов до сих пор без статуса — они просто исчезнут из выдачи. Явно документируйте, как фильтр обрабатывает NULL. Или добавьте специальное значение: ?status=null для поиска незаполненных полей.
Разные имена параметров для одного и того же. В одном эндпоинте min_price, в другом price_from, в третьем price[gte]. Если у вас больше трёх эндпоинтов — стандартизируйте именование фильтров на уровне код-стайла API. Версионирование API не поможет, если внутри одной версии бардак.
Пагинация без Link-заголовков. Без них клиент обязан сам собирать URL страниц, зная ваш синтаксис ?page= и ?limit=. Это жёсткая связка. Поменяли имена параметров — сломали навигацию у всех. RFC 8288 решает проблему элегантно: в заголовках приходят готовые URL, клиент просто переходит по ним.
Чек-лист для аналитика
При проектировании выборок зафиксируйте ответы на эти вопросы. Минимум полчаса на архитектурном ревью сэкономят.
- Какой тип пагинации? Offset — для админок и отчётов. Cursor — для лент и real-time. Keyset — для простых справочников с одним полем сортировки.
- Что в ответе кроме данных?
total/pagesдля offset,next_cursor/has_moreдля cursor, Link-заголовки в обоих случаях. - Как именуются параметры фильтрации? Единый стандарт на все эндпоинты:
field[gte],min_field, или плоские — но что-то одно. - Какие поля доступны для сортировки? Белый список, согласованный с DBA. Никакого свободного
ORDER BYиз query-параметра. - Как обрабатываются NULL при фильтрации? Документировано явно. Или есть способ искать
NULL. - Сколько максимум записей за раз?
limitдолжен иметь потолок. Без него?limit=999999— это отказ сервиса. - Как клиент понимает, что данные кончились? Пустой массив — ОК. Но дублируйте это в
has_more: falseилиpage >= totalPages. - Link-заголовки или хардкод URL на клиенте? Первое — гибкость. Второе — жёсткая связка.
PS. Если ваш API сейчас отдаёт GET /orders и возвращает всю таблицу — это не «пока так, потом доработаем». Это минус производительности, который копится с каждым днём. Добавьте хотя бы ?limit=100 с потолком и total в ответе. Остальное — потом.