Partial Prerendering в Next.js 15: SEO-лендинг с интерактивными блоками
Лендинг должен одновременно попадать в индекс Google за миллисекунды и показывать калькулятор цены, форму записи или анимированный hero-блок. Классический SSR отдаёт HTML на каждый запрос — это дорого на edge и не всегда нужно. Чистый SSG не даёт персонализации и динамики. Static Site Generation с client-side hydration — быстро, но JavaScript блокирует INP, а контент «пустой» до гидратации.
Partial Prerendering (PPR) в Next.js 15 решает этот компромисс: статическая оболочка страницы отдаётся мгновенно, а «дыры» для динамических компонентов заполняются потоком (streaming). Поисковый робот видит полный HTML с текстом и мета-тегами, пользователь — интерактивный интерфейс без просадки LCP и INP.
В этой статье соберём production-лендинг: App Router, shadcn/ui, framer-motion для hero-секции, JSON-LD для SEO и деплой на Vercel с включённым PPR.
Что такое PPR и чем он отличается от SSR и SSG
Partial Prerendering — экспериментальная, но уже применимая в продакшене функция Next.js 15. При сборке Next.js генерирует статический HTML-каркас страницы. Компоненты, помеченные как динамические (Suspense + dynamic() или cookies() / headers()), остаются «дырами» — placeholder’ами, которые заполняются при запросе через React Server Components streaming.
| Подход | HTML для ботов | Интерактивность | TTFB | Сложность |
|---|---|---|---|---|
| SSG | Полный | Только client-side | Минимальный | Низкая |
| SSR | Полный | Полная | Средний | Средняя |
| PPR | Статическая оболочка + stream | Полная | Минимальный | Средняя |
Для SEO-лендинга это означает: заголовки, тексты, FAQ, JSON-LD — в статике. Форма «Записаться», блок «Цена зависит от города», счётчик мест — в динамике. Google получает контент сразу, Core Web Vitals не страдают от тяжёлого JS на первом экране.
Включение PPR в next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
};
export default nextConfig;
Значение 'incremental' позволяет включать PPR постранично через export const experimental_ppr = true — не нужно мигрировать весь проект сразу.
Структура лендинга: статика и динамика
Типичный лендинг услуг делится на зоны с разной «температурой»:
Статические зоны (попадают в prerender):
- Hero с заголовком, подзаголовком, CTA-текстом
- Блок преимуществ, отзывы, FAQ
- Footer с контактами и JSON-LD
- Open Graph и meta-теги
Динамические зоны (streaming через Suspense):
- Форма записи с валидацией (shadcn/ui)
- Калькулятор стоимости
- Блок «Свободные слоты на сегодня»
- Персонализированный баннер по UTM-метке
Структура App Router:
app/
├── layout.tsx # общий layout, шрифты, metadata
├── page.tsx # лендинг с PPR
├── components/
│ ├── hero-static.tsx # статический hero
│ ├── hero-animation.tsx # framer-motion (client)
│ ├── booking-form.tsx # динамическая форма
│ └── price-calculator.tsx
└── api/
└── slots/route.ts # API для слотов
Ключевой файл — page.tsx:
import { Suspense } from 'react';
import { HeroStatic } from '@/components/hero-static';
import { HeroAnimation } from '@/components/hero-animation';
import { BookingForm } from '@/components/booking-form';
import { PriceCalculator } from '@/components/price-calculator';
import { Benefits, FAQ, Footer } from '@/components/sections';
export const experimental_ppr = true;
export default function LandingPage() {
return (
<>
<HeroStatic />
<Suspense fallback={<div className="h-64 animate-pulse bg-muted" />}>
<HeroAnimation />
</Suspense>
<Benefits />
<Suspense fallback={<BookingFormSkeleton />}>
<BookingForm />
</Suspense>
<Suspense fallback={<CalculatorSkeleton />}>
<PriceCalculator />
</Suspense>
<FAQ />
<Footer />
</>
);
}
HeroStatic — Server Component с текстом, который индексируется. HeroAnimation — Client Component с framer-motion, обёрнут в Suspense, чтобы не блокировать первый рендер.
Если нужна подобная разработка — напишите в Telegram.
SEO: metadata, JSON-LD и Core Web Vitals
PPR не отменяет базовую SEO-работу — он делает её эффективнее, потому что статический HTML отдаётся без ожидания динамики.
Metadata API
В Next.js 15 metadata объявляется в layout или page:
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Разработка сайтов под ключ — Next.js, Astro, SEO',
description: 'Лендинги и корпоративные сайты с Core Web Vitals 90+. Next.js 15, Astro 5, деплой на Vercel.',
openGraph: {
title: 'Разработка сайтов под ключ',
description: 'Быстрые SEO-лендинги с Partial Prerendering',
images: [{ url: '/og-landing.png', width: 1200, height: 630 }],
},
alternates: {
canonical: 'https://example.com',
},
};
Canonical и OG-теги попадают в статический HTML — робот видит их без JavaScript.
JSON-LD для LocalBusiness / Service
Structured data добавляем Server Component’ом:
export function JsonLd() {
const schema = {
'@context': 'https://schema.org',
'@type': 'ProfessionalService',
name: 'Kel IT — разработка сайтов',
url: 'https://example.com',
description: 'Лендинги и корпоративные сайты на Next.js и Astro',
areaServed: 'RU',
priceRange: '$$',
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
Размещаем в layout.tsx — schema всегда в prerender, не зависит от клиентского JS.
Core Web Vitals: INP и LCP
INP (Interaction to Next Paint) стал ключевым метриком с 2024 года и остаётся критичным в 2026-м. PPR помогает:
- LCP — hero-изображение и текст в статике,
priorityна<Image>изnext/image - INP — тяжёлые client-компоненты (форма, калькулятор) загружаются после первого paint
- CLS — skeleton’ы в Suspense fallback совпадают по размеру с финальным контентом
Для framer-motion в hero используйте LazyMotion и domAnimation — bundle framer-motion уменьшается в 10+ раз:
'use client';
import { LazyMotion, domAnimation, m } from 'framer-motion';
export function HeroAnimation() {
return (
<LazyMotion features={domAnimation}>
<m.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* декоративные элементы, не критичные для SEO */}
</m.div>
</LazyMotion>
);
}
Динамические компоненты: форма и калькулятор
Форма записи — классический кандидат для динамической «дыры». Подключаем shadcn/ui, React Hook Form и Server Actions.
// components/booking-form.tsx
import { getAvailableSlots } from '@/lib/slots';
async function BookingFormInner() {
const slots = await getAvailableSlots(); // fetch к API или БД
return <BookingFormClient slots={slots} />;
}
export function BookingForm() {
return (
<Suspense fallback={<BookingFormSkeleton />}>
<BookingFormInner />
</Suspense>
);
}
getAvailableSlots может читать cookies() для персонализации или обращаться к headless CMS — это автоматически делает компонент dynamic boundary.
Server Action для отправки:
'use server';
import { revalidatePath } from 'next/cache';
export async function submitBooking(formData: FormData) {
const name = formData.get('name') as string;
const slot = formData.get('slot') as string;
// сохранение в CMS / CRM
await saveBooking({ name, slot });
revalidatePath('/');
}
Калькулятор цены — Client Component внутри Suspense. Логика расчёта на клиенте, начальные данные (тарифы) — из Server Component через props.
Деплой на Vercel и проверка PPR
Vercel — нативная платформа для Next.js 15. PPR работает на Edge Network без дополнительной настройки.
- Подключите репозиторий к Vercel
- Убедитесь, что
experimental.ppr: 'incremental'в конфиге - На страницах с PPR добавьте
export const experimental_ppr = true - Деплой — Vercel автоматически разделит static shell и dynamic holes
Проверка после деплоя:
curl -s https://your-site.vercel.app | head -100
В ответе должны быть статические секции (hero, FAQ) и placeholder’ы или streaming-разметка для Suspense-блоков.
Lighthouse / PageSpeed Insights:
- LCP < 2.5s — статический hero
- INP < 200ms — client-компоненты не блокируют main thread на первом экране
- SEO 100 — metadata + JSON-LD в HTML
Google Search Console: через «Проверка URL» убедитесь, что rendered HTML содержит тексты из статических секций.
Для preview-окружений Vercel создаёт отдельные URL — тестируйте PPR на preview до merge в main.
Нужна помощь? Telegram → или vic.kell@ya.ru
FAQ
PPR стабилен для продакшена в 2026 году?
PPR помечен как experimental, но 'incremental' позволяет включать его точечно — только на лендингах, без риска для всего приложения. Vercel и крупные проекты уже используют PPR в production. Следите за changelog Next.js 16 — функция может стать stable.
Можно ли комбинировать PPR с ISR?
Да. Статическая оболочка может revalidate по revalidate или revalidatePath, а динамические holes обновляются при каждом запросе. Для лендинга с редко меняющимся контентом достаточно revalidate: 3600.
Нужен ли PPR, если лендинг полностью статичный?
Если нет форм, калькуляторов и персонализации — достаточно чистого SSG (Astro 5 или Next.js static export). PPR имеет смысл, когда есть хотя бы один динамический блок, а SEO-критичный контент должен быть в HTML сразу.
Как PPR влияет на headless CMS?
Контент из CMS (Contentful, Sanity, Strapi) можно загружать в статическую часть через generateStaticParams и build-time fetch. Динамические блоки — для данных, которые меняются чаще одного раза в час: слоты записи, остатки, A/B-тесты.
Чем PPR лучше React Server Components без PPR?
RSC без PPR рендерит всю страницу на сервере при каждом запросе (или кэширует целиком). PPR отделяет «холодный» статический контент от «горячей» динамики — TTFB ниже, CDN отдаёт оболочку из edge-кэша.
Заключение
Partial Prerendering в Next.js 15 — практичный инструмент для SEO-лендингов с интерактивными блоками. Статическая оболочка обеспечивает быстрый TTFB, полный HTML для поисковиков и высокие Core Web Vitals. Динамические «дыры» через Suspense дают формы, калькуляторы и персонализацию без компромисса по SEO.
Схема работает: experimental_ppr = true на странице, статика в Server Components, интерактив в Client Components внутри Suspense, JSON-LD и metadata в layout, деплой на Vercel. Для полностью статичных проектов по-прежнему рационален Astro 5 — но если стек уже Next.js, PPR закрывает типичный кейс «лендинг + одна динамическая фича» без перехода на полный SSR.