REST API WordPressa — integracja z React i Next.js

Jak połączyć WordPress z nowoczesnym frontendem bez typowych pułapek? REST API, autentykacja, custom endpoints, cache i praktyczne przykłady z Next.js.

Opublikowano

29 stycznia 2025 09:00

Czytanie

5 min czytania

Aktualizacja

15 kwietnia 2026 11:52

WordPress to nie tylko PHP i szablony. Od wersji 4.7 ma wbudowane REST API to styl projektowania interfejsów oparty na zasobach, metodach HTTP i bezstanowej komunikacji., dzięki któremu może działać jako headless CMS, czyli Content Management System, to system do zarządzania treścią bez ręcznej edycji kodu.. Backend w WordPressie, frontend w React lub Next.js — to w wielu projektach bardzo sensowny układ.

W tym artykule pokażę jak zbudować tę integrację od podstaw. Jeśli nie znasz jeszcze WordPressa od środka, zacznij od WordPressa od zera — instalacji, architektury i podstaw działania.

Krótka odpowiedź: WordPress REST API, czyli Application Programming Interface, definiuje sposób komunikacji między aplikacjami lub modułami. (dostępne pod /wp-json/wp/v2/) udostępnia treści jako JSON, co pozwala używać WordPressa jako headless CMS z dowolnym frontendem. W Next.js App Router pobierasz dane w Server Components przez fetch() z opcją next: { revalidate }, generujesz statyczne ścieżki przez generateStaticParams i obsługujesz CORS dla różnych domen. Do operacji zapisu potrzebujesz autentykacji — Application Passwords dla połączeń server-to-server lub JWT, czyli JSON Web Token, to podpisany token używany często do autoryzacji i przekazywania tożsamości użytkownika. przez wtyczkę.

Czym jest WordPress REST API?

REST API to interfejs HTTP do danych WordPressa. Zamiast renderować HTML, WordPress zwraca JSON:

Code
# 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

Odpowiedź to czyste dane — idealne dla frontendu JavaScript.

Wbudowane endpointy

WordPress udostępnia domyślnie:

EndpointMetodyOpis
/wp/v2/postsGET, POST, PUT, DELETEPosty
/wp/v2/pagesGET, POST, PUT, DELETEStrony
/wp/v2/mediaGET, POST, DELETEMedia
/wp/v2/categoriesGET, POST, PUT, DELETEKategorie
/wp/v2/tagsGET, POST, PUT, DELETETagi
/wp/v2/usersGET, POST, PUT, DELETEUżytkownicy
/wp/v2/commentsGET, POST, PUT, DELETEKomentarze

Parametry zapytań

Code
# 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=true

Konfiguracja WordPress

1. Permalinki

REST API wymaga ładnych permalinków:

Code
Ustawienia → Bezpośrednie odnośniki → Nazwa wpisu (lub dowolne inne niż "Prosty")

2. CORS (Cross-Origin)

Jeśli frontend jest na innej domenie:

Code
// 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: whitelistuj tylko zaufane originy i nie łącz Access-Control-Allow-Credentials: true z wildcardem *.

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:

Code
add_filter('rest_prepare_user', function($response, $user, $request) {
    unset($response->data['email']);
    return $response;
}, 10, 3);

Integracja z Next.js

Klient API

Code
// 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: rewaliduj co 60s
  })
  
  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

Code
// 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="text-3xl font-bold mb-8">Blog</h1>
      
      <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
        {posts.map((post) => {
          const featuredImage = post._embedded?.['wp:featuredmedia']?.[0]?.source_url
          
          return (
            <article key={post.id} className="border rounded-lg overflow-hidden">
              {featuredImage && (
                <img 
                  src={featuredImage} 
                  alt="" 
                  className="w-full h-48 object-cover"
                />
              )}
              <div className="p-4">
                <h2 className="text-xl font-semibold mb-2">
                  <Link href={'/blog/' + post.slug}>
                    <span dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
                  </Link>
                </h2>
                <div 
                  className="text-gray-600 line-clamp-3"
                  dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}
                />
              </div>
            </article>
          )
        })}
      </div>
    </main>
  )
}

Strona pojedynczego posta

Code
// 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 px-4 py-8 max-w-3xl">
      {featuredImage && (
        <img 
          src={featuredImage} 
          alt="" 
          className="w-full h-64 object-cover rounded-lg mb-8"
        />
      )}
      
      <h1 
        className="text-4xl font-bold mb-4"
        dangerouslySetInnerHTML={{ __html: post.title.rendered }}
      />
      
      <time className="text-gray-500 block mb-8">
        {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ą. Tworzenie własnych:

Prosty endpoint

Code
// 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

Code
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

Dla operacji zapisu (POST, PUT, DELETE) potrzebujesz autentykacji.

Application Passwords (WordPress 5.6+)

Code
Użytkownicy → Twój profil → Application Passwords → Dodaj

Użycie:

Code
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ń server-to-server albo 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":

Code
// 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 storage tokena, odświeżanie i bezpieczeństwo po stronie klienta.

Dodawanie pól do odpowiedzi

Custom fields (ACF lub natywne)

Code
// 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:

Code
{
  "id": 123,
  "title": { "rendered": "Mój post" },
  "reading_time": 5,
  "custom_subtitle": "Podtytuł z ACF"
}

Preview postów

Dla podglądu drafts potrzebujesz autoryzacji:

Code
// 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()
}
Code
// 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

Code
// 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

Code
// ISR
const posts = await fetch(url, {
  next: { revalidate: 60 },
})
 
// Statyczne (build time)
const posts = await fetch(url, {
  cache: 'force-cache',
})
 
// Bez cache
const posts = await fetch(url, {
  cache: 'no-store',
})

3. Cache w WordPress

Code
// 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)

AspektREST APIWPGraphQL
SetupWbudowaneWymaga wtyczki
Elastyczność zapytańOgraniczonaPełna
OverfetchingTakNie
Learning curveNiższaWyższa
ToolingStandardoweApollo, urql
CacheStandardowy HTTPWymaga konfiguracji

Dla prostych projektów REST API wystarczy. Dla skomplikowanych aplikacji z wieloma relacjami — rozważ WPGraphQL.

FAQ

Jak włączyć WordPress REST API i sprawdzić, czy działa?

REST API jest domyślnie włączone od WordPressa 4.7. Warunkiem działania są włączone ładne permalinki (Ustawienia → Bezpośrednie odnośniki → dowolna opcja inna niż „Prosty"). Możesz przetestować API otwierając w przeglądarce twoja-domena.pl/wp-json/wp/v2/posts — jeśli widzisz JSON, API działa. Brak odpowiedzi najczęściej oznacza problem z permalinkami lub blokadę przez plugin bezpieczeństwa.

Jak pobrać posty z WordPress REST API w Next.js App Router?

W Server Component użyj fetch() z opcją next: { revalidate: 60 } dla ISR, czyli Incremental Static Regeneration, pozwala odświeżać strony statyczne po czasie bez pełnego rebuildu. (odświeżanie co 60 sekund) lub cache: 'force-cache' dla danych statycznych. Parametr _embed=true dołącza powiązane dane (featured image, kategorie) w jednym requeście. Dla stron generowanych statycznie zaimplementuj generateStaticParams, który zwróci listę slugów pobranych z API.

Jak skonfigurować CORS dla headless WordPress z frontendem na innej domenie?

Dodaj w functions.php lub wtyczce filtr rest_pre_serve_request, który ustawia nagłówki CORS tylko dla zaufanych domen z tablicy $allowed_origins. Nigdy nie używaj wildcardu * razem z Access-Control-Allow-Credentials: true. Dla środowiska lokalnego dodaj http://localhost:3000 do whitelist.

Jak autentykować się do WordPress REST API przy operacjach zapisu?

Dla połączeń server-to-server (Next.js backend → WordPress) użyj Application Passwords — tworzysz je w profilu użytkownika WordPressa i przekazujesz w nagłówku Authorization: Basic base64(user:password). W praktyce używaj ich wyłącznie po HTTPS, bo Basic Auth nie szyfruje poświadczeń sam z siebie. JWT (przez wtyczkę) jest alternatywą, ale wymaga zarządzania czasem życia tokena. Nigdy nie wysyłaj danych uwierzytelniających do publicznego klienta w przeglądarce.

Jak dodać własne pola do odpowiedzi WordPress REST API?

Użyj funkcji register_rest_field() wewnątrz hooka rest_api_init. Podajesz typ posta, nazwę pola i callback get_callback, który zwraca wartość. Możesz w ten sposób dodać pola z Advanced Custom Fields (ACF), obliczone wartości (jak czas czytania) lub metadane, które normalnie nie są widoczne w API.

Kiedy wybrać REST API, a kiedy WPGraphQL?

REST API jest wbudowane, nie wymaga dodatkowych wtyczek i sprawdza się świetnie do prostych projektów pobierających posty, strony i media. WPGraphQL (wtyczka) daje pełną elastyczność zapytań bez overfetchingu, co jest ważne przy złożonych relacjach danych (np. zagnieżdżone custom post types z ACF). Jeśli zaczynasz projekt headless, zacznij od REST API — przejdź na GraphQL, gdy poczujesz jego ograniczenia.

Jak zoptymalizować wydajność przy pobieraniu danych z WordPress REST API?

Używaj parametru _fields=id,title,slug,excerpt do pobierania tylko potrzebnych pól zamiast pełnych obiektów. Łącz _embed z _fields ostrożnie — możesz przypadkowo wyciąć _embedded z odpowiedzi. Po stronie WordPress cachuj kosztowne zapytania przez Transient API (set_transient()). Po stronie Next.js stosuj odpowiednie opcje revalidate — dłuższy czas dla rzadko zmienianych treści (3600 s dla kategorii), krótszy dla postów.

Podsumowanie

WordPress REST API to solidna podstawa do budowy headless CMS:

  • Wbudowane endpointy pokrywają 80% potrzeb
  • Custom endpoints dla specyficznej logiki
  • Autentykacja przez Application Passwords, cookie auth albo JWT zależnie od scenariusza
  • Next.js App Router z ISR to idealne combo
  • _embed i _fields optymalizują transfer danych

Headless WordPress + Next.js to architektura, która łączy prostotę zarządzania treścią z mocą nowoczesnego frontendu. Więcej o tym podejściu przeczytasz w Headless WordPress + Next.js — kiedy warto?

Źródła i dokumentacja


Chcesz zbudować coś więcej? Sprawdź jak skonfigurować sklep WooCommerce lub poznaj zasady projektowania REST API.

Pracuję z tym zawodowo.

Jeśli chcesz zbudować lub uporządkować WordPressa, WooCommerce albo architekturę headless tak, żeby była szybka, wygodna redakcyjnie i sensowna biznesowo, skontaktuj się ze mną. Pomagam łączyć development, SEO, performance i realne cele sprzedażowe.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
Anthropic uderza w Figmę i Adobe — oto Claude Design

Anthropic uderza w Figmę i Adobe — oto Claude Design

Anthropic wypuścił właśnie narzędzie AI do tworzenia stron, landing page'ów i prezentacji z promptu. Oto co wiemy o Claude Design i Opus 4.7 — i co to oznacza dla developerów.

Maciej Sala

Maciej Sala

Founder Strivelab

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Fachowe porównanie Astro.js i Next.js z perspektywy developera pracującego na co dzień w Next.js. Architektura, wydajność, SEO, DX, koszty i konkretne use case — z benchmarkami i przykładami kodu.

Maciej Sala

Maciej Sala

Founder Strivelab