WPGraphQL + Next.js App Router — implementacja headless WordPress krok po kroku

Jak technicznie wdrożyć headless WordPress z WPGraphQL i Next.js App Router? Klient GraphQL, zapytania, ISR, webhooks, next/image i preview bez mieszania z decyzją biznesową.

Opublikowano

11 kwietnia 2026 10:10

Czytanie

3 min czytania

Aktualizacja

15 kwietnia 2026 11:52

Zakres: implementacja WPGraphQL + Next.js App Router

Ten wpis jest technicznym tutorialem wdrożeniowym: jak połączyć WordPress, WPGraphQL i Next.js App Router w działającą architekturę. Jeśli najpierw chcesz ocenić, czy headless WordPress ma sens dla konkretnego projektu, zacznij od wpisu Headless WordPress + Next.js — kiedy ma sens, a kiedy nie.

Headless WordPress to WordPress używany wyłącznie jako CMS (backend do zarządzania treścią) z Next.js jako frontendem. WordPress udostępnia treści przez API (REST lub GraphQL), a Next.js pobiera je, renderuje i serwuje użytkownikowi.

Efekt: znany edytor WordPress dla klienta + wydajność i SEO Next.js na froncie.

Kiedy headless WordPress ma sens

  • Istniejący WordPress z setkami artykułów — klient nie chce migrować treści, ale chce szybszy frontend
  • Zespół redakcyjny zna WordPress — nie chcą uczyć się Sanity/Strapi
  • Rozbudowane ACF (Advanced Custom Fields) — custom pola, flexible content, grupy pól
  • WooCommerce — headless checkout z Next.js, ale zarządzanie produktami w WordPress

Kiedy lepiej wybrać dedykowany headless CMS

  • Nowy projekt od zera — Sanity/Strapi mają lepszy DX, nie ciągniesz ze sobą PHP
  • Nie potrzebujesz ekosystemu pluginów WP — mniej zależności = mniej problemów
  • Jeden deweloper — utrzymanie WordPress + Next.js to dwa systemy do zarządzania

Setup — WPGraphQL

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

Po instalacji WPGraphQL: endpoint GraphQL dostępny na https://twoj-wordpress.pl/graphql.

Klient GraphQL w Next.js

Code
// lib/wordpress.ts
const WORDPRESS_API_URL = process.env.WORDPRESS_API_URL!;
 
interface GraphQLResponse<T> {
  data: T;
  errors?: { message: string }[];
}
 
export async function fetchGraphQL<T>(
  query: string,
  variables?: Record<string, unknown>
): Promise<T> {
  const res = await fetch(WORDPRESS_API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(process.env.WORDPRESS_AUTH_TOKEN && {
        Authorization: `Bearer ${process.env.WORDPRESS_AUTH_TOKEN}`,
      }),
    },
    body: JSON.stringify({ query, variables }),
    next: { revalidate: 3600 }, // ISR — odśwież co godzinę
  });
 
  const json: GraphQLResponse<T> = await res.json();
 
  if (json.errors) {
    throw new Error(json.errors.map((e) => e.message).join(', '));
  }
 
  return json.data;
}

Zapytania GraphQL

Code
// lib/queries/posts.ts
import { fetchGraphQL } from '../wordpress';
 
interface WPPost {
  id: string;
  title: string;
  slug: string;
  excerpt: string;
  date: string;
  content: string;
  featuredImage: {
    node: {
      sourceUrl: string;
      altText: string;
    };
  } | null;
  categories: {
    nodes: { name: string; slug: string }[];
  };
  seo: {
    title: string;
    metaDesc: string;
    opengraphImage: { sourceUrl: string } | null;
  };
}
 
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
          featuredImage {
            node {
              sourceUrl
              altText
            }
          }
          categories {
            nodes {
              name
              slug
            }
          }
        }
      }
    }
  `, { first });
 
  return data.posts.nodes;
}
 
export async function getPostBySlug(slug: string): Promise<WPPost | null> {
  const data = await fetchGraphQL<{ post: WPPost | null }>(`
    query GetPost($slug: ID!) {
      post(id: $slug, idType: SLUG) {
        id
        title
        slug
        content
        date
        featuredImage {
          node {
            sourceUrl
            altText
          }
        }
        categories {
          nodes {
            name
            slug
          }
        }
      }
    }
  `, { slug });
 
  return data.post;
}
 
export async function getAllPostSlugs(): Promise<string[]> {
  const data = await fetchGraphQL<{ posts: { nodes: { slug: string }[] } }>(`
    query GetAllSlugs {
      posts(first: 1000, where: { status: PUBLISH }) {
        nodes {
          slug
        }
      }
    }
  `);
 
  return data.posts.nodes.map((p) => p.slug);
}

Strony w Next.js

Code
// app/blog/page.tsx
import { getPosts } from '@/lib/queries/posts';
import Image from 'next/image';
import Link from 'next/link';
 
export const revalidate = 3600;
 
export default async function BlogPage() {
  const posts = await getPosts();
 
  return (
    <main className="max-w-4xl mx-auto py-12">
      <h1 className="text-3xl font-bold mb-8">Blog</h1>
      <div className="space-y-8">
        {posts.map((post) => (
          <Link key={post.id} href={`/blog/${post.slug}`} className="block group">
            <article className="flex gap-6">
              {post.featuredImage && (
                <Image
                  src={post.featuredImage.node.sourceUrl}
                  alt={post.featuredImage.node.altText || post.title}
                  width={300}
                  height={200}
                  className="rounded-lg object-cover"
                />
              )}
              <div>
                <h2 className="text-xl font-semibold group-hover:text-blue-600">
                  {post.title}
                </h2>
                <div
                  className="text-gray-600 mt-2 line-clamp-2"
                  dangerouslySetInnerHTML={{ __html: post.excerpt }}
                />
                <time className="text-sm text-gray-400 mt-2 block">
                  {new Date(post.date).toLocaleDateString('pl-PL')}
                </time>
              </div>
            </article>
          </Link>
        ))}
      </div>
    </main>
  );
}
Code
// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPostSlugs } from '@/lib/queries/posts';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
 
export const revalidate = 3600;
 
export async function generateStaticParams() {
  const slugs = await getAllPostSlugs();
  return slugs.map((slug) => ({ slug }));
}
 
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  if (!post) return {};
 
  return {
    title: post.title,
    description: post.excerpt?.replace(/<[^>]*>/g, '').slice(0, 160),
  };
}
 
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="max-w-3xl mx-auto py-12 prose prose-lg">
      <h1>{post.title}</h1>
      <time className="text-gray-400">{new Date(post.date).toLocaleDateString('pl-PL')}</time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

On-demand ISR z WordPress webhooks

Code
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
 
export async function POST(req: Request) {
  const secret = req.headers.get('x-webhook-secret');
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
  }
 
  const body = await req.json();
 
  if (body.post_type === 'post') {
    revalidatePath('/blog');
    if (body.post_name) revalidatePath(`/blog/${body.post_name}`);
  }
 
  return NextResponse.json({ revalidated: true });
}

W WordPress — plugin WP Webhooks lub custom save_post hook wysyłający POST do /api/revalidate przy każdej publikacji.

Konfiguracja next/image dla WordPress

Code
// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'twoj-wordpress.pl',
      },
      {
        protocol: 'https',
        hostname: '**.wp.com', // Jeśli używasz WordPress.com CDN
      },
    ],
  },
};

Podsumowanie

Headless WordPress z WPGraphQL + Next.js to sprawdzony stack, gdy klient zna WordPress i ma istniejące treści. WPGraphQL eksponuje dane, Next.js pobiera je z ISR i renderuje z pełnym SEO. Kluczowe: webhook do on-demand revalidation, generateStaticParams dla pre-renderingu i next/image z remote patterns dla zdjęć z WordPress.

Najczęściej zadawane pytania

WPGraphQL vs REST API — co wybrać?

WPGraphQL: pobierasz dokładnie te pola, których potrzebujesz (mniejsze payloady). REST API: prostsze, nie wymaga pluginu, ale zwraca całe obiekty. Dla headless Next.js — WPGraphQL jest lepszy.

Czy muszę utrzymywać dwa serwery?

Tak — WordPress na osobnym hostingu (VPS, shared), Next.js na Vercel/inny. WordPress nie musi być szybki (nie serwuje frontendu) — tani shared hosting wystarczy.

Jak obsłużyć preview (podgląd draftu)?

WPGraphQL Preview plugin + Next.js Draft Mode. Draft mode pozwala renderować niepublikowane treści po kliknięciu „Podgląd" w WordPress.

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