Wszystkie posty
wordpress

WordPress REST API: praktyczne przypadki użycia dla custom post types

Jak używać WordPress REST API z custom post types w produkcji. Konkretne przykłady: headless CMS, mobile app backend, integracje z AI. Kod PHP + JS gotowy do użycia.

7 min czytaniaAktualizacja: 24 czerwca 2026

WordPress REST API: praktyczne przypadki użycia dla custom post types

WordPress REST API to niedoceniane narzędzie. Wiele osób traktuje WP jako "tylko CMS do blogów", a tymczasem pod spodem jest w pełni funkcjonalny backend z uwierzytelnianiem, uprawnieniami, custom post types i polami meta. Ten post to konkretne use case'y, nie sucha dokumentacja.

Custom post type z REST API support

Pierwszy krok: przy rejestracji CPT dodaj 'show_in_rest' => true i 'rest_base' => 'projekty':

// functions.php lub własny plugin
add_action('init', function () {
    register_post_type('projekt', [
        'labels' => [
            'name' => 'Projekty',
            'singular_name' => 'Projekt',
        ],
        'public' => true,
        'show_in_rest' => true,        // KLUCZ: eksponuje w REST API
        'rest_base' => 'projekty',     // /wp/v2/projekty
        'rest_controller_class' => 'WP_REST_Posts_Controller',
        'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
        'has_archive' => true,
        'menu_icon' => 'dashicons-portfolio',
        'rewrite' => ['slug' => 'projekty'],
    ]);
});

Od teraz endpoint /wp-json/wp/v2/projekty zwraca tablicę projektów jako JSON. Każdy projekt to pełny post object z polami: id, title (rendered + raw), content, excerpt, featured_media, meta.

Dodawanie custom fields do REST API

Domyślnie custom fields (post meta) nie są widoczne w REST. Trzeba je jawnie zarejestrować:

// Rejestracja meta box + REST exposure
add_action('init', function () {
    register_post_meta('projekt', 'klient', [
        'type' => 'string',
        'single' => true,
        'show_in_rest' => true,
        'sanitize_callback' => 'sanitize_text_field',
        'auth_callback' => function () {
            return current_user_can('edit_posts');
        },
    ]);

    register_post_meta('projekt', 'budzet', [
        'type' => 'number',
        'single' => true,
        'show_in_rest' => true,
        'sanitize_callback' => 'absint',
    ]);

    register_post_meta('projekt', 'data_realizacji', [
        'type' => 'string',
        'single' => true,
        'show_in_rest' => true,
        'sanitize_callback' => function ($value) {
            // ISO 8601 date validation
            $d = DateTime::createFromFormat('Y-m-d', $value);
            return $d && $d->format('Y-m-d') === $value ? $value : '';
        },
    ]);
});

Trzy rzeczy do zapamiętania:

  • sanitize_callback — zawsze. Bez niego user może wpisać cokolwiek w pole meta (XSS, SQL injection w edge case'ach).
  • auth_callback — kto może edytować to pole. Domyślnie nikt. Zwraca true gdy user ma uprawnienia.
  • Walidacja formatu — dla dat, URL-i, JSON-ów. Lepiej zwrócić pusty string niż zaakceptować 2025-13-99.

Custom endpoint z walidacją i uprawnieniami

Czasem potrzebujesz endpointu, który nie mapuje 1:1 na CPT. Np. "daj mi statystyki projektu":

add_action('rest_api_init', function () {
    register_rest_route('custom/v1', '/projekt/(?P<id>\d+)/stats', [
        'methods' => 'GET',
        'callback' => 'get_projekt_stats',
        'permission_callback' => function () {
            return current_user_can('read');
        },
        'args' => [
            'id' => [
                'validate_callback' => function ($param) {
                    return is_numeric($param);
                },
            ],
        ],
    ]);
});

function get_projekt_stats(WP_REST_Request $request) {
    $projekt_id = absint($request['id']);

    $projekt = get_post($projekt_id);
    if (!$projekt || $projekt->post_type !== 'projekt') {
        return new WP_Error('not_found', 'Projekt nie istnieje', ['status' => 404]);
    }

    // Cache'owanie na 1h
    $cache_key = "projekt_stats_{$projekt_id}";
    $stats = wp_cache_get($cache_key);
    if ($stats === false) {
        $stats = [
            'views' => (int) get_post_meta($projekt_id, 'views', true),
            'leads' => (int) get_post_meta($projekt_id, 'leads', true),
            'conversion_rate' => $this->calc_conversion($projekt_id),
            'tasks_total' => $this->count_tasks($projekt_id),
            'tasks_done' => $this->count_tasks($projekt_id, 'done'),
        ];
        wp_cache_set($cache_key, $stats, '', HOUR_IN_SECONDS);
    }

    return new WP_REST_Response($stats, 200);
}

Kluczowe punkty:

  1. permission_callback — oddzielnie od callback. WordPress sprawdza uprawnienia PRZED wywołaniem logiki. Bez tego każdy może odpytać endpoint.

  2. validate_callback w args — walidacja URL parametrów. Bez tego ktoś może wpisać /projekt/abc/stats i dostać 500 error.

  3. Cachewp_cache_get + wp_cache_set. WordPress używa object cache (domyślnie transient, z Redis/Memcached — szybko).

  4. WP_Error z status code — nigdy nie zwracaj null ani pustej tablicy. Zwraca sensowne błędy z kodami HTTP (404, 403, 500).

Dodawanie computed fields do standardowych endpointów

Czasem chcesz rozszerzyć istniejący endpoint o dodatkowe pole, np. "related_projects" w odpowiedzi /wp/v2/projekty:

add_action('rest_api_init', function () {
    register_rest_field('projekt', 'related_projects', [
        'get_callback' => 'get_related_projects',
        'update_callback' => null,    // read-only
        'schema' => [
            'description' => 'Lista 3 powiązanych projektów',
            'type' => 'array',
            'items' => ['type' => 'object'],
        ],
    ]);
});

function get_related_projects($post) {
    $tags = wp_get_post_tags($post['id'], ['fields' => 'ids']);
    if (empty($tags)) return [];

    $related = get_posts([
        'post_type' => 'projekt',
        'post__not_in' => [$post['id']],
        'posts_per_page' => 3,
        'tag__in' => $tags,
        'fields' => 'ids',
    ]);

    return array_map(function ($id) {
        $p = get_post($id);
        return [
            'id' => $id,
            'title' => $p->post_title,
            'permalink' => get_permalink($id),
            'klient' => get_post_meta($id, 'klient', true),
        ];
    }, $related);
}

Teraz każde wywołanie /wp/v2/projekty/123 zwraca dodatkowe pole related_projects w JSON. Frontend może je wyświetlić bez dodatkowych requestów.

Uwierzytelnianie dla zewnętrznych integracji

Dla system-to-system (np. Next.js frontend → WP backend) nie używaj cookie auth. Użyj Application Passwords (WP 5.6+):

# Generowanie hasła w WP Admin: Users → Profile → Application Passwords
# WordPress daje 24-znakowe hasło, np. "abcd EFGH ijkl MNOP qrst UVWX"

# Request z autoryzacją
curl -u "admin:abcd EFGH ijkl MNOP qrst UVWX" \
  https://example.com/wp-json/wp/v2/projekty

W Node.js / Next.js:

const username = process.env.WP_USERNAME!;
const appPassword = process.env.WP_APP_PASSWORD!;
const baseUrl = process.env.WP_BASE_URL!;

const auth = Buffer.from(`${username}:${appPassword}`).toString('base64');

async function fetchProjects() {
  const res = await fetch(`${baseUrl}/wp-json/wp/v2/projekty?per_page=100`, {
    headers: { Authorization: `Basic ${auth}` },
    next: { revalidate: 60 }, // ISR w Next.js
  });
  return res.json();
}

Trzy warstwy bezpieczeństwa, które wdrażam w każdym projekcie:

  1. HTTPS only — bez tego hasło leci plain text.
  2. Application Password scope — hasło per integracja, nie główne. Jeśli wycieknie — unieważniasz jedno, nie wszystkie.
  3. Rate limiting w Cloudflare — patrz poprzedni post o Cloudflare. Bez tego boty mogą wyciągnąć wszystkie dane.

Use case 1: Headless CMS dla Next.js

WordPress jako backend, Next.js jako frontend. Pełna kontrola nad UX, SSG dla SEO, React dla interaktywności:

// app/projekty/page.tsx
import { headers } from 'next/headers';

async function getProjects() {
  const auth = Buffer.from(
    `${process.env.WP_USERNAME}:${process.env.WP_APP_PASSWORD}`
  ).toString('base64');

  const res = await fetch(
    `${process.env.WP_BASE_URL}/wp-json/wp/v2/projekty?_embed&per_page=50`,
    {
      headers: { Authorization: `Basic ${auth}` },
      next: { revalidate: 300 }, // regeneruj co 5 min
    }
  );

  if (!res.ok) throw new Error('Failed to fetch projects');
  return res.json();
}

export default async function ProjektyPage() {
  const projects = await getProjects();

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {projects.map((p) => (
        <article key={p.id} className="border rounded p-4">
          <h2 dangerouslySetInnerHTML={{ __html: p.title.rendered }} />
          <div dangerouslySetInnerHTML={{ __html: p.excerpt.rendered }} />
          <span className="text-sm text-gray-500">{p.klient}</span>
        </article>
      ))}
    </div>
  );
}

_embed w URL zwraca powiązane zasoby w jednym requeście: featured image (pełne URL-e w różnych rozmiarach), author (z avatar), terms (tagi, kategorie). Bez _embed musiałbyś robić N dodatkowych requestów na featured_media każdego projektu.

Use case 2: AI integration - generowanie opisu projektu

Mój workflow dla klientów: AI generuje draft opisu, redaktor akceptuje. Endpoint POST /custom/v1/projekt/<id>/ai-description:

add_action('rest_api_init', function () {
    register_rest_route('custom/v1', '/projekt/(?P<id>\d+)/ai-description', [
        'methods' => 'POST',
        'callback' => 'generate_ai_description',
        'permission_callback' => function () {
            return current_user_can('edit_posts');
        },
    ]);
});

function generate_ai_description(WP_REST_Request $request) {
    $projekt_id = absint($request['id']);
    $projekt = get_post($projekt_id);
    if (!$projekt) {
        return new WP_Error('not_found', 'Projekt nie istnieje', ['status' => 404]);
    }

    $klient = get_post_meta($projekt_id, 'klient', true);
    $budzet = get_post_meta($projekt_id, 'budzet', true);

    $prompt = "Napisz 3-zdaniowy opis projektu IT w języku polskim.\n";
    $prompt .= "Klient: $klient\n";
    $prompt .= "Budżet: $budzet PLN\n";
    $prompt .= "Tytuł: {$projekt->post_title}\n";
    $prompt .= "Bieżący opis: {$projekt->post_excerpt}\n";

    $response = openai_chat_completion([
        'model' => 'gpt-4o-mini',
        'messages' => [
            ['role' => 'system', 'content' => 'Jesteś redaktorem opisu projektów IT.'],
            ['role' => 'user', 'content' => $prompt],
        ],
        'temperature' => 0.7,
    ]);

    if (is_wp_error($response)) {
        return $response;
    }

    // Zapisz do custom field, NIE nadpisuj post_content
    update_post_meta($projekt_id, 'ai_description_draft', $response['content']);

    return new WP_REST_Response([
        'draft' => $response['content'],
        'cost_usd' => $response['cost'],
    ], 200);
}

Taki endpoint daje redaktorowi draft do zaakceptowania, zamiast nadpisywać treść od razu. Bezpieczeństwo: tylko user z uprawnieniem edit_posts może wywołać.

Use case 3: Mobile app backend

Prosta app mobilna (React Native) dla zespołu — pobiera aktywne projekty, pozwala dodać task. Endpointy z autoryzacją:

add_action('rest_api_init', function () {
    register_rest_route('mobile/v1', '/projekty', [
        'methods' => 'GET',
        'callback' => 'mobile_get_projekty',
        'permission_callback' => 'mobile_check_token',
    ]);

    register_rest_route('mobile/v1', '/projekty/(?P<id>\d+)/tasks', [
        'methods' => 'POST',
        'callback' => 'mobile_create_task',
        'permission_callback' => 'mobile_check_token',
    ]);
});

function mobile_check_token(WP_REST_Request $request) {
    $token = $request->get_header('X-Mobile-Token');
    if (!$token) return new WP_Error('no_token', 'Brak tokenu', ['status' => 401]);

    // HMAC: token = HMAC(secret, timestamp)
    $timestamp = $request->get_header('X-Mobile-Timestamp');
    $expected = hash_hmac('sha256', $timestamp, MOBILE_SECRET);
    if (!hash_equals($expected, $token)) {
        return new WP_Error('invalid_token', 'Nieprawidłowy token', ['status' => 401]);
    }
    return true;
}

HMAC w headerze: app wysyła X-Mobile-Token (HMAC z secret) i X-Mobile-Timestamp (Unix time). Backend weryfikuje HMAC, sprawdza timestamp (max 5 min różnicy żeby uniknąć replay attacks). Prostsze niż OAuth, bezpieczniejsze niż API key w URL.

Debugowanie i testowanie

# Test endpointu z autoryzacją
curl -u "admin:abcd EFGH ijkl MNOP qrst UVWX" \
  https://example.com/wp-json/wp/v2/projekty?per_page=5

# Sprawdź dostępne endpointy
curl https://example.com/wp-json/ | jq

# Sprawdź custom fields w odpowiedzi
curl https://example.com/wp-json/wp/v2/projekty/123 | jq '.klient, .budzet'

# Sprawdź response headers (rate limit, cache)
curl -I https://example.com/wp-json/wp/v2/projekty

Plugin Query Monitor pokazuje w adminie WP: ile query SQL leci na request, ile czasu, które endpointy są wolne. Bez niego optymalizujesz na ślepo.

Co dalej

Jeśli masz projekt WordPress, który chcesz przenieść na headless architekturę albo rozbudować o AI features — napisz do mnie. Realizacja: 2-4 tygodnie, koszt od 4000 PLN.

Tagi:#wordpress#rest-api#php#headless#custom-post-types

Najczęściej zadawane pytania

Czy WordPress REST API jest stabilny w produkcji?
Tak, od wersji 4.4 (2015). Używam go produkcyjnie od 2017 roku — setki tysięcy requestów dziennie bez problemu. Wersja WP 5.5+ ma osobny namespace 'wp/v2' (stabilny kontrakt). Wersje 4.x używały 'wp/v1' — porzucone, ale stare endpointy działają.
Headless WordPress czy klasyczny WordPress z AJAX?
Headless (Next.js/Astro frontend + WP backend) — dla dużych projektów (e-commerce, SaaS, portale z tysiącami podstron), gdy frontend musi być szybki (React/SSG). Klasyczny WP z AJAX — dla blogów, stron firmowych, małych sklepów. Dodatkowa złożoność headless opłaca się dopiero powyżej 50k odsłon miesięcznie lub gdy potrzebujesz AI/ML po stronie frontendu.
Jak zabezpieczyć custom endpoint przed nieautoryzowanym dostępem?
Trzy warstwy: (1) WordPress nonces dla uwierzytelnionych userów (sprawdzasz zalogowanie + uprawnienia), (2) Application Passwords dla zewnętrznych integracji (WP 5.6+), (3) custom token w header (HMAC) dla system-to-system. Nigdy nie zostawiaj endpointu bez sprawdzenia `current_user_can()`.
Czy mogę zwrócić related posts w jednym requeście?
Tak — register_rest_field dodaje pole do odpowiedzi. Możesz dodać 'related_posts' do endpointu /wp/v2/posts, a WP zwróci tablicę 5 powiązanych postów w jednym JSON. Unikasz N+1 zapytań i łańcuchowania requestów z frontendu.

Powiązane posty