За последние два года перевел 8 проектов на headless архитектуру. Первый был кошмаром — недели отладки preview, проблемы с кешированием, конфликты CORS. Последний запустили за неделю с нулевым временем простоя и улучшением TTFB на 73%. Headless WordPress в 2025 — это не эксперимент, а production-ready решение, которое TechCrunch использует с 2018 года. Разберу архитектуру реального проекта без маркетинговой шелухи.
Почему headless: цифры из практики
Производительность: реальные измерения
На проекте новостного портала (15,000+ статей) мигрировал с традиционного WordPress на headless с Next.js:
До миграции (Traditional WP):
- TTFB: 850ms
- FCP: 2.1s
- LCP: 3.8s
- Database queries: 47/page
- Server load: 65% CPU average
После миграции (Headless):
- TTFB: 95ms (↓89%)
- FCP: 0.4s (↓81%)
- LCP: 0.9s (↓76%)
- Database queries: 0 на frontend
- Server load: 12% CPU average
Результат: Трафик вырос на 28% за первый месяц из-за улучшения SEO метрик.
Когда headless оправдан
Headless подходит когда:
- Нужна скорость > 95 PageSpeed Score
- Контент публикуется на нескольких платформах (web, mobile app, IoT)
- Требуется современный DX (React, TypeScript, Tailwind)
- Планируется высокая нагрузка (100k+ посетителей/день)
- Есть команда frontend разработчиков
НЕ используйте headless если:
- Бюджет < $10,000
- Команда только PHP разработчики
- Нужны сложные WordPress плагины (WooCommerce, BuddyPress)
- Клиент должен сам редактировать дизайн через Customizer
- Проект < 100 страниц без динамики
Архитектура: разделение фронтенда и бэкенда
Компоненты системы
┌─────────────────────────────────────────────────┐
│ Content Creators │
│ (WordPress Admin Panel) │
└────────────────┬────────────────────────────────┘
│
┌────────────────▼────────────────────────────────┐
│ WordPress Backend (Headless) │
│ • WPGraphQL / REST API │
│ • ACF (Custom Fields) │
│ • Authentication (JWT/OAuth) │
│ • Media Management │
└────────────────┬────────────────────────────────┘
│ API Layer (GraphQL/REST)
┌────────────────▼────────────────────────────────┐
│ Next.js Frontend │
│ • React Components │
│ • Server-Side Rendering (SSR) │
│ • Incremental Static Regeneration (ISR) │
│ • API Routes │
└────────────────┬────────────────────────────────┘
│
┌────────────────▼────────────────────────────────┐
│ CDN / Edge Network │
│ • Cloudflare / Vercel Edge │
│ • Static Asset Caching │
│ • Edge Functions │
└─────────────────────────────────────────────────┘
Изоляция backend от frontend
WordPress backend оптимизация:
php<code><em>// wp-config.php - Headless режим</em> define('HEADLESS_MODE_CLIENT_URL', 'https://frontend.example.com'); <em>// Отключение фронтенда WordPress</em> add_filter('template_include', function($template) { if (!is_admin()) { wp_die('This is a headless WordPress installation. Please visit ' . HEADLESS_MODE_CLIENT_URL); } return $template; }); <em>// Безопасность: CORS для API</em> add_action('rest_api_init', function() { remove_filter('rest_pre_serve_request', 'rest_send_cors_headers'); add_filter('rest_pre_serve_request', function($value) { $allowed_origins = array( 'https://frontend.example.com', 'https://preview.example.com' ); $origin = get_http_origin(); 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, X-WP-Nonce'); } return $value; }); }); <em>// Отключение ненужных REST API endpoints</em> add_filter('rest_endpoints', function($endpoints) { <em>// Удаляем endpoints, которые не нужны headless frontend</em> $to_remove = array( '/wp/v2/users', '/wp/v2/comments' ); foreach ($to_remove as $endpoint) { if (isset($endpoints[$endpoint])) { unset($endpoints[$endpoint]); } } return $endpoints; }); </code>REST API vs GraphQL: выбор подхода
WP REST API: простота и надежность
Преимущества:
- Встроен в WordPress ядро
- Простая интеграция
- Стандартные HTTP методы
- Широкая документация
Недостатки:
- Overfetching (получаем больше данных, чем нужно)
- Множественные запросы для связанных данных
- Фиксированная структура ответов
Пример использования:
javascript<code><em>// lib/wordpress.js - REST API fetcher</em> const WORDPRESS_API_URL = process.env.WORDPRESS_API_URL; export async function getAllPosts() { const res = await fetch(`${WORDPRESS_API_URL}/wp-json/wp/v2/posts?_embed&per_page=100`, { next: { revalidate: 60 } <em>// ISR revalidation</em> }); if (!res.ok) { throw new Error('Failed to fetch posts'); } const posts = await res.json(); <em>// Трансформация данных</em> return posts.map(post => ({ id: post.id, slug: post.slug, title: post.title.rendered, excerpt: post.excerpt.rendered, content: post.content.rendered, date: post.date, author: post._embedded?.author?.[0]?.name, featuredImage: post._embedded?.['wp:featuredmedia']?.[0]?.source_url, categories: post._embedded?.['wp:term']?.[0]?.map(cat => cat.name) || [] })); } export async function getPostBySlug(slug) { const res = await fetch( `${WORDPRESS_API_URL}/wp-json/wp/v2/posts?slug=${slug}&_embed`, { next: { revalidate: 60 } } ); const posts = await res.json(); return posts.length > 0 ? transformPost(posts[0]) : null; } </code>WPGraphQL: эффективность и гибкость
Преимущества:
- Запрашиваем только нужные данные
- Один запрос для всех связанных данных
- Strongly-typed schema
- Автоматическая документация через GraphiQL
Недостатки:
- Требует установки плагина
- Более сложная настройка
- Дополнительная нагрузка на сервер
Настройка WPGraphQL:
php<code><em>// functions.php - Расширение WPGraphQL</em> add_action('graphql_register_types', function() { <em>// Добавление кастомного поля reading time</em> register_graphql_field('Post', 'readingTime', [ 'type' => 'Int', 'description' => 'Estimated reading time in minutes', 'resolve' => function($post) { $content = get_post_field('post_content', $post->ID); $word_count = str_word_count(strip_tags($content)); return ceil($word_count / 200); <em>// 200 слов в минуту</em> } ]); <em>// Кастомный query для популярных постов</em> register_graphql_field('RootQuery', 'popularPosts', [ 'type' => ['list_of' => 'Post'], 'description' => 'Get popular posts based on views', 'args' => [ 'count' => [ 'type' => 'Int', 'defaultValue' => 5 ] ], 'resolve' => function($source, $args) { $query = new WP_Query([ 'post_type' => 'post', 'posts_per_page' => $args['count'], 'meta_key' => 'post_views_count', 'orderby' => 'meta_value_num', 'order' => 'DESC' ]); return $query->posts; } ]); }); </code>Использование в Next.js:
typescript<code><em>// lib/graphql.ts</em> const GRAPHQL_ENDPOINT = process.env.WORDPRESS_GRAPHQL_URL; interface GraphQLResponse<T> { data: T; errors?: any[]; } export async function fetchGraphQL<T>( query: string, variables: Record<string, any> = {} ): Promise<T> { const res = await fetch(GRAPHQL_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables }), next: { revalidate: 60 } }); const json: GraphQLResponse<T> = await res.json(); if (json.errors) { console.error('GraphQL errors:', json.errors); throw new Error('Failed to fetch API'); } return json.data; } <em>// Queries</em> export const GET_ALL_POSTS = ` query GetAllPosts($first: Int = 100) { posts(first: $first, where: {orderby: {field: DATE, order: DESC}}) { nodes { id slug title excerpt date readingTime author { node { name avatar { url } } } featuredImage { node { sourceUrl(size: LARGE) altText } } categories { nodes { name slug } } } } } `; export const GET_POST_BY_SLUG = ` query GetPostBySlug($slug: String!) { postBy(slug: $slug) { id title content date readingTime author { node { name description avatar { url } } } featuredImage { node { sourceUrl(size: LARGE) mediaDetails { width height } } } seo { title metaDesc opengraphImage { sourceUrl } } } } `; </code>Структура реального проекта
Организация кода Next.js
text<code>nextjs-frontend/ ├── app/ │ ├── layout.tsx # Root layout │ ├── page.tsx # Homepage │ ├── [slug]/ │ │ └── page.tsx # Dynamic post pages │ ├── category/ │ │ └── [slug]/ │ │ └── page.tsx # Category archives │ ├── api/ │ │ ├── revalidate/ │ │ │ └── route.ts # ISR revalidation endpoint │ │ └── preview/ │ │ └── route.ts # Preview mode handler │ └── error.tsx # Error boundary ├── components/ │ ├── PostCard.tsx │ ├── Header.tsx │ ├── Footer.tsx │ └── shared/ │ ├── Button.tsx │ └── Image.tsx ├── lib/ │ ├── wordpress.ts # WordPress API client │ ├── graphql.ts # GraphQL queries │ └── cache.ts # Caching utilities ├── types/ │ └── wordpress.d.ts # TypeScript types ├── public/ ├── .env.local ├── next.config.js └── package.json </code>Next.js конфигурация
javascript<code><em>// next.config.js</em> <em>/** @type {import('next').NextConfig} */</em> const nextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'backend.example.com', pathname: '/wp-content/uploads/**', }, ], formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], }, async headers() { return [ { source: '/:path*', headers: [ { key: 'X-DNS-Prefetch-Control', value: 'on' }, { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, { key: 'X-Content-Type-Options', value: 'nosniff' } ], }, ]; }, async rewrites() { return [ { source: '/wp-content/:path*', destination: `${process.env.WORDPRESS_API_URL}/wp-content/:path*`, }, ]; }, <em>// Webpack optimization</em> webpack: (config, { isServer }) => { if (!isServer) { config.resolve.fallback = { ...config.resolve.fallback, fs: false, }; } return config; }, }; module.exports = nextConfig; </code>Компонент динамической страницы поста
typescript<code><em>// app/[slug]/page.tsx</em> import { fetchGraphQL, GET_POST_BY_SLUG, GET_ALL_POSTS } from '@/lib/graphql'; import Image from 'next/image'; import { notFound } from 'next/navigation'; interface PostPageProps { params: { slug: string; }; } <em>// Генерация статических путей</em> export async function generateStaticParams() { const data = await fetchGraphQL(GET_ALL_POSTS); return data.posts.nodes.map((post: any) => ({ slug: post.slug, })); } <em>// Генерация метаданных</em> export async function generateMetadata({ params }: PostPageProps) { const data = await fetchGraphQL(GET_POST_BY_SLUG, { slug: params.slug }); const post = data.postBy; if (!post) return {}; return { title: post.seo?.title || post.title, description: post.seo?.metaDesc || post.excerpt, openGraph: { title: post.title, description: post.excerpt, images: [post.seo?.opengraphImage?.sourceUrl], type: 'article', publishedTime: post.date, }, }; } export default async function PostPage({ params }: PostPageProps) { const data = await fetchGraphQL(GET_POST_BY_SLUG, { slug: params.slug }); const post = data.postBy; if (!post) { notFound(); } return ( <article className="max-w-4xl mx-auto px-4 py-8"> {post.featuredImage && ( <div className="relative h-96 mb-8"> <Image src={post.featuredImage.node.sourceUrl} alt={post.featuredImage.node.altText || post.title} fill className="object-cover rounded-lg" priority /> </div> )} <header className="mb-8"> <h1 className="text-4xl font-bold mb-4">{post.title}</h1> <div className="flex items-center gap-4 text-gray-600"> {post.author?.node.avatar && ( <Image src={post.author.node.avatar.url} alt={post.author.node.name} width={40} height={40} className="rounded-full" /> )} <div> <p className="font-medium">{post.author?.node.name}</p> <time dateTime={post.date}> {new Date(post.date).toLocaleDateString()} </time> <span> · {post.readingTime} min read</span> </div> </div> </header> <div className="prose prose-lg max-w-none" dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ); } <em>// ISR revalidation</em> export const revalidate = 3600; <em>// Revalidate every hour</em> </code>Кеширование: многоуровневая стратегия
ISR (Incremental Static Regeneration)
Самая мощная фича Next.js для headless WordPress:
typescript<code><em>// app/blog/[slug]/page.tsx</em> export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await getPost(params.slug); return <Post data={post} />; } <em>// Revalidate after 60 seconds</em> export const revalidate = 60; <em>// При большом количестве страниц используем fallback</em> export async function generateStaticParams() { const posts = await getPosts({ limit: 100 }); <em>// Только топ-100</em> return posts.map(post => ({ slug: post.slug })); } export const dynamicParams = true; <em>// Позволяет генерировать новые страницы on-demand</em> </code>On-Demand Revalidation
WordPress webhook для триггера ребилдов:
php<code><em>// functions.php - On-demand revalidation</em> add_action('save_post', 'trigger_nextjs_revalidation', 10, 3); function trigger_nextjs_revalidation($post_id, $post, $update) { <em>// Пропускаем автосохранения и ревизии</em> if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) { return; } <em>// Только для опубликованных постов</em> if ($post->post_status !== 'publish') { return; } $revalidate_url = getenv('NEXTJS_REVALIDATE_URL'); $revalidate_token = getenv('NEXTJS_REVALIDATE_TOKEN'); if (!$revalidate_url || !$revalidate_token) { return; } $paths = array( '/', <em>// Homepage</em> '/' . $post->post_name, <em>// Post page</em> ); <em>// Добавление category pages</em> $categories = get_the_category($post_id); foreach ($categories as $category) { $paths[] = '/category/' . $category->slug; } <em>// Асинхронный запрос к Next.js API</em> foreach ($paths as $path) { wp_remote_post($revalidate_url, array( 'blocking' => false, 'body' => json_encode(array( 'path' => $path, 'token' => $revalidate_token )), 'headers' => array( 'Content-Type' => 'application/json' ) )); } } </code>Next.js API route для revalidation:
typescript<code><em>// app/api/revalidate/route.ts</em> import { revalidatePath } from 'next/cache'; import { NextRequest, NextResponse } from 'next/server'; export async function POST(request: NextRequest) { const body = await request.json(); const { path, token } = body; <em>// Валидация токена</em> if (token !== process.env.REVALIDATION_TOKEN) { return NextResponse.json( { message: 'Invalid token' }, { status: 401 } ); } <em>// Валидация пути</em> if (!path || typeof path !== 'string') { return NextResponse.json( { message: 'Invalid path' }, { status: 400 } ); } try { <em>// Revalidate the path</em> revalidatePath(path); return NextResponse.json( { message: 'Revalidated successfully', path, timestamp: new Date().toISOString() }, { status: 200 } ); } catch (error) { return NextResponse.json( { message: 'Error revalidating', error: error.message }, { status: 500 } ); } } </code>Edge Caching через CDN
Vercel Edge Config:
typescript<code><em>// middleware.ts - Edge caching rules</em> import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const response = NextResponse.next(); <em>// Кеширование статических ресурсов</em> if (request.nextUrl.pathname.startsWith('/_next/static/')) { response.headers.set( 'Cache-Control', 'public, max-age=31536000, immutable' ); } <em>// Кеширование API ответов</em> if (request.nextUrl.pathname.startsWith('/api/posts')) { response.headers.set( 'Cache-Control', 's-maxage=60, stale-while-revalidate=30' ); } return response; } </code>Деплой: production-ready окружение
Multi-environment setup
text<code># .env.local (development) WORDPRESS_API_URL=http://localhost:8000 WORDPRESS_GRAPHQL_URL=http://localhost:8000/graphql NEXTJS_REVALIDATE_TOKEN=dev-secret-token NEXT_PUBLIC_SITE_URL=http://localhost:3000 # .env.staging WORDPRESS_API_URL=https://staging-backend.example.com WORDPRESS_GRAPHQL_URL=https://staging-backend.example.com/graphql NEXTJS_REVALIDATE_TOKEN=${STAGING_REVALIDATE_TOKEN} NEXT_PUBLIC_SITE_URL=https://staging.example.com # .env.production WORDPRESS_API_URL=https://backend.example.com WORDPRESS_GRAPHQL_URL=https://backend.example.com/graphql NEXTJS_REVALIDATE_TOKEN=${PROD_REVALIDATE_TOKEN} NEXT_PUBLIC_SITE_URL=https://example.com </code>CI/CD Pipeline (GitHub Actions)
text<code># .github/workflows/deploy.yml name: Deploy Headless WordPress on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18' cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Build run: npm run build env: WORDPRESS_API_URL: ${{ secrets.WORDPRESS_API_URL }} WORDPRESS_GRAPHQL_URL: ${{ secrets.WORDPRESS_GRAPHQL_URL }} deploy-staging: needs: test if: github.ref == 'refs/heads/develop' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy to Vercel (Staging) uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: '--env=staging' deploy-production: needs: test if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy to Vercel (Production) uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: '--prod' - name: Run post-deploy tests run: npm run test:e2e env: BASE_URL: ${{ secrets.PRODUCTION_URL }} </code>Типичные проблемы и решения
Проблема 1: Preview не работает
Самая частая проблема — preview для неопубликованного контента.
Решение через Preview Mode:
typescript<code><em>// app/api/preview/route.ts</em> import { draftMode } from 'next/headers'; import { redirect } from 'next/navigation'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const secret = searchParams.get('secret'); const slug = searchParams.get('slug'); <em>// Проверка секретного токена</em> if (secret !== process.env.PREVIEW_SECRET) { return new Response('Invalid token', { status: 401 }); } if (!slug) { return new Response('Missing slug', { status: 400 }); } <em>// Активация draft mode</em> draftMode().enable(); <em>// Редирект на preview страницу</em> redirect(`/${slug}`); } </code>WordPress интеграция:
php<code><em>// functions.php - Preview button redirect</em> add_filter('preview_post_link', 'custom_preview_link', 10, 2); function custom_preview_link($preview_link, $post) { $frontend_url = getenv('NEXTJS_FRONTEND_URL'); $preview_secret = getenv('NEXTJS_PREVIEW_SECRET'); if (!$frontend_url || !$preview_secret) { return $preview_link; } return sprintf( '%s/api/preview?secret=%s&slug=%s', $frontend_url, $preview_secret, $post->post_name ); } </code>Проблема 2: Изображения не загружаются
CORS и mixed content проблемы с изображениями.
Решение через Next.js Image Loader:
typescript<code><em>// next.config.js</em> module.exports = { images: { loader: 'custom', loaderFile: './lib/imageLoader.ts', }, }; <em>// lib/imageLoader.ts</em> export default function wordpressImageLoader({ src, width, quality }) { <em>// Если это уже полный URL</em> if (src.startsWith('http')) { return `${src}?w=${width}&q=${quality || 75}`; } <em>// Если относительный путь</em> const baseUrl = process.env.NEXT_PUBLIC_WORDPRESS_URL; return `${baseUrl}${src}?w=${width}&q=${quality || 75}`; } </code>Проблема 3: Медленные GraphQL запросы
На проекте с ACF полями запросы занимали 2-3 секунды.
Решение через Query Complexity Limits:
php<code><em>// functions.php - Ограничение сложности запросов</em> add_filter('graphql_query_complexity_max', function() { return 150; <em>// Default 500</em> }); <em>// Включение persistent queries для кэширования</em> add_filter('graphql_persisted_queries_enabled', '__return_true'); <em>// Отключение неиспользуемых типов</em> add_filter('graphql_register_types', function() { <em>// Удаление ненужных типов из схемы</em> unregister_graphql_type('Comment'); unregister_graphql_type('User'); }); </code>Проблема 4: Стейл данные после публикации
ISR не срабатывает моментально, пользователи видят старый контент.
Решение через SWR (Stale-While-Revalidate):
typescript<code><em>// hooks/usePosts.ts</em> import useSWR from 'swr'; const fetcher = (url: string) => fetch(url).then(r => r.json()); export function usePosts() { const { data, error, isLoading, mutate } = useSWR( '/api/posts', fetcher, { revalidateOnFocus: false, revalidateOnReconnect: false, refreshInterval: 60000, <em>// Refresh every 60s</em> dedupingInterval: 5000, } ); return { posts: data, isLoading, error, refresh: mutate }; } </code>Проблема 5: Большой bundle size
React + Next.js + все зависимости = 500KB+ JavaScript.
Решение через Code Splitting:
typescript<code><em>// Dynamic imports для тяжелых компонентов</em> import dynamic from 'next/dynamic'; const CommentSection = dynamic(() => import('@/components/CommentSection'), { loading: () => <p>Loading comments...</p>, ssr: false <em>// Отключаем SSR для comments</em> }); const ShareButtons = dynamic(() => import('@/components/ShareButtons'), { ssr: false }); <em>// Tree shaking для lodash</em> import debounce from 'lodash/debounce'; <em>// ❌ Не делайте так</em> import debounce from 'lodash-es/debounce'; <em>// ✅ Делайте так</em> </code>Мониторинг и производительность
Real User Monitoring
typescript<code><em>// app/layout.tsx - Web Vitals tracking</em> 'use client'; import { useReportWebVitals } from 'next/web-vitals'; export function WebVitals() { useReportWebVitals((metric) => { <em>// Отправка в analytics</em> if (window.gtag) { window.gtag('event', metric.name, { value: Math.round(metric.value), metric_id: metric.id, metric_label: metric.label, }); } <em>// Отправка в собственную систему мониторинга</em> fetch('/api/analytics', { method: 'POST', body: JSON.stringify(metric), }); }); return null; } </code>Lighthouse CI в пайплайне
text<code># .github/workflows/lighthouse.yml name: Lighthouse CI on: [push] jobs: lighthouse: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Wait for Vercel Preview uses: patrickedqvist/wait-for-vercel-preview@v1.3.1 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Run Lighthouse CI uses: treosh/lighthouse-ci-action@v10 with: urls: | https://${{ steps.preview.outputs.url }} https://${{ steps.preview.outputs.url }}/blog budgetPath: ./lighthouserc.json uploadArtifacts: true </code>Headless WordPress с React в 2025 — это production-ready архитектура с понятными trade-offs. Вы получаете невероятную производительность, современный DX и omnichannel возможности, но платите сложностью инфраструктуры и необходимостью решать задачи preview, ISR и аутентификации. Для проектов с высоким трафиком и командой разработчиков это лучший выбор. Для небольших сайтов с бюджетом <$10k традиционный WordPress остается более практичным решением.