Класичний серверний рендеринг (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 15 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 15 модель 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) |
+-----------------------------------------------------------------------------------------+Ключові сутності потокової архітектури:
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-статусів без браузера застосовується скрипт на базі нативного мережевого сокета:
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}`));
});
});
}Розробник перевіряє існування сутності в базі даних всередині дочірнього асинхронного компонента, обгорнутого в <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 15: об'єкт metadata або функцію generateMetadata(), що гарантують розміщення тегів у першому чанку head.
Розміщення важких некешованих запитів (await fetch(...) без кешування або пряме звернення до повільної БД) всередині app/layout.tsx. Кореневий макет блокує відправку початкового чанка (Initial Chunk Flush) для абсолютно всіх сторінок застосунку. Навіть якщо цільова сторінка повністю статична, її TTFB погіршується до швидкості виконання кореневого макета.
Вирішення: У кореневих макетах розміщуйте лише легкі статичні оболонки, а всі динамічні дані огортайте в <Suspense> або спускайте на рівень окремих сторінок (page.tsx).
Googlebot надсилає GET-запит із підтримкою HTTP/1.1 (Chunked) або HTTP/2. Сервер валідує маршрут і метадані.
Сервер скидає статус 200 OK та повний блок head із канонічними посиланнями, Schema.org та скелетонами. TTFB зафіксовано.
Серверний рантайм паралельно виконує проміси всередині меж Suspense. З'єднання утримується відкритим.
У міру готовності дані надсилаються у вигляді прихованих блоків з інлайн-інструкціями підміни розмітки в DOM.
Сервер надсилає термінальний сигнал (0 байтів або END_STREAM). Бот закриває сесію.