REST API в WordPress: рабочие приёмы и цена решений
Четыре способа отдать данные через WP REST API — от _fields на штатных эндпоинтах до своего контроллера. Что выигрываете, чем платите и почему фронт получает 403 после включения кеша страниц.

Типичная ситуация: фронт тянет /wp-json/wp/v2/posts?per_page=20&_embed, чтобы показать плитку из двадцати карточек с автором и картинкой. В ответ приходят сотни килобайт, потому что в каждом объекте едет content.rendered целиком, excerpt, meta, блок _links и встроенные через _embedded автор с медиафайлом. Карточке нужны четыре поля. Второй вариант — свой эндпоинт с циклом по записям и get_post_meta() внутри — отдаёт ровно то, что нужно, но добавляет по запросу к postmeta на каждую запись. Дальше разбираем четыре рабочих подхода и цену каждого. Всё проверялось на WordPress 6.x и PHP 8.1.
Что именно идёт не так
Три проблемы, из которых вырастает почти вся боль с WP REST API.
- Размер ответа. Штатные контроллеры отдают полную схему объекта. Для админки Гутенберга это правильно, для публичного фронта — перерасход трафика и времени на сериализацию.
- Запросы к базе.
get_post_meta()в цикле по записям, которые вы достали мимоWP_Query, превращается в N+1*. Та же история с терминами и с ACF-полями, если дёргать их по одному. - Права и аутентификация.
permission_callbackчасто ставят__return_true«на время отладки», а cookie-аутентификация с nonce** отваливается ровно в тот день, когда включают кеширование страниц.
Четыре способа отдать данные
1. Штатные эндпоинты плюс _fields
Самый дешёвый путь: не писать серверный код вообще. Параметр _fields обрезает ответ до перечисленных полей, и ядро не рендерит то, что вы не просили. Для своего типа записи достаточно show_in_rest при регистрации, для мета-полей — отдельная регистрация.
// Плагин или functions.php, WordPress 6.x
add_action( 'init', function () {
register_post_meta( 'product', 'price_rub', [
'show_in_rest' => true,
'single' => true,
'type' => 'number',
// auth_callback решает, кто может менять эту мету через REST
'auth_callback' => function () {
return current_user_can( 'edit_posts' );
},
] );
} );
После этого запрос /wp-json/wp/v2/product?per_page=20&_fields=id,title,meta.price_rub,link возвращает компактный массив. Мета-кеш ядро прогревает само, пока вы работаете через WP_Query внутри штатного контроллера.
Цена: вы привязаны к схеме ядра. Нельзя переименовать поле, нельзя убрать обёртку title.rendered, нельзя поднять per_page выше 100 без фильтра rest_{$post_type}_collection_params.
2. Расширение штатного ответа
Когда структура ядра устраивает, но не хватает одного-двух вычисляемых полей, их добавляют через register_rest_field(). Поле появится и в схеме, и в _fields, то есть останется отключаемым.
add_action( 'rest_api_init', function () {
register_rest_field( 'post', 'reading_time', [
'get_callback' => function ( array $post ) {
$text = wp_strip_all_tags( get_post_field( 'post_content', $post['id'] ) );
// str_word_count не дружит с кириллицей, считаем по юникод-буквам
$words = preg_match_all( '/\p{L}+/u', $text );
return (int) ceil( $words / 180 );
},
'schema' => [
'description' => 'Время чтения в минутах',
'type' => 'integer',
'context' => [ 'view', 'edit' ],
],
] );
} );
Цена: get_callback выполняется для каждого объекта коллекции. Если внутри обращение к базе или к внешнему API, вы своими руками сделали N+1 там, где его не было. Тяжёлые вычисления кешируйте в мете или в объектном кеше.
Фильтровать выборку штатного эндпоинта по своим условиям удобно через rest_{$post_type}_query — он получает массив аргументов WP_Query и объект запроса.
3. Свой маршрут через register_rest_route
Нужен ответ с собственной структурой, агрегация из нескольких таблиц или действие, а не чтение, — пишите маршрут.
add_action( 'rest_api_init', function () {
register_rest_route( 'shop/v1', '/orders/(?P<id>\d+)', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'shop_rest_get_order',
'permission_callback' => function ( WP_REST_Request $request ) {
return current_user_can( 'edit_shop_orders' );
},
'args' => [
'id' => [
'required' => true,
'type' => 'integer',
'validate_callback' => static function ( $value ) {
return is_numeric( $value ) && (int) $value > 0;
},
],
],
] );
} );
function shop_rest_get_order( WP_REST_Request $request ) {
$order_id = (int) $request->get_param( 'id' );
$order = get_post( $order_id );
if ( ! $order || 'shop_order' !== $order->post_type ) {
return new WP_Error(
'shop_order_not_found',
'Заказ не найден',
[ 'status' => 404 ]
);
}
$response = rest_ensure_response( [
'id' => $order->ID,
'status' => $order->post_status,
'total' => (float) get_post_meta( $order->ID, 'order_total', true ),
] );
$response->header( 'Cache-Control', 'private, max-age=0' );
return $response;
}
Два момента, на которых спотыкаются чаще всего. Первый: с WordPress 5.5 маршрут без permission_callback вызывает _doing_it_wrong — в логе появится предупреждение о пропущенном обязательном аргументе. Второй: ошибки возвращайте через WP_Error с ключом status, иначе клиент получит 200 с телом ошибки.
Если в своём маршруте вы сами собираете список записей, мета-кеш придётся прогревать руками:
$query = new WP_Query( [
'post_type' => 'shop_order',
'post_status' => 'wc-processing',
'posts_per_page' => 50,
'fields' => 'ids',
'no_found_rows' => true, // не считаем общее количество, если пагинация не нужна
] );
update_meta_cache( 'post', $query->posts ); // один запрос вместо пятидесяти
foreach ( $query->posts as $order_id ) {
$total = get_post_meta( $order_id, 'order_total', true ); // уже из кеша
}
4. Свой контроллер на WP_REST_Controller
Класс-наследник WP_REST_Controller даёт то, что в процедурном маршруте приходится писать самому: схему объекта, единообразную пагинацию с заголовками X-WP-Total и X-WP-TotalPages, раздельные проверки прав на коллекцию и элемент, поддержку context и _fields. Переопределяют обычно register_routes(), get_items(), get_item(), get_item_schema() и prepare_item_for_response().
Цена — объём кода. Для одного эндпоинта «отдай три цифры для виджета» это оверинжиниринг. Для публичного API плагина, которым пользуются чужие разработчики, — единственный честный вариант: схема автоматически попадает в OPTIONS-ответ и в корневой дискавери.
Сравнение подходов
| Подход | Что выигрываете | Чем платите |
|---|---|---|
_fields на штатных эндпоинтах | Ноль серверного кода, мета-кеш и пагинация уже работают | Схема ядра, обёртки rendered, per_page до 100 |
register_rest_field | Своё поле в штатной схеме, остаётся отключаемым через _fields | Колбэк на каждый объект коллекции, легко получить N+1 |
register_rest_route | Любая структура ответа, агрегация, действия | Валидация, права, пагинация, прогрев кеша — руками |
Наследник WP_REST_Controller | Схема, context, заголовки пагинации, дискавери | Больше всего кода, нужно знать контракт базового класса |
Самый быстрый эндпоинт — тот, который вы не написали. Прежде чем заводить свой маршрут, проверьте, не решает ли задачу
_fieldsна штатном.
Аутентификация с фронта: nonce или пароль приложения
Для запросов из браузера от залогиненного пользователя работает cookie-аутентификация, но ядро дополнительно требует nonce в заголовке X-WP-Nonce. Его отдают на страницу вместе со скриптом:
wp_localize_script( 'shop-front', 'shopRest', [
'root' => esc_url_raw( rest_url() ),
'nonce' => wp_create_nonce( 'wp_rest' ),
] );
const response = await fetch( shopRest.root + 'shop/v1/orders/42', {
credentials: 'same-origin', // без этого cookie не уедут
headers: { 'X-WP-Nonce': shopRest.nonce },
} );
Для внешних интеграций (скрипт на сервере, мобильное приложение, Postman) с WordPress 5.6 в ядре есть пароли приложений — отдельные учётные данные для Basic-аутентификации, которые по умолчанию работают только по HTTPS и отзываются по одному, не ломая основной пароль. Это заметно лучше, чем тащить в проект JWT-плагин ради одного cron-скрипта. Самописные схемы с токеном в query-параметре не делайте вовсе: он попадёт в логи nginx и в referrer.
Что ломается в проде
rest_cookie_invalid_nonce, 403. Классика: включили кеширование страниц, nonce в HTML застыл, а живёт он порядка полусуток-суток. Лечится исключением страниц с REST-запросами из кеша либо получением свежего nonce отдельным запросом. Для анонимных GET-эндпоинтов nonce не нужен вовсе — не требуйте его там, где нет приватных данных.rest_no_route, 404. Либо опечатка в namespace, либо маршрут регистрируется не наrest_api_init, либо на сайте отключены красивые ссылки. Во втором случае проверяйте через/?rest_route=/shop/v1/orders/42— так эндпоинт доступен и без постоянных ссылок.rest_forbiddenпри корректном токене. Плагины безопасности часто режут REST целиком через фильтрrest_authentication_errorsили блокируют заголовокAuthorizationна уровне веб-сервера. Проверяйте не только PHP, но и конфиг nginx или Apache.rest_invalid_param, 400. Обычно потому, что вargsобъявленtype => integer, а клиент прислал строку из поля формы. Либо объявляйте тип честно, либо добавляйтеsanitize_callback.- Ответы не кешируются. Ядро отдаёт на REST-ответы заголовки, запрещающие кеширование. Для публичных эндпоинтов разумнее кешировать результат внутри колбэка (транзиент или объектный кеш) и отдавать свой
Cache-Controlчерез$response->header(), а не бороться с внутренними фильтрами ядра; актуальный список фильтров смотрите в справочнике функций.
Инвалидация кеша и настройка реверс-прокси — отдельная тема, здесь мы только обозначаем точку, где она появляется.
Когда что выбирать
- Читаете записи и таксономии, структура ядра устраивает — штатный эндпоинт плюс
_fields. - Не хватает одного-двух полей, остальное как в ядре —
register_rest_fieldс кешированием тяжёлых вычислений. - Ответ собирается из нескольких источников или эндпоинт что-то меняет — свой маршрут.
- Эндпоинтом будут пользоваться люди вне вашей команды, нужна документируемая схема — контроллер.
Что делать дальше
Снимите с продакшена две цифры по каждому своему эндпоинту: количество SQL-запросов и время ответа. Проще всего через Query Monitor — он показывает запросы в том числе для REST-вызовов. Дальше по порядку: вынесите тяжёлые get_callback в кеш, добавьте no_found_rows там, где пагинация не нужна, и проверьте, что каждый маршрут отвечает 401 или 403 при запросе без авторизации. Последнее стоит закрыть тестом: PHPUnit-класс WP_Test_REST_TestCase из тестового набора ядра позволяет дёргать маршруты напрямую, без HTTP.
* N+1 — антипаттерн, когда вместо одного запроса за данными для всего списка код делает один запрос за списком и ещё по одному на каждый элемент. В WordPress чаще всего вылезает на мете и терминах.
** Nonce — одноразовый токен с ограниченным сроком жизни, привязанный к пользователю и действию. Защищает от CSRF: без него чужой сайт мог бы отправить запрос от имени вашего залогиненного пользователя.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: WordPress REST API Handbook, Code Reference: register_rest_route()
Частые вопросы
У меня WordPress 5.x, всё это сработает?
Эндпоинт отдаёт 404 с кодом rest_no_route, хотя код на месте
Как отдать больше 100 записей за один запрос?
Можно ли закрыть REST API полностью, если он не нужен?
Что из этого чаще всего стреляет под нагрузкой?
Нужен ли nonce для публичного GET-эндпоинта?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.