За последние два года перевел 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 остается более практичным решением.



