Классический серверный рендеринг (Server-Side Rendering, SSR) на протяжении десятилетий оперировал монолитной моделью «всё или ничего» (All-or-Nothing Buffer). Сервер приложений получал входящий HTTP-запрос, запускал рендеринг всего дерева компонентов, дожидался завершения всех асинхронных операций ввода-вывода (обращений к базам данных, микросервисам, сторонним REST API), сериализовал полученный DOM в единую строку HTML и только после этого выставлял заголовки и начинал передачу тела ответа в сокет.
При такой схеме метрика TTFB оказывалась жестко привязана к самому медленному асинхронному запросу на странице. Если второстепенный блок рекомендаций или расчет персональных скидок задерживался на 1500 мс, поисковый робот (Googlebot, Bingbot, Yandexbot) простаивал в ожидании первого байта, расходуя свой Crawl Budget.
МОНОЛИТНЫЙ SSR (Буферизация):
Бот ──── GET /catalog ────► Сервер
├─ fetch(Metadata) ... 50ms
├─ fetch(Products) ... 150ms
└─ fetch(Reviews) .... 1200ms ◄── Блокирует весь ответ!
┌─────────────────────────────┐
│ Генерация единого HTML-буфера│
└─────────────────────────────┘
Бот ◄─── HTTP 200 OK + Весь HTML (TTFB = 1450ms) ───────────┘Появление Streaming SSR на базе React 18/19 и архитектуры Next.js App Router кардинально изменило механику взаимодействия сервера с сетевым сокетом. Вместо накопления буфера сервер инициирует немедленную потоковую передачу данных, используя низкоуровневые возможности транспортных протоколов:
Transfer-Encoding: chunked: Ответ передается последовательностью дискретных фрагментов (чанков), каждому из которых предшествует шестнадцатеричный размер чанка в байтах, завершаясь нулевым чанком 0\r\n\r\n.DATA), завершаясь кадром с флагом END_STREAM.Для поисковых краулеров эта трансформация имеет фундаментальное значение. Однако вокруг способности поисковых роботов корректно обрабатывать потоковый контент сформировался ряд опасных заблуждений. Поисковый краулер — это не просто HTTP-клиент вроде curl; современный краулер Googlebot управляется подсистемой Web Rendering Service (WRS). WRS удерживает сетевое соединение открытым вплоть до получения терминального чанка потока (или до наступления жесткого системного таймаута сканирования, обычно составляющего от 5 до 10 секунд).
Специфика заключается в том, что именно попадает в начальный чанк документа и как обрабатываются краевые ситуации (ошибки 404 и редиректы 301/308) в условиях, когда HTTP-заголовки уже сброшены в сокет.
Чтобы понять распределение данных во времени, рассмотрим разделение страницы на статический каркас (Static Shell) и динамические изолированные слоты (Dynamic Holes), управляемые границами <Suspense>.
В Next.js модель Partial Prerendering (PPR) объединяет сильные стороны статической сборки (SSG) и потокового динамического рендеринга (Streaming SSR). При включенном PPR статический каркас страницы генерируется заранее во время сборки (next build) и сохраняется в пограничном кэше (Edge CDN). В момент запроса Edge-сервер мгновенно сбрасывает первый чанк клиенту, а серверный рантайм возобновляет вычисление динамических слотов через низкоуровневый механизм React 19 postpone.
+-----------------------------------------------------------------------------------------+
| ВХОДЯЩИЙ HTTP-ЗАПРОС |
+-----------------------------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------------------------+
| ЧАНК 1: МГНОВЕННЫЙ СБРОС (TTFB < 30ms из Edge CDN) |
| HTTP/1.1 200 OK (или HTTP/2 200) |
| Transfer-Encoding: chunked (или DATA frames) |
| <!DOCTYPE html><html><head> |
| <title>Купить серверные компоненты | Fullstack Agentic Lab</title> |
| <meta name="robots" content="index, follow" /> |
| <link rel="canonical" href="https://agenticlab.dev/catalog" /> |
| <script type="application/ld+json">{...}</script> <!-- Schema.org --> |
| </head><body> |
| <header>Статическая навигация</header> |
| <main> |
| <div id="P:1"><!-- Скелетон fallback для товаров --></div> |
+-----------------------------------------------------------------------------------------+
│
[WRS / Googlebot считывает <head>]
[Продолжает слушать открытый сокет]
│
▼
+-----------------------------------------------------------------------------------------+
| ЧАНК 2: АСИНХРОННЫЙ ПОТОКОВЫЙ СЛОТ (Dynamic Hole разрешен через 180ms) |
| <div hidden id="S:1"> |
| <div class="product-grid">...реальные товары из БД...</div> |
| </div> |
| <script>$RC("B:1","S:1")</script> |
+-----------------------------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------------------------+
| ЧАНК 3: ТЕРМИНАЛЬНЫЙ СИГНАЛ (Stream Termination) |
| </body></html> |
| 0\r\n\r\n (HTTP/1.1) или флаг END_STREAM (HTTP/2) |
+-----------------------------------------------------------------------------------------+Ключевые сущности потоковой архитектуры:
Googlebot отправляет GET-запрос с поддержкой HTTP/1.1 (Chunked) или HTTP/2. Сервер валидирует маршрут и метаданные.
Сервер сбрасывает статус 200 OK и полный блок head с каноническими ссылками, Schema.org и скелетонами. TTFB зафиксирован.
Серверный рантайм параллельно разрешает промисы внутри границ Suspense. Соединение удерживается открытым.
По мере готовности данные отправляются в виде скрытых блоков с инлайновыми инструкциями подмены разметки в DOM.
Сервер отправляет терминальный сигнал (0 байт или END_STREAM). Бот закрывает сессию.
Каким образом Googlebot WRS обрабатывает потоковый HTTP-ответ (Streaming SSR через Transfer-Encoding: chunked или HTTP/2 DATA frames)?
Product, пока блоки динамических остатков на складах и персонализированных цен подгружаются в потоке.В архитектуре Next.js 15 App Router метаданные страницы, объявленные через статический объект metadata или динамическую функцию generateMetadata(), разрешаются строго до сброса первого чанка в сетевой сокет.
Компилятор Next.js блокирует старт стрима до тех пор, пока промис generateMetadata() не выполнится. Благодаря этому Initial Chunk документа всегда содержит исчерпывающий, валидный блок head:
title и meta name="description";link rel="canonical";meta name="robots";Поисковый робот первой фазы (Crawl & Parse), который анализирует только сырой поток байтов без исполнения JavaScript, мгновенно извлекает все необходимые метаданные и директивы обхода из первого же сетевого пакета.
notFound() и redirect() против Soft 404#Сетевой протокол HTTP накладывает жесткое техническое ограничение: после того как первый байт тела ответа (Body) отправлен в сокет, HTTP-заголовки и статус ответа изменить физически невозможно.
Если сервер отправил клиенту статус HTTP/1.1 200 OK и начал передачу статической оболочки страницы, а затем глубоко внутри вложенного асинхронного компонента под <Suspense> выяснилось, что запрашиваемый товар удален из базы данных, выставить статус 404 Not Found на уровне протокола уже нельзя.
Next.js 15 решает эту проблему архитектурно на уровне жизненного цикла роутинга:
import { Suspense } from 'react';
import { notFound, redirect } from 'next/navigation';
import type { Metadata } from 'next';
import { ProductHeroSkeleton, ProductReviewsSkeleton } from '@/components/skeletons';
interface ProductPageProps {
params: Promise<{ slug: string }>;
}
interface ProductEntity {
id: string;
slug: string;
title: string;
price: number;
isPublished: boolean;
canonicalSlug?: string;
}
// 1. Изолированный слой проверки существования сущности
async function getProductRecord(slug: string): Promise<ProductEntity | null> {
// Быстрый запрос к первичному кэшу / индексу сущностей
const res = await fetch(`https://api.internal.agenticlab.dev/v1/products/${slug}`, {
next: { tags: [`product-${slug}`], revalidate: 3600 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Internal service error: ${res.statusText}`);
return (await res.json()) as ProductEntity;
}
// 2. Метаданные исполняются ДО старта стрима
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { slug } = await params;
const product = await getProductRecord(slug);
if (!product || !product.isPublished) {
return {
title: 'Товар не найден | Fullstack Agentic Lab',
robots: { index: false, follow: false },
};
}
return {
title: `${product.title} — купить по выгодной цене | Fullstack Agentic Lab`,
alternates: {
canonical: `https://agenticlab.dev/products/${product.slug}`,
},
};
}
// 3. Главный компонент страницы (Корневой Shell)
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
// КРИТИЧЕСКИЙ ШАГ: Валидация ДО границы Suspense!
// Этот код исполняется синхронно до сброса HTTP-заголовков.
const product = await getProductRecord(slug);
if (!product) {
// Вызов notFound() здесь ГАРАНТИРОВАННО возвращает полноценный HTTP 404
// в первичном заголовке ответа до отправки любого HTML-чанка.
notFound();
}
if (product.canonicalSlug && product.canonicalSlug !== slug) {
// Гарантированный 308/301 Permanent Redirect до начала стриминга
redirect(`/products/${product.canonicalSlug}`);
}
return (
<main className="max-w-7xl mx-auto px-4 py-8 space-y-8">
{/* Статическая часть оболочки страницы */}
<h1 className="text-3xl font-extrabold tracking-tight text-slate-100">
{product.title}
</h1>
{/* Динамический слот 1: Основная информация о товаре */}
<Suspense fallback={<ProductHeroSkeleton />}>
<ProductHeroAsync productId={product.id} />
</Suspense>
{/* Динамический слот 2: Тяжелые пользовательские отзывы */}
<Suspense fallback={<ProductReviewsSkeleton />}>
<ProductReviewsAsync productId={product.id} />
</Suspense>
</main>
);
}
// Асинхронный компонент внутри Suspense: не блокирует TTFB и <head>
async function ProductHeroAsync({ productId }: { productId: string }) {
const res = await fetch(`https://api.internal.agenticlab.dev/v1/pricing/${productId}`, {
cache: 'no-store',
});
const data = (await res.json()) as { livePrice: number; inStock: boolean };
return (
<section className="p-6 rounded-xl border border-border bg-bg-surface">
<p className="text-2xl font-bold text-accent-cyan">
{data.livePrice} ₽ {data.inStock ? '— В наличии' : '— Под заказ'}
</p>
</section>
);
}
async function ProductReviewsAsync({ productId }: { productId: string }) {
const res = await fetch(`https://api.internal.agenticlab.dev/v1/reviews/${productId}`, {
next: { revalidate: 60 },
});
const reviews = (await res.json()) as Array<{ id: string; author: string; text: string }>;
return (
<section className="space-y-4">
<h2 className="text-xl font-semibold text-slate-200">Отзывы покупателей</h2>
{reviews.map((rev) => (
<div key={rev.id} className="p-4 rounded-lg border border-border bg-bg-elevated/40">
<p className="text-sm font-bold text-slate-300">{rev.author}</p>
<p className="text-sm text-text-muted mt-1">{rev.text}</p>
</div>
))}
</section>
);
}Для перевода проекта в режим инкрементального PPR необходимо активировать флаг в конфигурационном файле next.config.ts и объявить директиву в целевом роуте:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
// Включение инкрементального режима Partial Prerendering
ppr: 'incremental',
},
};
export default nextConfig;В сегменте маршрута активируем PPR и изолируем динамические вызовы:
import { Suspense } from 'react';
import { connection } from 'next/server';
import { CatalogFiltersSkeleton, CatalogListSkeleton } from '@/components/skeletons';
// Активация PPR для данного сегмента маршрута
export const experimental_ppr = true;
export default function CatalogPage() {
return (
<div className="container mx-auto p-6 grid grid-cols-12 gap-6">
{/* Статическая часть: компилируется в Static Shell на этапе сборки */}
<aside className="col-span-3">
<h2 className="text-lg font-bold text-slate-100 mb-4">Категории</h2>
{/* Статическая навигация по категориям */}
</aside>
<section className="col-span-9 space-y-6">
{/* Динамический слот: откладывается до рантайма через connection() */}
<Suspense fallback={<CatalogListSkeleton />}>
<DynamicProductFeed />
</Suspense>
</section>
</div>
);
}
async function DynamicProductFeed() {
// connection() явно сообщает компилятору Next.js прервать сборку (React.postpone)
// и вынести рендеринг этого компонента в динамический запрос
await connection();
const res = await fetch('https://api.internal.agenticlab.dev/v1/feed', {
cache: 'no-store',
});
const items = (await res.json()) as Array<{ id: string; name: string }>;
return (
<div className="grid grid-cols-3 gap-4">
{items.map((item) => (
<article key={item.id} className="p-4 border border-border rounded-lg bg-bg-surface">
<h3 className="font-semibold text-slate-200">{item.name}</h3>
</article>
))}
</div>
);
}Для проверки корректности передачи чанков и HTTP-статусов без использования браузера применяется CLI-утилита curl или Node.js скрипт на базе нативного сетевого сокета:
import http from 'node:http';
interface StreamVerificationResult {
statusCode: number;
headers: http.IncomingHttpHeaders;
firstChunkMs: number;
hasHeadInFirstChunk: boolean;
totalDurationMs: number;
chunkCount: number;
}
export async function verifyStreamingUrl(targetUrl: string): Promise<StreamVerificationResult> {
return new Promise((resolve, reject) => {
const startTime = performance.now();
let firstChunkTime = 0;
let chunkCount = 0;
let firstChunkPayload = '';
let isFirst = true;
const req = http.get(targetUrl, (res) => {
res.on('data', (chunk: Buffer) => {
chunkCount++;
if (isFirst) {
firstChunkTime = performance.now() - startTime;
firstChunkPayload = chunk.toString('utf-8');
isFirst = false;
}
});
res.on('end', () => {
const totalDurationMs = performance.now() - startTime;
const hasHeadInFirstChunk =
firstChunkPayload.includes('<head>') &&
firstChunkPayload.includes('<title>') &&
firstChunkPayload.includes('rel="canonical"');
resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers,
firstChunkMs: Math.round(firstChunkTime),
hasHeadInFirstChunk,
totalDurationMs: Math.round(totalDurationMs),
chunkCount,
});
});
});
req.on('error', (err: unknown) => {
const message = err instanceof Error ? err.message : 'Unknown network failure';
reject(new Error(`Stream check failed: ${message}`));
});
});
}Почему вызов notFound() внутри асинхронного компонента, обернутого в границу Suspense, приводит к критической ошибке Soft 404 для поискового краулера?
Разработчик проверяет существование сущности в базе данных внутри дочернего асинхронного компонента, обернутого в <Suspense>. Поскольку корневой макет и первый чанк уже отданы клиенту с кодом 200 OK, вызов notFound() не может изменить HTTP-статус. Сервер аварийно закрывает стрим или рендерит клиентский fallback, порождая критическую ошибку Soft 404.
// ❌ АНТИПАТТЕРН: Soft 404 гарантирован!
export default function Page({ params }: { params: { id: string } }) {
return (
<Suspense fallback={<Skeleton />}>
{/* Заголовки 200 OK уже ушли в сеть! */}
<AsyncProductDetails id={params.id} />
</Suspense>
);
}
async function AsyncProductDetails({ id }: { id: string }) {
const item = await db.find(id);
if (!item) notFound(); // ОШИБКА: HTTP-статус 200 уже зафиксирован!
return <div>{item.title}</div>;
}Решение: Всегда выполняйте валидацию существования ресурса на корневом уровне страницы (page.tsx) до отрисовки первой границы <Suspense>.
Если обратный прокси-сервер (Nginx, HAProxy) сконфигурирован с включенной буферизацией ответов (proxy_buffering on; в Nginx) или на CDN активны агрессивные парсеры HTML (Cloudflare Auto Minify, Rocket Loader), прокси перехватывает первый чанк и ждет полного завершения соединения. В результате TTFB взлетает до времени ответа самого медленного динамического компонента, а все преимущества Streaming SSR и PPR полностью нивелируются.
Решение: В конфигурации Nginx обязательно пропишите директиву отключения буферизации для Node.js бэкенда:
proxy_buffering off;
proxy_set_header X-Accel-Buffering no;Попытка мутировать метаданные страницы через сторонние библиотеки или инъекцию тегов title и meta в клиентских компонентах внутри тела страницы (body). Поисковый краулер на первой фазе индексации парсит только Initial Chunk и блок head. Метаданные, внедренные динамическим скриптом в конце тела страницы, игнорируются или приводят к ошибкам дублирования тегов.
Решение: Использовать исключительно встроенный API метаданных Next.js 16: объект metadata или функцию generateMetadata(), гарантирующие размещение тегов в первом чанке head.
Размещение тяжелых некэшируемых запросов (await fetch(...) без кэширования или прямое обращение к медленной БД) внутри app/layout.tsx. Корневой макет блокирует отправку начального чанка (Initial Chunk Flush) для абсолютно всех страниц приложения. Даже если целевая страница полностью статична, ее TTFB деградирует до скорости исполнения корневого макета.
Решение: В корневых и промежуточных макетах размещайте только легкие статические оболочки, а все динамические данные оборачивайте в <Suspense> или спускайте на уровень страниц (page.tsx).