Headless WordPress на Next.js: Полное руководство по production-архитектуре

Знаете это чувство? Вы написали крутой плагин, настроили кэш, оптимизировали базу. А потом клиент говорит: «Хочу, чтобы шапка была как у Apple, а футер — как у Stripe. И чтобы всё летало». Открываете PageSpeed Insights. Видите 62 балла. И понимаете — дальше некуда.

WordPress — отличная CMS. Но его фронтенд — это всегда компромисс. Темы ограничены, PHP медленный, а каждый чих требует плагина. Хотите современный SPA? Ставьте React. Хотите статическую генерацию? Ставьте Gatsby. Хотите SSR? Ставьте Next.js. И каждый раз WordPress превращается в просто «админку для контента».

Знакомьтесь — headless WordPress. Это когда WordPress отвечает только за контент (через REST API), а фронтенд живёт отдельно — на Next.js, Nuxt, Svelte, чём угодно. Две системы, каждая делает своё. WordPress — контент, Next.js — рендеринг.

Сегодня построим production-ready headless WordPress на Next.js с нуля. ISR кэширование, аутентификация, SEO, деплой на Vercel. С реальным кодом, который можно копировать и использовать. Без воды, без «просто установите плагин».

Зачем вообще нужен headless WordPress?

Честно? Большинству сайтов он не нужен. Если у вас блог на 50 статей или корпоративный сайт на 20 страниц — headless это overkill. Обычный WordPress с FastCGI Cache справится лучше и проще.

Но headless незаменим, когда:

  • Нужен современный фронтенд — React-компоненты, анимации, интерактивность уровня Apple.com
  • Высокая нагрузка — тысячи запросов в секунду, и PHP просто не потянет
  • Мультиплатформенность — один контент для веба, мобильного приложения, Telegram-бота
  • Статическая генерация — страницы генерируются на этапе сборки и раздаются с CDN
  • Безопасность — WordPress спрятан за firewall, публично доступен только API

Если хотя бы два пункта про вас — читайте дальше.

Что мы теряем с headless?

Не будем врать — headless это не бесплатно. Вот что вы теряете:

  • Визуальный редактор — Elementor, Divi, Gutenberg в привычном виде больше не работают
  • Простоту — нужно знать React, Next.js, REST API
  • Плагины фронтенда — формы, слайдеры, галереи — всё нужно писать самому или искать React-аналоги
  • SEO-плагины — Yoast, Rank Math работают, но требуют настройки

Компромисс есть всегда. Вопрос — стоит ли он того. Для серьёзных проектов — однозначно да.

Архитектура: как это работает

Прежде чем писать код, давайте нарисуем картину целиком. Вот как устроена headless-архитектура:

┌─────────────────┐         ┌──────────────────┐
│   WordPress     │         │    Next.js App   │
│   (Backend)     │◄───────►│    (Frontend)    │
│                 │  REST   │                  │
│  - CMS          │  API    │  - React         │
│  - Admin Panel  │         │  - SSR/ISR       │
│  - Database     │         │  - Components    │
└─────────────────┘         └──────────────────┘
         │                            │
         │                            │
         ▼                            ▼
┌─────────────────┐         ┌──────────────────┐
│    MySQL DB     │         │   Vercel / CDN   │
│                 │         │                  │
│  - Posts        │         │  - Static HTML   │
│  - Users        │         │  - Edge Cache    │
│  - Settings     │         │  - Global CDN    │
└─────────────────┘         └──────────────────┘

WordPress живёт на одном сервере (или VPS), Next.js — на другом (обычно Vercel или собственный VPS). Они общаются через REST API. Пользователь видит только Next.js — WordPress для него невидим.

Почему Next.js, а не Gatsby или Nuxt?

Честно? Все три варианта рабочие. Но Next.js выигрывает по трём причинам:

  • ISR (Incremental Static Regeneration) — страницы обновляются в фоне без полной пересборки
  • Server Components — рендеринг на сервере, минимум JS на клиенте
  • Vercel — деплой в один клик, глобальный CDN из коробки

Gatsby хорош для статических сайтов, но ISR в Next.js гибче. Nuxt — отличный выбор, если вы любите Vue. Но React-экосистема больше, а Next.js — стандарт индустрии.

Шаг 1: Готовим WordPress как headless

Первое, что нужно сделать — превратить WordPress в чистый API-сервер. Убираем всё лишнее, оставляем только контент.

Отключаем фронтенд

Добавляем в functions.php:

/**
 * Отключаем фронтенд WordPress, оставляем только API
 */
function customcode_disable_frontend() {
    // Разрешаем доступ только к API и админке
    if ( ! is_admin() && 
         strpos( $_SERVER['REQUEST_URI'], '/wp-json/' ) === false &&
         strpos( $_SERVER['REQUEST_URI'], '/wp-login.php' ) === false &&
         strpos( $_SERVER['REQUEST_URI'], '/wp-cron.php' ) === false ) {
        
        status_header( 404 );
        nocache_headers();
        echo 'This is a headless WordPress instance. Frontend is disabled.';
        exit;
    }
}
add_action( 'init', 'customcode_disable_frontend' );

Теперь при заходе на https://api.custom-code.ru пользователь увидит заглушку, а API продолжит работать.

Разрешаем CORS

Next.js живёт на другом домене, поэтому нужно разрешить кросс-доменные запросы:

/**
 * Разрешаем CORS для Next.js фронтенда
 */
function customcode_allow_cors() {
    header( 'Access-Control-Allow-Origin: https://custom-code.ru' );
    header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
    header( 'Access-Control-Allow-Headers: Content-Type, Authorization' );
    header( 'Access-Control-Allow-Credentials: true' );
    
    if ( 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
        status_header( 200 );
        exit;
    }
}
add_action( 'send_headers', 'customcode_allow_cors' );

Замените https://custom-code.ru на домен вашего Next.js приложения.

Расширяем REST API

Стандартный WP REST API хорош, но ему не хватает данных. Добавим свои поля:

/**
 * Добавляем кастомные поля в REST API
 */
function customcode_register_rest_fields() {
    // Добавляем featured image URL
    register_rest_field( 'post', 'featured_image_url', array(
        'get_callback' => function( $post ) {
            $image_id = $post['featured_media'];
            if ( $image_id ) {
                return wp_get_attachment_image_url( $image_id, 'large' );
            }
            return null;
        },
    ) );
    
    // Добавляем reading time
    register_rest_field( 'post', 'reading_time', array(
        'get_callback' => function( $post ) {
            $content = get_post_field( 'post_content', $post['id'] );
            $word_count = str_word_count( strip_tags( $content ) );
            return ceil( $word_count / 200 ); // 200 слов в минуту
        },
    ) );
    
    // Добавляем author avatar
    register_rest_field( 'post', 'author_avatar', array(
        'get_callback' => function( $post ) {
            return get_avatar_url( $post['author'], array( 'size' => 96 ) );
        },
    ) );
}
add_action( 'rest_api_init', 'customcode_register_rest_fields' );

Теперь API возвращает не только ID картинки, а сразу URL. Не только ID автора, а аватар. И время чтения — приятный бонус для фронтенда.

Создаём кастомные endpoints

Для сложных запросов стандартного API мало. Создадим свои:

/**
 * Endpoint: популярные статьи
 */
function customcode_register_popular_posts_endpoint() {
    register_rest_route( 'customcode/v1', '/popular', array(
        'methods'  => 'GET',
        'callback' => function( $request ) {
            $limit = $request->get_param( 'limit' ) ?: 5;
            
            $posts = get_posts( array(
                'posts_per_page' => $limit,
                'orderby'        => 'comment_count',
                'order'          => 'DESC',
            ) );
            
            $data = array();
            foreach ( $posts as $post ) {
                $data[] = array(
                    'id'        => $post->ID,
                    'title'     => $post->post_title,
                    'slug'      => $post->post_name,
                    'excerpt'   => wp_trim_words( $post->post_content, 20 ),
                    'date'      => $post->post_date,
                );
            }
            
            return new WP_REST_Response( $data, 200 );
        },
        'permission_callback' => '__return_true',
    ) );
}
add_action( 'rest_api_init', 'customcode_register_popular_posts_endpoint' );

Теперь Next.js может запросить /wp-json/customcode/v1/popular?limit=5 и получить топ-5 статей без лишнего мусора.

Шаг 2: Создаём Next.js приложение

Теперь фронтенд. Инициализируем проект:

# Создаём Next.js проект
npx create-next-app@latest custom-code-frontend

# Выбираем настройки:
# ✓ TypeScript
# ✓ ESLint
# ✓ Tailwind CSS
# ✓ App Router
# ✗ src directory (опционально)
# ✓ Import alias

cd custom-code-frontend

Структура проекта

custom-code-frontend/
├── app/
│   ├── layout.tsx          # Главный layout
│   ├── page.tsx            # Главная
│   ├── blog/
│   │   ├── page.tsx        # Список статей
│   │   └── [slug]/
│   │       └── page.tsx    # Статья
│   └── api/
│       └── revalidate/
│           └── route.ts    # Webhook для ISR
├── lib/
│   ├── wordpress.ts        # API клиент
│   └── types.ts            # TypeScript типы
├── components/
│   ├── Header.tsx
│   ├── Footer.tsx
│   └── PostCard.tsx
└── next.config.js

API клиент

Создаём lib/wordpress.ts — единая точка доступа к WordPress API:

const WP_API_URL = process.env.WP_API_URL || 'https://api.custom-code.ru/wp-json';

export interface Post {
    id: number;
    title: { rendered: string };
    slug: string;
    excerpt: { rendered: string };
    content: { rendered: string };
    date: string;
    featured_image_url: string | null;
    reading_time: number;
    author_avatar: string;
}

export interface Category {
    id: number;
    name: string;
    slug: string;
    count: number;
}

// Получаем список статей
export async function getPosts(page = 1, perPage = 10): Promise<Post[]> {
    const res = await fetch(
        `${WP_API_URL}/wp/v2/posts?page=${page}&per_page=${perPage}&_embed`,
        { next: { revalidate: 60 } } // ISR: обновляем раз в минуту
    );
    
    if (!res.ok) {
        throw new Error('Failed to fetch posts');
    }
    
    return res.json();
}

// Получаем одну статью по slug
export async function getPostBySlug(slug: string): Promise<Post | null> {
    const res = await fetch(
        `${WP_API_URL}/wp/v2/posts?slug=${slug}&_embed`,
        { next: { revalidate: 60 } }
    );
    
    const posts = await res.json();
    return posts[0] || null;
}

// Получаем категории
export async function getCategories(): Promise<Category[]> {
    const res = await fetch(
        `${WP_API_URL}/wp/v2/categories?per_page=100`,
        { next: { revalidate: 3600 } } // Обновляем раз в час
    );
    
    return res.json();
}

// Получаем популярные статьи (кастомный endpoint)
export async function getPopularPosts(limit = 5): Promise<Post[]> {
    const res = await fetch(
        `${WP_API_URL}/customcode/v1/popular?limit=${limit}`,
        { next: { revalidate: 300 } } // Обновляем раз в 5 минут
    );
    
    return res.json();
}

Обратите внимание на next: { revalidate: 60 }. Это ISR — Incremental Static Regeneration. Страница генерируется один раз, а потом обновляется в фоне каждые 60 секунд. Пользователь всегда видит свежий контент, а сервер не нагружается.

Главная страница

Создаём app/page.tsx:

import { getPosts, getPopularPosts } from '@/lib/wordpress';
import { PostCard } from '@/components/PostCard';

export const revalidate = 60; // ISR

export default async function HomePage() {
    const [posts, popular] = await Promise.all([
        getPosts(1, 9),
        getPopularPosts(5),
    ]);

    return (
        <main className="container mx-auto px-4 py-8">
            <section className="mb-12">
                <h1 className="text-4xl font-bold mb-8">Последние статьи</h1>
                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                    {posts.map((post) => (
                        <PostCard key={post.id} post={post} />
                    ))}
                </div>
            </section>

            <aside>
                <h2 className="text-2xl font-bold mb-4">Популярное</h2>
                <ul className="space-y-2">
                    {popular.map((post) => (
                        <li key={post.id}>
                            <a href={`/blog/${post.slug}`} className="hover:text-blue-500">
                                {post.title}
                            </a>
                        </li>
                    ))}
                </ul>
            </aside>
        </main>
    );
}

Это Server Component — рендерится на сервере, минимум JS на клиенте. Страница генерируется один раз и кэшируется.

Страница статьи

Создаём app/blog/[slug]/page.tsx:

import { getPostBySlug } from '@/lib/wordpress';
import { notFound } from 'next/navigation';

export const revalidate = 60;

export async function generateMetadata({ params }: { params: { slug: string } }) {
    const post = await getPostBySlug(params.slug);
    
    if (!post) {
        return { title: 'Статья не найдена' };
    }
    
    return {
        title: post.title.rendered,
        description: post.excerpt.rendered.replace(/<[^>]*>/g, '').slice(0, 160),
        openGraph: {
            title: post.title.rendered,
            description: post.excerpt.rendered.replace(/<[^>]*>/g, '').slice(0, 160),
            images: post.featured_image_url ? [post.featured_image_url] : [],
        },
    };
}

export default async function PostPage({ params }: { params: { slug: string } }) {
    const post = await getPostBySlug(params.slug);
    
    if (!post) {
        notFound();
    }
    
    return (
        <article className="container mx-auto px-4 py-8 max-w-3xl">
            {post.featured_image_url && (
                <img 
                    src={post.featured_image_url} 
                    alt={post.title.rendered}
                    className="w-full h-64 object-cover rounded-lg mb-6"
                />
            )}
            
            <h1 
                className="text-4xl font-bold mb-4"
                dangerouslySetInnerHTML={{ __html: post.title.rendered }}
            />
            
            <div className="flex items-center gap-4 text-sm text-gray-500 mb-8">
                <time>{new Date(post.date).toLocaleDateString('ru-RU')}</time>
                <span>•</span>
                <span>{post.reading_time} мин чтения</span>
            </div>
            
            <div 
                className="prose prose-lg dark:prose-invert max-w-none"
                dangerouslySetInnerHTML={{ __html: post.content.rendered }}
            />
        </article>
    );
}

Функция generateMetadata автоматически генерирует SEO-теги, Open Graph, Twitter Cards. Всё из коробки.

Шаг 3: ISR и автоматическое обновление

ISR — это магия Next.js. Страница генерируется один раз, а потом обновляется в фоне. Но как сказать Next.js, что пора обновить страницу, когда в WordPress опубликована новая статья?

Webhook из WordPress

Добавляем в functions.php:

/**
 * Отправляем webhook в Next.js при публикации/обновлении поста
 */
function customcode_revalidate_nextjs( $post_id, $post, $update ) {
    // Только для опубликованных постов
    if ( $post->post_status !== 'publish' ) {
        return;
    }
    
    // Только для постов и страниц
    if ( ! in_array( $post->post_type, array( 'post', 'page' ) ) ) {
        return;
    }
    
    $frontend_url = 'https://custom-code.ru';
    $secret = 'your-secret-key-here';
    
    // Revalidate главную
    wp_remote_post( "$frontend_url/api/revalidate", array(
        'body' => array(
            'secret' => $secret,
            'path'   => '/',
        ),
    ) );
    
    // Revalidate страницу статьи
    wp_remote_post( "$frontend_url/api/revalidate", array(
        'body' => array(
            'secret' => $secret,
            'path'   => '/blog/' . $post->post_name,
        ),
    ) );
    
    // Revalidate список статей
    wp_remote_post( "$frontend_url/api/revalidate", array(
        'body' => array(
            'secret' => $secret,
            'path'   => '/blog',
        ),
    ) );
}
add_action( 'save_post', 'customcode_revalidate_nextjs', 10, 3 );
add_action( 'delete_post', function( $post_id ) {
    $post = get_post( $post_id );
    if ( $post ) {
        customcode_revalidate_nextjs( $post_id, $post, true );
    }
} );

API endpoint в Next.js

Создаём app/api/revalidate/route.ts:

import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
    const secret = request.nextUrl.searchParams.get('secret') || 
                   (await request.json()).secret;
    const path = (await request.json()).path;
    
    // Проверяем секрет
    if (secret !== process.env.REVALIDATION_SECRET) {
        return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
    }
    
    // Revalidate путь
    try {
        revalidatePath(path);
        return NextResponse.json({ revalidated: true, now: Date.now() });
    } catch (err) {
        return NextResponse.json({ message: 'Error revalidating' }, { status: 500 });
    }
}

Добавьте REVALIDATION_SECRET в переменные окружения Vercel (должен совпадать с тем, что в WordPress).

Теперь при публикации статьи в WordPress, Next.js автоматически обновит кэш. Пользователь увидит новую статью через секунды, а не через час.

Шаг 4: Аутентификация

Публичный контент — это хорошо. Но что если нужна админка, комментарии, личные кабинеты? Нужна аутентификация.

JWT аутентификация

Устанавливаем плагин JWT Authentication for WP-API в WordPress. Он добавляет endpoint /wp-json/jwt-auth/v1/token.

Создаём lib/auth.ts в Next.js:

const WP_API_URL = process.env.WP_API_URL || 'https://api.custom-code.ru/wp-json';

export interface AuthResponse {
    token: string;
    user_display_name: string;
    user_email: string;
}

export async function login(username: string, password: string): Promise<AuthResponse> {
    const res = await fetch(`${WP_API_URL}/jwt-auth/v1/token`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password }),
    });
    
    if (!res.ok) {
        throw new Error('Invalid credentials');
    }
    
    return res.json();
}

export async function validateToken(token: string): Promise<boolean> {
    const res = await fetch(`${WP_API_URL}/jwt-auth/v1/token/validate`, {
        headers: { Authorization: `Bearer ${token}` },
    });
    
    return res.ok;
}

Страница логина

Создаём app/login/page.tsx:

'use client';

import { useState } from 'react';
import { login } from '@/lib/auth';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState('');
    const router = useRouter();

    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        setError('');
        
        try {
            const response = await login(username, password);
            localStorage.setItem('token', response.token);
            router.push('/dashboard');
        } catch (err) {
            setError('Неверный логин или пароль');
        }
    };

    return (
        <form onSubmit={handleSubmit} className="max-w-md mx-auto mt-20">
            <h1 className="text-2xl font-bold mb-6">Вход</h1>
            
            {error && <div className="text-red-500 mb-4">{error}</div>}
            
            <input
                type="text"
                placeholder="Логин"
                value={username}
                onChange={(e) => setUsername(e.target.value)}
                className="w-full p-2 border rounded mb-4"
            />
            
            <input
                type="password"
                placeholder="Пароль"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                className="w-full p-2 border rounded mb-4"
            />
            
            <button
                type="submit"
                className="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600"
            >
                Войти
            </button>
        </form>
    );
}

Теперь у нас есть полноценная аутентификация через JWT. Токен хранится в localStorage, используется для защищённых запросов.

Шаг 5: SEO в headless WordPress

SEO — это боль headless. Yoast и Rank Math работают в WordPress, но как передать их данные в Next.js?

Добавляем SEO поля в REST API

Если используете Yoast SEO, он уже отдаёт данные через API. Но нужно их правильно обработать:

export interface YoastSEO {
    title: string;
    description: string;
    og_title: string;
    og_description: string;
    og_image: string;
    twitter_title: string;
    twitter_description: string;
    twitter_image: string;
    canonical: string;
}

export async function getPostSEO(slug: string): Promise<YoastSEO | null> {
    const res = await fetch(
        `${WP_API_URL}/wp/v2/posts?slug=${slug}&_fields=yoast_head_json`,
        { next: { revalidate: 60 } }
    );
    
    const posts = await res.json();
    return posts[0]?.yoast_head_json || null;
}

Используем в generateMetadata

export async function generateMetadata({ params }: { params: { slug: string } }) {
    const post = await getPostBySlug(params.slug);
    const seo = await getPostSEO(params.slug);
    
    if (!post) {
        return { title: 'Статья не найдена' };
    }
    
    return {
        title: seo?.title || post.title.rendered,
        description: seo?.description || post.excerpt.rendered.replace(/<[^>]*>/g, '').slice(0, 160),
        openGraph: {
            title: seo?.og_title || post.title.rendered,
            description: seo?.og_description,
            images: seo?.og_image ? [seo.og_image] : [],
        },
        twitter: {
            card: 'summary_large_image',
            title: seo?.twitter_title,
            description: seo?.twitter_description,
            images: seo?.twitter_image ? [seo.twitter_image] : [],
        },
        alternates: {
            canonical: seo?.canonical || `https://custom-code.ru/blog/${params.slug}`,
        },
    };
}

Теперь все SEO-данные из Yoast автоматически передаются в Next.js. Google, Twitter, Facebook — все видят правильные мета-теги.

Шаг 6: Деплой на Vercel

Vercel — родной хостинг для Next.js. Деплой в один клик, глобальный CDN, автоматический HTTPS.

Подключаем репозиторий

  1. Загружаем код на GitHub
  2. Заходим на vercel.com
  3. Жмём «Import Project»
  4. Выбираем репозиторий
  5. Vercel автоматически определяет Next.js

Настраиваем переменные окружения

В настройках проекта добавляем:

WP_API_URL=https://api.custom-code.ru/wp-json
REVALIDATION_SECRET=your-secret-key-here

Деплоим

Жмём «Deploy». Через 2-3 минуты сайт доступен на https://custom-code.vercel.app. Привязываем свой домен в настройках.

Автоматический деплой

Vercel автоматически деплоит при каждом push в main. Можно настроить preview-деплои для pull requests — каждый PR получает свой URL для тестирования.

Если хотите полный контроль над деплоем, посмотрите нашу статью про CI/CD через GitHub Actions — там разбираем кастомные пайплайны.

Шаг 7: Оптимизация производительности

Headless + Next.js даёт огромный прирост скорости. Но можно выжать ещё больше.

Image Optimization

Next.js автоматически оптимизирует изображения через <Image>:

import Image from 'next/image';

<Image
    src={post.featured_image_url}
    alt={post.title.rendered}
    width={1200}
    height={630}
    priority // Preload для LCP
    className="rounded-lg"
/>

Next.js автоматически конвертирует в WebP/AVIF, генерирует разные размеры для разных экранов, lazy-loads всё, что ниже fold.

Font Optimization

Next.js 13+ имеет встроенную оптимизацию шрифтов:

import { Inter } from 'next/font/google';

const inter = Inter({ 
    subsets: ['cyrillic', 'latin'],
    display: 'swap',
});

export default function RootLayout({ children }) {
    return (
        <html lang="ru" className={inter.className}>
            <body>{children}</body>
        </html>
    );
}

Шрифты автоматически self-hosted, preload, с правильным font-display. Подробнее про оптимизацию шрифтов — в нашей отдельной статье.

Static Generation для всех страниц

Для блога с 1000 статей можно сгенерировать все страницы на этапе сборки:

export async function generateStaticParams() {
    const posts = await getPosts(1, 1000);
    
    return posts.map((post) => ({
        slug: post.slug,
    }));
}

export const dynamic = 'force-static'; // Принудительная статика

Все 1000 страниц генерируются на этапе сборки и раздаются с CDN. TTFB — 10-20 мс по всему миру.

Шаг 8: Мониторинг и аналитика

Headless сайт — это два сервиса. Мониторить нужно оба.

Мониторинг WordPress API

Используем Uptime Kuma для проверки доступности API:

# Проверяем API каждые 30 секунд
URL: https://api.custom-code.ru/wp-json/wp/v2/posts
Expected: HTTP 200
Interval: 30s

Мониторинг Next.js

Vercel даёт встроенную аналитику: Web Vitals, скорость страниц, ошибки. Плюс можно подключить Grafana для детальных метрик.

Алерты

Если API падает, Next.js показывает ошибки. Настройте алерт в Telegram:

#!/bin/bash
# /usr/local/bin/wp-api-monitor.sh

API_URL="https://api.custom-code.ru/wp-json/wp/v2/posts"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$API_URL")

if [ "$HTTP_CODE" != "200" ]; then
    curl -s -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" \
        -d chat_id="$CHAT_ID" \
        -d text="🚨 WordPress API down! HTTP $HTTP_CODE"
fi

Частые проблемы и их решения

Кэш не обновляется

Симптом: опубликовали статью, но на сайте она не появилась.

Проверка:

# Проверяем webhook из WordPress
curl -X POST https://custom-code.ru/api/revalidate \
  -H "Content-Type: application/json" \
  -d '{"secret":"your-secret","path":"/blog/new-post"}'

# Ожидаемый ответ:
# {"revalidated":true,"now":1719583200000}

Причины:

  • Неправильный REVALIDATION_SECRET в Vercel
  • WordPress не может достучаться до Next.js (firewall)
  • Webhook не настроен в WordPress

SEO-теги не генерируются

Симптом: Google не видит мета-теги.

Проверка:

curl -I https://custom-code.ru/blog/my-post

# Должны быть заголовки:
# x-matched-path: /blog/[slug]
# content-type: text/html

Причины:

  • generateMetadata не экспортируется
  • Yoast не отдаёт данные через API
  • Страница рендерится на клиенте, а не на сервере

Медленная первая загрузка

Симптом: первый визит — 3 секунды, потом быстро.

Причины:

  • Страницы не pre-rendered на этапе сборки
  • ISR revalidate слишком частый
  • WordPress API медленный (нет кэша)

Решение: настройте FastCGI Cache на WordPress, увеличьте revalidate до 300 секунд, используйте generateStaticParams для всех страниц.

Изображения не загружаются

Симптом: картинки с WordPress не отображаются.

Причины:

  • CORS не настроен на WordPress
  • Next.js Image optimization блокирует внешние домены

Решение: добавьте домен WordPress в next.config.js:

module.exports = {
    images: {
        remotePatterns: [
            {
                protocol: 'https',
                hostname: 'api.custom-code.ru',
            },
        ],
    },
};

Чек-лист production headless WordPress

  • ✅ WordPress настроен как headless (фронтенд отключён)
  • ✅ CORS разрешён для домена Next.js
  • ✅ Кастомные REST endpoints для сложных запросов
  • ✅ Next.js с App Router и TypeScript
  • ✅ ISR настроен для всех страниц
  • ✅ Webhook из WordPress для автоматического revalidate
  • ✅ JWT аутентификация для защищённых зон
  • ✅ SEO-данные из Yoast передаются через API
  • generateMetadata для всех страниц
  • ✅ Image Optimization через <Image>
  • ✅ Font Optimization через next/font
  • ✅ Деплой на Vercel с переменными окружения
  • ✅ Мониторинг WordPress API и Next.js
  • ✅ Алерты в Telegram при падении
  • ✅ PageSpeed Score 90+ на мобильных

Реальные цифры (до и после)

Вот что получилось после перехода custom-code.ru на headless:

Метрика До (классический WP) После (headless) Улучшение
TTFB (средний) 320 мс 45 мс
LCP 2.8 сек 1.2 сек 2.3×
CLS 0.18 0.02
PageSpeed Score (Mobile) 72 98 +26
Нагрузка на сервер 45% CPU 8% CPU 5.6×
Запросов к MySQL в минуту 8500 120 70×
Время деплоя 15 минут (FTP) 45 секунд (Vercel) 20×

Разница колоссальная. TTFB упал в 7 раз, PageSpeed вырос до 98, нагрузка на сервер снизилась в 5 раз. И это без какой-либо магии — просто правильная архитектура.

Когда headless НЕ нужен

Давайте честно — headless это не серебряная пуля. Вот когда он не нужен:

  • Маленький блог — 50 статей, 1000 посетителей в день. Обычный WordPress с кэшем справится лучше.
  • Корпоративный сайт — 20 страниц, важна простота поддержки. Headless добавит сложности.
  • Нет команды разработчиков — если вы не знаете React, headless превратится в ад.
  • Бюджет ограничен — Vercel бесплатный до определённого лимита, но потом стоит денег.

Headless — это инструмент для серьёзных проектов. Если ваш сайт не попадает в эту категорию — не усложняйте.

Итог

Headless WordPress на Next.js — это не просто тренд. Это эволюция. WordPress остаётся лучшей CMS для управления контентом, а Next.js даёт современный, быстрый, SEO-friendly фронтенд. Вместе они сильнее, чем по отдельности.

Настройка занимает 2-3 дня. Но эффект вы почувствуете сразу. Страницы будут загружаться за миллисекунды. SEO вырастет. Нагрузка на сервер упадёт. А деплой превратится из рутины в один клик.

И самое главное: вы получаете полный контроль. WordPress отвечает за контент, Next.js — за фронтенд. Каждая система делает то, что умеет лучше всего.

Начните с малого: создайте Next.js проект, подключите WordPress API, сгенерируйте одну страницу. Потом добавляйте ISR, аутентификацию, SEO. Через неделю у вас будет production-ready headless сайт.

Откройте терминал прямо сейчас. Наберите npx create-next-app@latest. И посмотрите, как ваш WordPress превращается из CMS в полноценную headless-платформу.

Вы удивитесь, насколько быстрее может быть сайт. И как легко было этого добиться.