WordPress to nie tylko PHP i szablony, ponieważ od wersji 4.7 ma wbudowane
REST API, dzięki któremu może funkcjonować jako headless CMS. Backend w
WordPressie, frontend w React lub Next.js to w wielu projektach bardzo
rozsądne rozwiązanie.
REST API jest wbudowane od WordPressa 4.7 — pod warunkiem włączonych ładnych permalinków; sprawdzisz je, otwierając /wp-json/wp/v2/posts.
_embed i _fields kontrolują wielkość odpowiedzi — pierwszy dołącza powiązane dane (obrazek, kategorie) w jednym requeście, drugi tnie zbędne pola i transfer.
Paginacja siedzi w nagłówkach — X-WP-Total i X-WP-TotalPages, a nie w samym JSON-ie, który zwraca tylko bieżącą stronę.
CORS tylko dla zaufanych domen — nigdy gwiazdka * z Access-Control-Allow-Credentials: true; to wprost chroni przed kradzieżą sesji.
Zapis wymaga autentykacji server-to-server — Application Passwords (od WP 5.6) po HTTPS; nigdy nie wysyłaj poświadczeń do klienta w przeglądarce.
Cachuj na dwóch poziomach — Transient API po stronie WordPressa dla kosztownych zapytań i revalidate po stronie Next.js dla ISR.
W tym artykule pokażę jak prosto zbudować tę integrację od podstaw, a działa tu zarówno REST API REST API to interfejs udostępniający dane przez standardowe metody HTTP (GET, POST...) pod adresami zasobów — w WordPressie domyślnie pod /wp-json/. , jak i architektura headless CMS CMS, czyli Content Management System, to system do zarządzania treścią bez ręcznej edycji kodu. . Nie wiesz jeszcze za dużo o WordPressie? Zobacz artykuł WordPressa od zera — instalacji, konfiguracji i podstaw .
Czym jest WordPress REST API?
REST API to interfejs HTTP do danych WordPressa, który zamiast renderować gotowy HTML, WordPress zwraca czysty JSON:
# Pobierz posty
curl https://twojadomena.pl/wp-json/wp/v2/posts
# Pobierz strony
curl https://twojadomena.pl/wp-json/wp/v2/pages
# Pobierz media
curl https://twojadomena.pl/wp-json/wp/v2/media
W odpowiedzi dostajesz surowe dane, po które dowolny frontend w JavaScript może sięgnąć i wyrenderować je po swojemu.
Wbudowane endpointy
WordPress udostępnia domyślnie:
Endpoint Metody Opis /wp/v2/postsGET, POST, PUT, DELETE Posty /wp/v2/pagesGET, POST, PUT, DELETE Strony /wp/v2/mediaGET, POST, PUT, DELETE Media /wp/v2/categoriesGET, POST, PUT, DELETE Kategorie /wp/v2/tagsGET, POST, PUT, DELETE Tagi /wp/v2/usersGET, POST, PUT, DELETE Użytkownicy /wp/v2/commentsGET, POST, PUT, DELETE Komentarze
Przy listingach zwróć uwagę na nagłówki odpowiedzi X-WP-Total i X-WP-TotalPages, bo to z nich budujesz paginację po stronie klienta. Sam JSON zwraca tylko bieżącą stronę.
Parametry zapytań
# Paginacja
/wp/v2/posts?page =2 & per_page = 10
# Filtrowanie po kategorii
/wp/v2/posts?categories =5
# Wyszukiwanie
/wp/v2/posts?search =javascript
# Sortowanie
/wp/v2/posts?orderby =date & order = desc
# Wybór pól (oszczędność transferu)
/wp/v2/posts?_fields =id,title,slug,excerpt
# Embed (dołącz powiązane dane)
/wp/v2/posts?_embed
Parametr _embed jest flagą, więc wystarczy jego obecność (?_embed), a wartość true jest ignorowana po stronie serwera, choć zadziała. W dalszych przykładach używam _embed=true dla czytelności w URLSearchParams.
Konfiguracja WordPress
1. Permalinki
REST API wymaga ładnych permalinków:
Ustawienia → Bezpośrednie odnośniki → Nazwa wpisu (lub dowolne inne niż "Prosty")
2. CORS (Cross-Origin)
Jeśli frontend jest na innej domenie:
// functions.php lub wtyczka
add_action ( 'rest_api_init' , function () {
remove_filter ( 'rest_pre_serve_request' , 'rest_send_cors_headers' ) ;
add_filter ( 'rest_pre_serve_request' , function ($value) {
$origin = get_http_origin () ;
$allowed_origins = [
'http://localhost:3000' ,
'https://moj-frontend.vercel.app' ,
];
if (in_array ( $origin , $allowed_origins ) ) {
header ( 'Access-Control-Allow-Origin: ' . $origin ) ;
header ( 'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS' ) ;
header ( 'Access-Control-Allow-Credentials: true' ) ;
header ( 'Access-Control-Allow-Headers: Authorization, Content-Type' ) ;
}
return $value;
} ) ;
} ) ;
Najważniejsze, by dopuszczać wyłącznie zaufane domeny (np. własny frontend) i nigdy nie pozwalać „każdemu” (*) na zapytania z ciasteczkami logowania. Te dwie zasady chronią przed kradzieżą sesji użytkowników.
3. Ukrycie wrażliwych danych
Nie zakładaj, że każde pole z odpowiedzi powinno być publiczne. Jeśli wystawiasz własne endpointy albo pola użytkowników, jawnie ograniczaj dane:
add_filter ( 'rest_prepare_user' , function ($response , $user , $request) {
unset ( $response -> data[ 'email' ] ) ;
return $response;
} , 10 , 3 ) ;
Integracja z Next.js
Klient API
// lib/wordpress.ts
const WP_URL = process . env . WORDPRESS_URL || 'https://twojadomena.pl'
const API_URL = ` ${ WP_URL } /wp-json/wp/v2`
interface WPPost {
id : number
slug : string
title : { rendered : string }
content : { rendered : string }
excerpt : { rendered : string }
date : string
featured_media : number
categories : number []
_embedded ?: {
'wp:featuredmedia' ?: Array <{ source_url : string }>
'wp:term' ?: Array < Array <{ id : number ; name : string ; slug : string }>>
}
}
interface FetchOptions {
page ?: number
perPage ?: number
categories ?: number []
search ?: string
slug ?: string
}
export async function getPosts (options : FetchOptions = {}) : Promise < WPPost []> {
const params = new URLSearchParams ({
_embed : 'true' ,
per_page : String ( options .perPage || 10 ) ,
page : String ( options .page || 1 ) ,
})
if ( options . categories ?. length ) {
params .set ( 'categories' , options . categories .join ( ',' ))
}
if ( options .search) {
params .set ( 'search' , options .search)
}
if ( options .slug) {
params .set ( 'slug' , options .slug)
}
const response = await fetch ( ` ${ API_URL } /posts? ${ params } ` , {
next : { revalidate : 60 } , // ISR: odświeżaj dane co 60 sekund
})
if ( ! response .ok) {
throw new Error ( `WordPress API error: ${ response .status } ` )
}
return response .json ()
}
export async function getPostBySlug (slug : string ) : Promise < WPPost | null > {
const posts = await getPosts ({ slug })
return posts[ 0 ] || null
}
export async function getCategories () {
const response = await fetch ( ` ${ API_URL } /categories?per_page=100` , {
next : { revalidate : 3600 } ,
})
return response .json ()
}
Strona listingu
// app/blog/page.tsx
import { getPosts } from '@/lib/wordpress'
import Link from 'next/link'
export const revalidate = 60
export default async function BlogPage () {
const posts = await getPosts ({ perPage : 12 })
return (
< main className = "container mx-auto px-4 py-8" >
< h1 className = "mb-8 text-3xl font-bold" >Blog</ h1 >
< div className = "grid gap-6 md:grid-cols-2 lg:grid-cols-3" >
{ posts .map ((post) => {
const featuredImage =
post ._embedded?.[ 'wp:featuredmedia' ]?.[ 0 ]?.source_url
return (
< article
key = { post .id}
className = "overflow-hidden rounded-lg border"
>
{featuredImage && (
< img
src = {featuredImage}
alt = ""
className = "h-48 w-full object-cover"
/>
)}
< div className = "p-4" >
< h2 className = "mb-2 text-xl font-semibold" >
< Link href = { '/blog/' + post .slug}>
< span
dangerouslySetInnerHTML = {{ __html : post . title .rendered }}
/>
</ Link >
</ h2 >
< div
className = "line-clamp-3 text-gray-600"
dangerouslySetInnerHTML = {{ __html : post . excerpt .rendered }}
/>
</ div >
</ article >
)
})}
</ div >
</ main >
)
}
Strona pojedynczego posta
// app/blog/[slug]/page.tsx
import { getPostBySlug , getPosts } from '@/lib/wordpress'
import { notFound } from 'next/navigation'
interface Props {
params : Promise <{ slug : string }>
}
// Generowanie statycznych ścieżek
export async function generateStaticParams () {
const posts = await getPosts ({ perPage : 100 })
return posts .map ((post) => ({ slug : post .slug }))
}
// Metadata dla SEO
export async function generateMetadata ({ params } : Props ) {
const { slug } = await params
const post = await getPostBySlug (slug)
if ( ! post) {
return { title : 'Nie znaleziono' }
}
return {
title : post . title .rendered ,
description : post . excerpt . rendered .replace ( /<[ ^ >] * >/ g , '' ) .slice ( 0 , 160 ) ,
}
}
export default async function PostPage ({ params } : Props ) {
const { slug } = await params
const post = await getPostBySlug (slug)
if ( ! post) {
notFound ()
}
const featuredImage = post ._embedded?.[ 'wp:featuredmedia' ]?.[ 0 ]?.source_url
return (
< article className = "container mx-auto max-w-3xl px-4 py-8" >
{featuredImage && (
< img
src = {featuredImage}
alt = ""
className = "mb-8 h-64 w-full rounded-lg object-cover"
/>
)}
< h1
className = "mb-4 text-4xl font-bold"
dangerouslySetInnerHTML = {{ __html : post . title .rendered }}
/>
< time className = "mb-8 block text-gray-500" >
{ new Date ( post .date) .toLocaleDateString ( 'pl-PL' )}
</ time >
< div
className = "prose prose-lg max-w-none"
dangerouslySetInnerHTML = {{ __html : post . content .rendered }}
/>
</ article >
)
}
Custom endpoints
Wbudowane endpointy często nie wystarczają i wtedy warto napisać własne:
Prosty endpoint
// functions.php lub wtyczka
add_action ( 'rest_api_init' , function () {
register_rest_route ( 'moja-api/v1' , '/featured-posts' , [
'methods' => 'GET' ,
'callback' => 'get_featured_posts' ,
'permission_callback' => '__return_true' ,
] ) ;
} ) ;
function get_featured_posts () {
$posts = get_posts ( [
'meta_key' => 'is_featured' ,
'meta_value' => '1' ,
'posts_per_page' => 5 ,
] ) ;
return array_map ( function ($post) {
return [
'id' => $post -> ID ,
'title' => $post -> post_title ,
'slug' => $post -> post_name ,
'image' => get_the_post_thumbnail_url ( $post -> ID , 'large' ),
];
} , $posts ) ;
}
Endpoint z parametrami
register_rest_route ( 'moja-api/v1' , '/posts-by-author/(?P<author_id>\d+)' , [
'methods' => 'GET' ,
'callback' => function ($request) {
$author_id = $request[ 'author_id' ];
$page = $request -> get_param ( 'page' ) ?: 1 ;
$query = new WP_Query ( [
'author' => $author_id ,
'posts_per_page' => 10 ,
'paged' => $page ,
] ) ;
return [
'posts' => array_map ( 'format_post' , $query -> posts ),
'total' => $query -> found_posts ,
'total_pages' => $query -> max_num_pages ,
];
} ,
'args' => [
'author_id' => [
'required' => true ,
'validate_callback' => function ($param) {
return is_numeric ( $param ) ;
} ,
] ,
'page' => [
'default' => 1 ,
'validate_callback' => function ($param) {
return is_numeric ( $param ) && $param > 0 ;
} ,
] ,
] ,
'permission_callback' => '__return_true' ,
] ) ;
Autentykacja
Jeśli zamierzasz dodawać, edytować lub usuwać dane (przy użyciu metod POST, PUT i DELETE), musisz się wcześniej uwierzytelnić.
Application Passwords (WordPress 5.6+)
Użytkownicy → Twój profil → Application Passwords → Dodaj
Użycie:
const credentials = Buffer .from (
`username:xxxx xxxx xxxx xxxx xxxx xxxx` ,
) .toString ( 'base64' )
const response = await fetch ( ` ${ API_URL } /posts` , {
method : 'POST' ,
headers : {
Authorization : `Basic ${ credentials } ` ,
'Content-Type' : 'application/json' ,
} ,
body : JSON .stringify ({
title : 'Nowy post' ,
content : 'Treść posta...' ,
status : 'publish' ,
}) ,
})
To rozwiązanie jest sensowne głównie dla połączeń pomiędzy serwerami (np. Twój backend Next.js rozmawia z WordPressem) albo dla zaplecza redakcyjnego. Nie wysyłaj takich danych uwierzytelniających do publicznego klienta w przeglądarce.
JWT (z wtyczką)
Zainstaluj wtyczkę "JWT Authentication for WP REST API":
// Pobierz token
const tokenResponse = await fetch ( ` ${ WP_URL } /wp-json/jwt-auth/v1/token` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON .stringify ({
username : 'admin' ,
password : 'haslo' ,
}) ,
})
const { token } = await tokenResponse .json ()
// Użyj tokena
const response = await fetch ( ` ${ API_URL } /posts` , {
method : 'POST' ,
headers : {
Authorization : `Bearer ${ token } ` ,
'Content-Type' : 'application/json' ,
} ,
body : JSON .stringify ({ title : 'Test' , content : '...' , status : 'draft' }) ,
})
JWT bywa wygodne, ale to dodatkowa warstwa i dodatkowa odpowiedzialność za przechowywanie tokena, odświeżanie go i bezpieczeństwo po stronie klienta.
Dodawanie pól do odpowiedzi
Custom fields (ACF lub natywne)
// Dodaj pole do odpowiedzi API
add_action ( 'rest_api_init' , function () {
register_rest_field ( 'post' , 'reading_time' , [
'get_callback' => function ($post) {
$content = get_post_field ( 'post_content' , $post[ 'id' ] ) ;
preg_match_all ( '/\p{L} + /u' , wp_strip_all_tags ( $content ), $matches ) ;
$word_count = count ( $matches[ 0 ] ) ;
return ceil ( $word_count / 200 ) ;
} ,
] ) ;
// Jeśli używasz ACF
register_rest_field ( 'post' , 'custom_subtitle' , [
'get_callback' => function ($post) {
return get_field ( 'subtitle' , $post[ 'id' ] ) ;
} ,
] ) ;
} ) ;
Teraz w odpowiedzi API:
{
"id" : 123 ,
"title" : { "rendered" : "Mój post" } ,
"reading_time" : 5 ,
"custom_subtitle" : "Podtytuł z ACF"
}
Podgląd postów (preview)
Aby zobaczyć podgląd szkicu (draftu) przed publikacją, potrzebujesz autoryzacji:
// lib/wordpress.ts
export async function getPreviewPost (id : number , token : string ) {
const response = await fetch ( ` ${ API_URL } /posts/ ${ id } ?status=draft` , {
headers : {
Authorization : `Bearer ${ token } ` ,
} ,
cache : 'no-store' ,
})
return response .json ()
}
// app/preview/[id]/page.tsx
import { getPreviewPost } from '@/lib/wordpress'
import { cookies } from 'next/headers'
export default async function PreviewPage ({
params ,
} : {
params : Promise <{ id : string }>
}) {
const { id } = await params
const token = ( await cookies ()) .get ( 'wp_preview_token' )?.value
if ( ! token) {
return < div >Brak autoryzacji</ div >
}
const post = await getPreviewPost ( Number (id) , token)
return (
< div className = "bg-yellow-100 p-4" >
< p className = "font-bold" >PREVIEW MODE</ p >
< article dangerouslySetInnerHTML = {{ __html : post . content .rendered }} />
</ div >
)
}
Wydajność
1. Wybieraj tylko potrzebne pola
// Zamiast pobierać wszystko
const posts = await fetch ( ` ${ API_URL } /posts?_embed=true` )
// Wybierz tylko potrzebne
const posts = await fetch ( ` ${ API_URL } /posts?_fields=id,title,slug,excerpt` )
Jeśli łączysz _fields z _embed, pamiętaj, że możesz przypadkiem wyciąć sobie potrzebne _embedded albo _links.
2. Cache na poziomie Next.js
// ISR
const posts = await fetch (url , {
next : { revalidate : 60 } ,
})
// Statyczne (pobierane w trakcie budowania aplikacji)
const posts = await fetch (url , {
cache : 'force-cache' ,
})
// Bez cache
const posts = await fetch (url , {
cache : 'no-store' ,
})
3. Cache w WordPress
// Transient API dla kosztownych operacji
function get_featured_posts_cached () {
$cached = get_transient ( 'featured_posts' ) ;
if ($cached !== false ) {
return $cached;
}
$posts = expensive_query_here () ;
set_transient ( 'featured_posts' , $posts , HOUR_IN_SECONDS ) ;
return $posts;
}
Porównanie: REST API vs GraphQL (WPGraphQL)
Aspekt REST API WPGraphQL Instalacja Wbudowane Wymaga wtyczki Elastyczność zapytań Ograniczona Pełna Pobieranie zbędnych pól Tak (overfetching) Nie Próg wejścia Niższy Wyższy Dostępne narzędzia Standardowe (fetch) Apollo, urql Cache Standardowy HTTP Wymaga konfiguracji
Przy prostych projektach REST API w zupełności wystarczy, a po WPGraphQL warto sięgnąć dopiero, gdy zapytania robią się złożone.
Werdykt Labu WordPress REST API to solidny fundament pod architekturę headless CMS. Wbudowane endpointy rozwiązują od razu większość typowych problemów z pobieraniem danych, a jeśli potrzebujesz specyficznej logiki biznesowej, możesz wykorzystać własne endpointy. Autentykację dopasujesz do projektu, od prostych Application Passwords po tokeny JWT, a parametry _embed i _fields pozwalają dokładnie kontrolować wielkość paczek JSON i dbać o wydajność. W połączeniu z App Routerem i mechanizmem ISR Next.js tworzy z WordPressem wartościową kombinację.
Osiągnięcie właściwego balansu daje redaktorom swobodę w korzystaniu ze znajomego panelu do zarządzania treścią, a Ty masz pełną kontrolę nad wydajnością i nowoczesnym frontendem. Więcej o tym, kiedy taki wariant się opłaca, znajdziesz w artykule Headless WordPress + Next.js — kiedy warto? .
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js