Przejdź do treści

WPGraphQL i Next.js: poradnik Headless WordPress

Połącz WordPressa z Next.js App Router przez API. Poznaj bezpieczną konfigurację produkcyjną bez problemów z ISR, trybem podglądu i webhookami.

Maciej Sala

Founder StriveLab

8 min czytaniaOpublikowano 11 kwietnia 2026 (Aktualizacja 1 sierpnia 2026)

WPGraphQL i Next.js App Router: zakres implementacji headless WordPress

Jeśli decyzja ze wstępu jeszcze nie zapadła, zacznij od Headless WordPress, kiedy ma sens, a kiedy nie. Artykuł jest technicznym tutorialem wdrożeniowym, który mówi jak połączyć WordPress, i Next.js w działającą architekturę. Headless WordPress to WordPress używany wyłącznie jako , z Next.js jako frontendem.

WordPress udostępnia treści przez ( lub GraphQL), a Next.js pobiera je, renderuje i serwuje użytkownikowi. W efekcie znany edytor WordPress dla klienta łączy się z wydajnością i Next.js na frontendzie.

Kiedy headless WordPress z Next.js ma sens?

  • Istniejący WordPress z setkami artykułów. Klient nie chce migrować treści, ale chce szybszy frontend.

  • Zespół redakcyjny zna WordPress i nie chce uczyć się Sanity/Strapi.

  • Zaawansowane pola ACF (Advanced Custom Fields). Pola własne, elastyczna zawartość (flexible content) oraz grupy pól.

  • WooCommerce. Koszyk i checkout w wariancie headless z Next.js, ale zarządzanie produktami w WordPressie.

Kiedy zamiast WordPressa wybrać dedykowany ?

  • Nowy projekt od zera, w którym model treści, podgląd i praca wielojęzyczna są ważniejsze niż zgodność z ekosystemem WordPressa.

  • Nie potrzebujesz ekosystemu wtyczek WP. Mniej zależności oznacza mniej problemów.

  • Jeden deweloper. Utrzymanie WordPress i Next.js to dwa systemy do zarządzania.

Konfiguracja WPGraphQL w WordPressie i Next.js

Code
# W WordPressie zainstaluj wtyczki:
# 1. WPGraphQL (wp-graphql)
# 2. WPGraphQL for ACF (opcjonalnie, jeśli używasz ACF)

Po instalacji WPGraphQL endpoint jest dostępny pod adresem https://cms.example.com/graphql.

Klient GraphQL w Next.js App Router

Code
// lib/wordpress.ts
import 'server-only'
 
const WORDPRESS_API_URL = process.env.WORDPRESS_API_URL!
 
interface GraphQLResponse<T> {
  data?: T
  errors?: { message: string; path?: (string | number)[] }[]
}
 
export async function fetchGraphQL<T>(
  query: string,
  variables?: Record<string, unknown>,
  options: { tags?: string[]; revalidate?: number; authToken?: string } = {},
): Promise<T> {
  if (!WORDPRESS_API_URL) {
    throw new Error('Missing WORDPRESS_API_URL')
  }
 
  const cacheOptions = options.authToken
    ? ({ cache: 'no-store' } as const)
    : {
        next: {
          revalidate: options.revalidate ?? 3600,
          tags: options.tags,
        },
      }
 
  const res = await fetch(WORDPRESS_API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(options.authToken && {
        Authorization: `Bearer ${options.authToken}`,
      }),
    },
    body: JSON.stringify({ query, variables }),
    signal: AbortSignal.timeout(8_000),
    ...cacheOptions,
  })
 
  if (!res.ok) {
    throw new Error(`WordPress GraphQL HTTP ${res.status}`)
  }
 
  const json = (await res.json()) as GraphQLResponse<T>
 
  // GraphQL może zwrócić HTTP 200 razem z errors i częściowym data.
  if (json.errors?.length || json.data === undefined) {
    throw new Error(
      json.errors?.map((error) => error.message).join(', ') ||
        'WordPress GraphQL response has no data',
    )
  }
 
  return json.data
}

Uwierzytelnione odpowiedzi nie trafiają w tym przykładzie do współdzielonego cache Next.js. Prywatna treść wymaga osobnej ścieżki odczytu, ponieważ tagi i czas rewalidacji używane dla wpisów publicznych nie mogą objąć szkiców innego redaktora. Ważne, by dane publiczne pobierać anonimowo, a autoryzację stosować w trybie podglądu.

WPGraphQL nie dostarcza samodzielnego mechanizmu logowania dla zewnętrznego frontendu. Możesz dobrać rozszerzenie WPGraphQL Headless Login, WPGraphQL JWT Authentication albo Application Passwords, zależnie od przepływu i środowiska. Poświadczenia przesyłaj w nagłówku, przechowuj w module server-only, regularnie rotuj i przypisz do konta mającego wyłącznie uprawnienia potrzebne do podglądu.

Typ generyczny T nie sprawdza odpowiedzi w czasie działania aplikacji. W większym projekcie generuj typy na podstawie schematu i dokumentów GraphQL (GraphQL Code Generator) oraz waliduj krytyczne granice, szczególnie gdy aktualizacja wtyczki może zmienić schemat. W CI uruchamiaj kontrolę dokumentów względem schematu ze środowiska staging przed wdrożeniem WordPressa.

Zapytania GraphQL do treści WordPressa

Code
// lib/queries/posts.ts
import { fetchGraphQL } from '../wordpress'
import { cache } from 'react'
 
interface WPPost {
  id: string
  title: string
  slug: string
  excerpt: string
  date: string
  modified: string
  content: string
  featuredImage: {
    node: {
      sourceUrl: string
      altText: string
      mediaDetails: { width: number | null; height: number | null } | null
    }
  } | null
  categories: {
    nodes: { name: string; slug: string }[]
  }
}
 
export async function getPosts(first = 20): Promise<WPPost[]> {
  const data = await fetchGraphQL<{ posts: { nodes: WPPost[] } }>(
    `
    query GetPosts($first: Int!) {
      posts(first: $first, where: { status: PUBLISH }) {
        nodes {
          id
          title
          slug
          excerpt
          date
          modified
          featuredImage {
            node {
              sourceUrl
              altText
              mediaDetails {
                width
                height
              }
            }
          }
          categories {
            nodes {
              name
              slug
            }
          }
        }
      }
    }
  `,
    { first },
    { tags: ['wp-posts'] },
  )
 
  return data.posts.nodes
}
 
const getPostBySlugUncached = async (slug: string): Promise<WPPost | null> => {
  const data = await fetchGraphQL<{ post: WPPost | null }>(
    `
    query GetPost($slug: ID!) {
      post(id: $slug, idType: SLUG) {
        id
        title
        slug
        excerpt
        content
        date
        modified
        featuredImage {
          node {
            sourceUrl
            altText
            mediaDetails {
              width
              height
            }
          }
        }
        categories {
          nodes {
            name
            slug
          }
        }
      }
    }
  `,
    { slug },
    { tags: ['wp-posts', `wp-post-${slug}`] },
  )
 
  return data.post
}
 
// Deduplikacja tego samego odczytu między generateMetadata i stroną.
export const getPostBySlug = cache(getPostBySlugUncached)
 
export async function getAllPostSlugs(): Promise<string[]> {
  const slugs: string[] = []
  let after: string | null = null
 
  do {
    const data: {
      posts: {
        nodes: { slug: string }[]
        pageInfo: { hasNextPage: boolean; endCursor: string | null }
      }
    } = await fetchGraphQL(
      `
      query GetAllSlugs($after: String) {
        posts(first: 100, after: $after, where: { status: PUBLISH }) {
          nodes { slug }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `,
      { after },
      { tags: ['wp-posts'] },
    )
 
    slugs.push(...data.posts.nodes.map((post) => post.slug))
    after = data.posts.pageInfo.hasNextPage
      ? data.posts.pageInfo.endCursor
      : null
  } while (after)
 
  return slugs
}

WPGraphQL używa paginacji Relay opartej na kursorach. first: 1000 nie jest bezpiecznym skrótem: domyślny limit połączenia wynosi 100, a jego zwiększenie może mocno obciążyć PHP i bazę. Dla listy użytkowej pobieraj 10 do 100 elementów i wystaw „Następna strona” lub infinite scroll. Funkcja getAllPostSlugs przydaje się w migracji lub generatorze sitemap, lecz nie musi działać podczas każdego wdrożenia frontendu.

Pola seo nie należą do WPGraphQL core. Jeśli chcesz pobierać metadane Yoast albo Rank Math, potrzebujesz kompatybilnego rozszerzenia, a nazwy pól musisz potwierdzić w swoim schemacie. Nie deklaruj ich wyłącznie w interfejsie TypeScript, bo typ nie sprawi, że serwer GraphQL zacznie je zwracać.

Sanityzacja HTML z WordPressa

WPGraphQL zwraca content i excerpt jako wyrenderowany HTML. WordPress filtruje treść zależnie od uprawnień autora, ale administrator może mieć unfiltered_html, wtyczka może dołożyć niebezpieczny markup, a przejęty CMS staje się źródłem . Przy dangerouslySetInnerHTML ustaw własną granicę zaufania i listę dozwolonych elementów.

Code
yarn add sanitize-html
yarn add --dev @types/sanitize-html
Code
// lib/sanitize-wordpress-html.ts
import 'server-only'
import sanitizeHtml from 'sanitize-html'
 
export function sanitizeWordPressHtml(html: string) {
  return sanitizeHtml(html, {
    allowedTags: sanitizeHtml.defaults.allowedTags.concat([
      'img',
      'figure',
      'figcaption',
    ]),
    allowedAttributes: {
      ...sanitizeHtml.defaults.allowedAttributes,
      img: ['src', 'alt', 'width', 'height', 'loading'],
    },
    allowedSchemes: ['http', 'https', 'mailto'],
  })
}
 
export function wordpressText(html: string) {
  return sanitizeHtml(html, { allowedTags: [], allowedAttributes: {} }).trim()
}

Listę dozwolonych elementów dopasuj do bloków Gutenberga faktycznie używanych w projekcie. Osadzenia iframe, formularze, style inline i skrypty wymagają osobnej decyzji. Nie dopuszczaj ich globalnie z powodu jednego wpisu i dołóż restrykcyjne jako drugą warstwę ochrony.

Strony w Next.js renderowane z WPGraphQL

Code
// app/blog/page.tsx
import { getPosts } from '@/lib/queries/posts'
import Image from 'next/image'
import Link from 'next/link'
import { sanitizeWordPressHtml } from '@/lib/sanitize-wordpress-html'
 
export const revalidate = 3600
 
export default async function BlogPage() {
  const posts = await getPosts()
 
  return (
    <main className="mx-auto max-w-4xl py-12">
      <h1 className="mb-8 text-3xl font-bold">Blog</h1>
      <div className="space-y-8">
        {posts.map((post) => (
          <Link
            key={post.id}
            href={`/blog/${post.slug}`}
            className="group block"
          >
            <article className="flex gap-6">
              {post.featuredImage && (
                <Image
                  src={post.featuredImage.node.sourceUrl}
                  alt={post.featuredImage.node.altText || post.title}
                  width={300}
                  height={200}
                  sizes="(max-width: 768px) 100vw, 300px"
                  className="rounded-lg object-cover"
                />
              )}
              <div>
                <h2 className="text-xl font-semibold group-hover:text-blue-600">
                  {post.title}
                </h2>
                <div
                  className="mt-2 line-clamp-2 text-gray-600"
                  dangerouslySetInnerHTML={{
                    __html: sanitizeWordPressHtml(post.excerpt),
                  }}
                />
                <time className="mt-2 block text-sm text-gray-400">
                  {new Date(post.date).toLocaleDateString('pl-PL')}
                </time>
              </div>
            </article>
          </Link>
        ))}
      </div>
    </main>
  )
}
Code
// app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/queries/posts'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import {
  sanitizeWordPressHtml,
  wordpressText,
} from '@/lib/sanitize-wordpress-html'
 
export const revalidate = 3600
export const dynamicParams = true
 
export async function generateStaticParams() {
  // Wpisy powstaną jako strony ISR przy pierwszym żądaniu.
  return []
}
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await getPostBySlug(slug)
  if (!post) return {}
 
  const description = wordpressText(post.excerpt).slice(0, 160)
 
  return {
    title: post.title,
    description,
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      type: 'article',
      title: post.title,
      description,
      publishedTime: post.date,
      modifiedTime: post.modified,
      images: post.featuredImage
        ? [post.featuredImage.node.sourceUrl]
        : undefined,
    },
  }
}
 
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPostBySlug(slug)
  if (!post) notFound()
 
  return (
    <article className="prose prose-lg mx-auto max-w-3xl py-12">
      <h1>{post.title}</h1>
      <time className="text-gray-400">
        {new Date(post.date).toLocaleDateString('pl-PL')}
      </time>
      <div
        dangerouslySetInnerHTML={{
          __html: sanitizeWordPressHtml(post.content),
        }}
      />
    </article>
  )
}

Relatywny adres kanoniczny wymaga poprawnego metadataBase w głównym layoucie. W sytuacji, w której korzystasz z rozszerzenia SEO WordPressa, ustal jedno źródło prawdy dla tytułu, adresu kanonicznego, noindex i Open Graph. Nie mieszaj części pól z wtyczki z innymi, generowanymi niezależnie w Next.js. Pamiętaj też, by dla danych strukturalnych Article serializować bezpiecznie JSON (co najmniej zamień < na \u003c) i nie kopiuj w ciemno JSON-LD wygenerowanego dla starego frontendu WordPressa.

Powyższy wariant używa modelu cache bez cacheComponents, zgodnego z next.revalidate na fetch i segmentowym export const revalidate. Jeśli w Next.js 16 włączysz cacheComponents: true, konfiguracje segmentu takie jak revalidate są zastępowane przez jawne zakresy 'use cache', cacheLife i cacheTag. Wybór powinien być zapisany w next.config.ts i testowany po buildzie produkcyjnym.

Pusta tablica z dynamicParams = true pozwala w tym modelu budować strony ISR przy pierwszej wizycie i nie uzależnia wdrożenia od pobrania wszystkich slugów. Jeśli chcesz wygenerować część popularnych wpisów podczas builda, sprawdź zachowanie wybranej konfiguracji w dokumentacji swojej wersji Next.js. Po włączeniu cacheComponents obowiązują inne reguły dla dynamicznych parametrów i co najmniej jeden przykładowy parametr może być wymagany podczas budowania.

Ustawienie dynamicParams = false zwróci 404 dla adresu, którego nie było w wyniku generateStaticParams. Nowa publikacja nie pojawi się wtedy dzięki samemu webhookowi, bo funkcja parametrów nie uruchamia się ponownie podczas ISR.

Bezpieczny podgląd szkiców przez Next.js Draft Mode

Endpoint podglądu powinien przyjąć identyfikator wpisu, czas wygaśnięcia i podpis HMAC utworzony po stronie WordPressa. Dzięki temu wspólny sekret nie trafia do adresu, historii przeglądarki ani logów proxy. Po weryfikacji endpoint pobiera szkic samodzielnie i przekierowuje wyłącznie na slug zwrócony przez CMS.

Code
// app/api/draft/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { z } from 'zod'
 
const previewQuerySchema = z.object({
  id: z.coerce.number().int().positive(),
  expires: z.coerce.number().int().positive(),
  signature: z.string().regex(/^[a-f0-9]{64}$/i),
})
 
function hasValidPreviewSignature(
  id: number,
  expires: number,
  signature: string,
) {
  const secret = process.env.DRAFT_SECRET
  const now = Math.floor(Date.now() / 1000)
  if (!secret || expires < now || expires > now + 300) return false
 
  const expected = createHmac('sha256', secret)
    .update(`${id}.${expires}`)
    .digest()
  const received = Buffer.from(signature, 'hex')
 
  return (
    received.length === expected.length && timingSafeEqual(received, expected)
  )
}
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const parsed = previewQuerySchema.safeParse({
    id: searchParams.get('id'),
    expires: searchParams.get('expires'),
    signature: searchParams.get('signature'),
  })
 
  if (
    !parsed.success ||
    !hasValidPreviewSignature(
      parsed.data.id,
      parsed.data.expires,
      parsed.data.signature,
    )
  ) {
    return new Response('Invalid preview request', { status: 401 })
  }
 
  // Funkcja wysyła poświadczenia w nagłówku i zawsze używa cache: 'no-store'.
  const post = await getAuthenticatedPreviewPost(parsed.data.id)
  if (!post?.slug) return new Response('Draft not found', { status: 404 })
 
  const draft = await draftMode()
  draft.enable()
  redirect(`/blog/${encodeURIComponent(post.slug)}`)
}

Kod tworzący link po stronie WordPressa musi policzyć HMAC z dokładnie tego samego ciągu ${id}.${expires} i nadać maksymalnie pięć minut ważności. Sam podpis chroni wejście do Draft Mode, natomiast WordPress nadal sprawdza tożsamość konta technicznego oraz jego capabilities przy odczycie szkicu.

Na stronie wpisu sprawdzaj status przez await draftMode(). W sytuacji, kiedy podgląd jest aktywny, wywołaj getAuthenticatedPreviewPost bez cache. Dla zwykłego ruchu korzystaj z getPostBySlug. Pamiętaj, że token WordPressa nie może znaleźć się w zmiennej z prefiksem NEXT_PUBLIC_, komponencie klienckim ani adresie podglądu. Trzeba dodać także endpoint wyłączający Draft Mode i ustaw prefetch={false} dla prowadzącego do niego linku, aby prefetch nie usunął ciasteczka bez działania użytkownika.

Szkice mogą zawierać poufne informacje. Konto techniczne powinno mieć minimalne uprawnienia, a aplikacja powinna rejestrować nieudane próby bez zapisywania podpisu i danych wpisu. W systemie o wyższym ryzyku wymagaj również zalogowania redaktora we frontendzie. Podpis linku nie identyfikuje konkretnej osoby.

ISR na żądanie z webhookami WordPressa w Next.js

Code
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { createHmac, timingSafeEqual } from 'node:crypto'
import { z } from 'zod'
 
const webhookSchema = z.object({
  event_id: z.string().min(1).max(100),
  post_type: z.literal('post'),
  slug: z.string().min(1).max(200).optional(),
  previous_slug: z.string().min(1).max(200).optional(),
  category_slugs: z.array(z.string().min(1).max(200)).max(50).default([]),
})
 
function validSignature(rawBody: string, timestamp: string, signature: string) {
  const secret = process.env.REVALIDATION_SECRET
  if (
    !secret ||
    !/^\d+$/.test(timestamp) ||
    !/^sha256=[a-f0-9]{64}$/i.test(signature)
  ) {
    return false
  }
 
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(age) || age > 300) return false
 
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest()
  const received = Buffer.from(signature.slice(7), 'hex')
 
  if (received.length !== expected.length) return false
  return timingSafeEqual(received, expected)
}
 
export async function POST(req: Request) {
  const rawBody = await req.text()
  if (Buffer.byteLength(rawBody, 'utf8') > 64_000) {
    return Response.json({ error: 'Payload too large' }, { status: 413 })
  }
 
  const timestamp = req.headers.get('x-wp-timestamp') ?? ''
  const signature = req.headers.get('x-wp-signature') ?? ''
 
  if (!validSignature(rawBody, timestamp, signature)) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 })
  }
 
  let input: unknown
  try {
    input = JSON.parse(rawBody)
  } catch {
    return Response.json({ error: 'Invalid JSON' }, { status: 400 })
  }
 
  const parsed = webhookSchema.safeParse(input)
  if (!parsed.success) {
    return Response.json({ error: 'Invalid payload' }, { status: 422 })
  }
 
  const { event_id, slug, previous_slug, category_slugs } = parsed.data
  if (!(await claimWebhookEvent(event_id))) {
    return Response.json({ revalidated: false, duplicate: true })
  }
 
  try {
    revalidateTag('wp-posts', 'max')
    revalidatePath('/blog')
 
    const affectedSlugs = [slug, previous_slug].filter(
      (value): value is string => Boolean(value),
    )
    for (const affectedSlug of new Set(affectedSlugs)) {
      revalidateTag(`wp-post-${affectedSlug}`, 'max')
      revalidatePath(`/blog/${affectedSlug}`)
    }
 
    for (const category of category_slugs) {
      revalidateTag(`wp-category-${category}`, 'max')
      revalidatePath(`/blog/kategoria/${category}`)
    }
 
    await markWebhookCompleted(event_id)
    return Response.json({ revalidated: true })
  } catch {
    await markWebhookFailed(event_id)
    return Response.json({ error: 'Revalidation failed' }, { status: 500 })
  }
}

W WordPressie konfiguruj wysyłkę webhooków tylko dla zmian statusu lub treści, które wpływają na frontend. Odfiltruj autosave, rewizje oraz nieużywane typy wpisów. Podpis HMAC obliczaj na identycznym, surowym ciele żądania i znaczniku czasu. Dla event_id stosuj unikalny indeks. Mechanizm idempotencji musi pozwolić ponowić zdarzenie oznaczone jako nieudane, lecz nie może równolegle uruchomić dwóch prób z tym samym identyfikatorem.

revalidateTag(..., 'max') stosuje stale-while-revalidate, więc pierwszy odczyt może jeszcze dostać starą wartość. revalidatePath unieważnia wynik konkretnej trasy i pomaga przy publikacji lub wycofaniu wpisu. W sytuacji wymagania natychmiastowej widoczności zmiany, przetestuj strategię wygaszania dla używanego modelu cache.

Zmiana sluga wymaga przekierowania 301

Unieważnienie starej ścieżki nie tworzy przekierowania. Po zmianie sluga /blog/stary-adres pobierze null z WPGraphQL i skończy jako 404, co traci sygnały zgromadzone przez poprzedni URL. Webhook powinien zapisać parę previous_slug -> slug w trwałym rejestrze przekierowań. Strona dynamiczna lub warstwa routingu sprawdza ten rejestr po braku wpisu i wykonuje permanentRedirect do aktualnego adresu.

Łańcuch przekierowań wymaga spłaszczenia do celu. Gdy slug zmieni się drugi raz, wszystkie historyczne warianty powinny prowadzić bezpośrednio do najnowszej wersji. Zablokuj pętle, przechowuj datę zmiany i uwzględnij mapę w testach po migracji treści.

Konfiguracja next/image dla mediów z WordPressa

Code
// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cms.example.com',
        port: '',
        pathname: '/wp-content/uploads/**',
        search: '',
      },
      // Jeśli używasz CDN, dodaj jego konkretny hostname i pathname osobno.
    ],
  },
}

Ogranicz remotePatterns do rzeczywistej ścieżki uploadów i konkretnych hostów CDN. Zbyt szeroki wzorzec pozwala używać optymalizatora obrazów jako proxy dla niepotrzebnych zasobów. Gdy WordPress zwraca adresy po migracji domeny albo CDN przepisuje hostname, znormalizuj URL w jednej funkcji i przetestuj oba środowiska. W przypadku plików SVG zachowaj szczególną ostrożność i nie włączaj dangerouslyAllowSVG bez odpowiedniej polityki CSP oraz Content-Disposition.

Zawsze pobieraj mediaDetails.width i height albo utrzymuj znane proporcje wariantu obrazu. Atrybut sizes jest niezbędny przy responsywnym layoucie, aby przeglądarka nie pobierała zbyt dużych plików, pamiętaj jednak, że next/image nie naprawi pliku o wadze 15 MB ani braku właściwych wariantów w bibliotece mediów.

Bezpieczeństwo i utrzymanie WPGraphQL na produkcji

Headless nie usuwa obowiązków WordPressa. Endpoint /graphql nadal uruchamia PHP i zapytania do bazy, a panel administracyjny pozostaje powierzchnią ataku. Aktualizuj WordPress, WPGraphQL i rozszerzenia, usuwaj nieużywane wtyczki, rób testowane backupy oraz monitoruj błędy i czas odpowiedzi. Oddziel staging od produkcji i sprawdzaj kompatybilność schematu przed aktualizacją.

Stosuj rozsądne limity paginacji, głębokości lub złożoności zapytań, limitowanie ruchu na odwrotnym proxy oraz trwały object cache, jeśli obciążenie tego wymaga. WPGraphQL Smart Cache może pomóc, ale nie zastępuje pomiaru. Zapytania GraphQL wysyłane metodą POST nie korzystają automatycznie z typowego cache CDN tak jak stabilne GET; jeśli publiczny ruch trafia bezpośrednio do WordPressa, rozważ persisted/safelisted queries albo warstwę cache świadomą GraphQL.

Limit ciała webhooka w kodzie odrzuca zbyt duży payload po jego odczytaniu, dlatego trzeba ustawić dodatkowy limit na reverse proxy lub platformie, aby zbyt duże żądanie nie zajęło pamięci procesu Next.js. Trzeba dodać też timeout dla połączeń z WordPressem, metryki błędów GraphQL i alert na wzrost czasu odpowiedzi. Pamiętaj, że cache nie naprawi przeciążonego źródła danych.

Nie wystawiaj w schemacie pól na zapas, bo WPGraphQL respektuje wiele uprawnień WordPressa, ale każde rozszerzenie może dodawać własne typy i resolvery. Przetestuj schemat jako użytkownik anonimowy, redaktor i konto podglądu, a introspekcję oraz IDE produkcyjne skonfiguruj zgodnie z przyjętym modelem zagrożeń.

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

WPGraphQL czy REST API, co wybrać do headless WordPress?

WPGraphQL daje typowany schemat, relacje i precyzyjny wybór pól, co pomaga przy złożonych modelach treści. REST jest prostszy operacyjnie, działa bez dodatkowej wtyczki i łatwiej korzysta z cache HTTP/CDN. GraphQL nie gwarantuje mniejszej ani szybszej odpowiedzi, bo nieprawidłowo ograniczone zapytanie może być bardziej kosztowne niż REST. Wybór zależy od modelu i infrastruktury.

Czy muszę utrzymywać dwa serwery?

Utrzymujesz dwa środowiska uruchomieniowe: WordPress z bazą oraz frontend Next.js, choć mogą działać u jednego dostawcy. WordPress nadal obsługuje GraphQL, redakcję, media i webhooki, więc jego wydajność, backupy, aktualizacje i bezpieczeństwo bezpośrednio wpływają na proces budowania aplikacji oraz świeżość frontendu. Tani hosting współdzielony nie zawsze wystarczy.

Jak obsłużyć podgląd szkicu (preview) z WordPressa?

Draft Mode w Next.js tylko omija statyczny cache, nie daje dostępu do prywatnych danych WordPressa. Potrzebujesz podpisanego URL-a podglądu, uwierzytelnionego zapytania WPGraphQL z kontem o minimalnych uprawnieniach oraz obsługi rewizji lub szkicu. Integrację można napisać samodzielnie albo użyć utrzymywanego rozwiązania zgodnego z wybranym mechanizmem uwierzytelniania.

Dlaczego next/image nie ładuje zdjęć z WordPressa?

Domyślnie next/image optymalizuje tylko obrazy z dozwolonych hostów, więc dodaj host swojego WordPressa do images.remotePatterns w next.config. W innym wypadku Next.js odrzuci zewnętrzne URL-e z biblioteki mediów.

Jak odświeżać front po publikacji w WordPressie?

Przez podpisany webhook reagujący na zmianę statusu wpisu. Endpoint w Next.js unieważnia tag kolekcji, tag wpisu oraz ścieżki nowego i starego sluga. Odfiltruj autosave i rewizje, waliduj ciało żądania, chroń się przed ponowieniem starego żądania i ponawiaj nieudane dostarczenia.

O autorze

Maciej Sala

Maciej Sala — konsultant technologiczny produktów cyfrowych i web developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Headless WordPress z Next.js: kiedy ma sens, a kiedy nie

Headless WordPress wygląda na prosty manewr: odcinasz motyw PHP, podpinasz Next.js i zachowujesz panel znany redakcji. Prawdziwa operacja zaczyna się później, gdy szkic ma otworzyć się na właściwym adresie, publikacja musi odświeżyć cache, blok Gutenberga potrzebuje odpowiednika w React, a stary URL nie może stracić pozycji. Ten przewodnik pomaga policzyć całą architekturę, zanim pierwsze szybkie demo zamieni się w kosztowne utrzymanie dwóch systemów.

Maciej Sala

Maciej Sala

Founder StriveLab

Next.js czy WordPress w 2026 roku? Co wybrać

Z jednej strony mamy WordPressa, weterana, który wciąż napędza ponad 40% globalnego internetu. Z drugiej Next.js, flagowy framework wśród nowoczesnych technologii opartych na Reakcie. Cel obu narzędzi jest ten sam, by wypuścić stronę w świat. Różnią się jednak diametralnie drogą do tego celu, a wybór między nimi to kwestia tego, co konkretny projekt ma osiągnąć i kto będzie go utrzymywał.

Maciej Sala

Maciej Sala

Founder StriveLab

REST API WordPressa — integracja z React i Next.js

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.

Maciej Sala

Maciej Sala

Founder StriveLab