Перейти к содержимому

Стратегии рендеринга: SSG, SSR, ISR и CSR

Каждый фреймворк серверного рендеринга отвечает на один вопрос: в какой момент времени HTML страницы готовится к показу пользователю? В Next.js ответ зависит от комбинации трёх факторов: что написано в коде (fetch с no-store или с revalidate), как устроен маршрут (статический или динамический) и где кэшируется результат (на сервере, на CDN или в браузере).

В краткой версии учебника ты видел таблицу SSG/SSR/ISR/Streaming. Здесь мы идём глубже: разбираем, как Next.js принимает решение о стратегии, как устроены кэши маршрутов и почему «пользователь видит старые данные» — это почти всегда вопрос неправильно выбранной стратегии, а не баг фреймворка. Точка входа в официальную документацию — Static and Dynamic Rendering.

Представь реальный продакшен-сценарий. У тебя маркетплейс: главная страница с баннерами, каталог с фильтрами и личный кабинет с балансом. Если отрендерить всё через SSR — каждый запрос будет бить в базу, а CDN не сможет закэшировать ничего. Если всё через SSG — баланс пользователя будет показывать чужие данные из общего кэша. Правильный ответ: три разные стратегии для трёх типов страниц, и Next.js позволяет смешивать их в рамках одного приложения, вплоть до одного маршрута.

HTML генерируется один раз на этапе next build и раздаётся как статика с CDN. Самый быстрый вариант: TTFB измеряется в миллисекундах, нагрузка на сервер — ноль.

// app/blog/[slug]/page.tsx — статическая страница
export async function generateStaticParams() {
// на сборке Next.js вызовет эту функцию и сгенерирует
// по одной HTML-странице на каждый slug
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug); // fetch кэшируется при сборке
return <article>{/* ... */}</article>;
}

Полный API функции — в справочнике: generateStaticParams.

Когда использовать: блоги, документация, лендинги, страницы условий — всё, что одинаково для всех пользователей и меняется редко.

Ограничение: данные на момент сборки. Если пост опубликовался после деплоя — страница его не знает, пока не будет новый билд или ISR.

HTML собирается заново при каждом запросе. Дороже, зато данные всегда свежие и можно персонализировать под конкретного пользователя.

// app/dashboard/page.tsx — серверный рендеринг на каждый запрос
export const dynamic = 'force-dynamic'; // запрещаем любое кэширование маршрута
export default async function Dashboard() {
const session = await getSession(); // cookies() делает маршрут динамическим
const orders = await getOrders(session.userId); // свежие данные на каждый запрос
return <DashboardView user={session.user} orders={orders} />;
}

Когда использовать: личный кабинет, корзина, админки — всё, что зависит от сессии и должно быть актуальным прямо сейчас.

ISR — инкрементальная статическая регенерация

Заголовок раздела «ISR — инкрементальная статическая регенерация»

Гибрид: HTML генерируется статически, но Next.js в фоне пересобирает его с заданной периодичностью. Пользователь всегда получает быстрый ответ из кэша; свежесть данных — с задержкой до одного интервала ревалидации.

// app/products/page.tsx — ISR с ревалидацией раз в 5 минут
export const revalidate = 300; // секунд
export default async function ProductsPage() {
const products = await getProducts(); // кэш сбрасывается каждые 5 минут
return <ProductGrid products={products} />;
}

Когда использовать: каталоги, ленты новостей, SEO-страницы категорий — контент, который меняется регулярно, но допустима задержка в минуты.

Механика под капотом: первый запрос после истечения revalidate получает старый HTML из кэша, а Next.js параллельно запускает регенерацию. Следующие запросы — уже новый HTML. Это называется stale-while-revalidate (см. гайд по ISR), и это ключевое свойство ISR: пользователь никогда не ждёт пересборки.

HTML — пустой каркас, весь рендеринг происходит в браузере. В App Router это не отдельная «стратегия страницы», а состояние отдельных клиентских компонентов внутри серверной страницы. Чистый CSR нужен редко: админские интерфейсы с тяжёлыми таблицами, страницы за логином, где SEO не важно, а интерактивность — всё.

Next.js анализирует маршрут на этапе сборки и при выполнении. Маршрут становится динамическим (SSR), если внутри дерева серверных компонентов встречается хотя бы одно из:

Функция / API Почему делает маршрут динамическим
cookies(), headers() Зависит от конкретного запроса
searchParams в page Параметры запроса меняют результат
fetch(..., { cache: 'no-store' }) Данные должны быть свежими
Не-cached вызов БД напрямую Next.js не может доказать воспроизводимость
export const dynamic = 'force-dynamic' Явный запрет статики
Неизвестный динамический сегмент без generateStaticParams Слуг не было на сборке — рендерим по запросу

Обратная операция — export const dynamic = 'force-static' — принудительно делает маршрут статическим (применимо не всегда: если код реально читает cookies, сборка упадёт).

// app/catalog/page.tsx — принудительная статика, даже если что-то подозревает динамику
export const dynamic = 'force-static';
export const revalidate = 3600;

Это место, где путаются чаще всего. У Next.js два разных кэша на уровне маршрутов, и они живут в разных местах.

Серверный кэш готового RSC payload и HTML для статических и ISR-маршрутов (подробная схема — в официальном гайде по кэшированию). Лежит на сервере (в памяти standalone-сервера или на CDN при кэширующем прокси). Ключ — путь маршрута. Сбрасывается через revalidatePath, revalidateTag или истечение revalidate.

Браузерный кэш RSC payload для prefetch и soft-навигации. Когда ты кликаешь по <Link> внутри приложения, Next.js не запрашивает HTML, а запрашивает RSC payload и кладёт его в память клиента. Ключи особенности:

  • автоматический prefetch: Next.js префетчит видимые ссылки (viewport в next.config);
  • кэш живёт в sessionStorage-подобном хранилище на время сессии;
  • статические маршруты кэшируются на 5 минут, динамические — на 30 секунд;
  • он не сбрасывается при revalidatePathrouter.refresh() сбрасывает принудительно.
'use client';
import { useRouter } from 'next/navigation';
export function RefreshButton() {
const router = useRouter();
// после мутации вне Server Action — забираем свежий RSC payload
return <button onClick={() => router.refresh()}>Обновить данные</button>;
}
Стратегия TTFB Персонализация Свежесть данных Нагрузка на сервер
SSG ~0 мс (CDN) Нет На момент сборки Минимум
ISR ~0 мс (CDN) Нет С задержкой до revalidate Низкая (регенерация в фоне)
SSR 50–500 мс Полная Мгновенная Высокая, на каждый запрос
Streaming SSR ~0 мс за shell Полная Мгновенная Высокая, но отдача постепенная
CSR ~0 мс за shell + ожидание JS Полная После загрузки JS Низкая на сервере, цена — у пользователя

Правило выбора: бери самую «статичную» стратегию, которую позволяет бизнес-требование. Личный кабинет не может быть SSG — ок. Но страница «О компании» не должна быть SSR «по привычке»: это прямой ущерб TTFB и нагрузке на сервер, а значит — и деньгам на инфраструктуре.

Гибриды: статика, динамика и островки в одной странице

Заголовок раздела «Гибриды: статика, динамика и островки в одной странице»

Стратегии не обязаны жить на уровне целых страниц — они комбинируются внутри одного маршрута. Три рабочих паттерна:

Статический shell + динамические островки. Страница собирается статически (SSG/ISR), а персональные данные подгружаются клиентским компонентом или через серверный компонент с no-store внутри Suspense-границы:

// app/product/[id]/page.tsx — ISR-каркас
export const revalidate = 300;
export default async function ProductPage({ params }: Props) {
const { id } = await params;
const product = await getProduct(id); // ISR-данные
return (
<main>
<ProductInfo product={product} /> {/* статика */}
<Suspense fallback={<PriceSkeleton />}>
<PersonalPrice productId={id} /> {/* no-store: цена для конкретного юзера */}
</Suspense>
</main>
);
}

Динамический layout + статичные дети. Наоборот: каркас страницы зависит от сессии (динамический layout), а вложенные сегменты — статика. Работает: динамичность родителя не «заражает» детей, если те не используют динамические API.

Streaming как мост. Тяжёлый динамический блок не должен блокировать статический: оборачивай в <Suspense> (подробности — в главе про streaming).

  1. ISR вместо точечной ревалидации. revalidate = 60 на всём каталоге опрашивает данные зря. Лучше длинный интервал (или false) + revalidateTag('products') из Server Action при реальном изменении. Плохо: таймер «на всякий случай». Хорошо: событийная инвалидация.

  2. Смешение кэшей в голове. «Обновил данные, а страница старая» — проверь, что именно старое: Full Route Cache (сервер, решается revalidatePath), Router Cache (браузер, решается router.refresh()) или Data Cache (отдельный слой, решается revalidateTag). Три разных кэша — три разных решения.

  3. Динамический маршрут «случайно». Вызвал headers() в layout корня — вся страница стала SSR, и SSG-страницы перестали собираться статикой. Проверяй вывод next build: колонка ○ (static) / ƒ (dynamic) показывает реальное решение фреймворка.

  4. generateStaticParams без fallback-стратегии. Если slug появился после сборки, при dynamicParams = false будет 404, по умолчанию — SSR для нового slug (это хорошо). Понимай разницу между dynamicParams = false (только то, что собрано) и дефолтным поведением (ISR-подобно для новых slug’ов).

  5. SSR «по умолчанию» из страха устаревших данных. Команда ставит force-dynamic всему, «чтобы не думать». Итог: база падает от нагрузки, CDN бесполезен, TTFB вырос вдесятеро. Думать о стратегии — обязанность инженера, а не опциональная оптимизация.

  6. Персонализация на SSG-страницах через клиентский фетч. Страница статическая, а имя пользователя догружается в useEffect — мигание контента, layout shift, лишний запрос. Правильно: статический shell + клиентский островок с useUser(), или вообще динамический сегмент лейаута.

  1. Чем Full Route Cache отличается от Router Cache? Full Route Cache — серверный кэш HTML/RSC payload для статических и ISR маршрутов, сбрасывается ревалидацией. Router Cache — клиентский кэш RSC payload для soft-навигации, живёт в браузере и не сбрасывается серверной ревалидацией.
  2. Как Next.js решает, рендерить ли маршрут статически? Анализирует код на наличие динамических API: cookies(), headers(), searchParams, no-store fetch. Если ни одного — маршрут статический; решение видно в выводе next build.
  3. Объясни stale-while-revalidate в ISR. Первый запрос после истечения интервала получает старый HTML из кэша, а регенерация идёт в фоне; следующие запросы получают свежий. Пользователь никогда не ждёт сборки страницы.
  4. Нужна страница с балансом пользователя. Какая стратегия и почему? SSR (или динамический рендер): данные персональные и должны быть актуальными. SSG невозможен из-за персонализации, ISR — рискованно из-за денег, CSR — плохой UX и лишний запрос.
  5. Пользователь жалуется на устаревший контент после публикации. Что проверяешь? Три кэша: Data Cache (теги/время fetch), Full Route Cache (путь), Router Cache (браузер). Дальше — какой именно слой устарел и какой способ ревалидации применялся.
  6. Что такое generateStaticParams и когда без него обходятся? Функция, возвращающая список параметров динамического сегмента для сборки статических страниц. Без неё страницы рендерятся по запросу (ISR-подобно). Нужна, когда полный список slug’ов известен заранее.
  7. Может ли один маршрут быть частично SSG и частично SSR? Да: статический лейаут + динамические вложенные сегменты, либо статическая страница с динамическими островками, либо Streaming с статическим shell и динамическими Suspense-блоками.
  1. В pet-проекте раздели три маршрута: /about (SSG), /products (ISR, 300 секунд), /account (SSR через чтение cookies). Прогони next build и зафиксируй вывод: какие маршруты помечены ○, какие ƒ.
  2. Настрой generateStaticParams для /blog/[slug] с пятью постами. Открой страницу нового поста, которого не было на сборке, и объясни поведение. Затем добавь export const dynamicParams = false и сравни.
  3. Сделай страницу /time: серверный компонент выводит текущее время, cache: 'no-store'. Замерь TTFB через curl -w "@curl-format.txt". Повтори с SSG-версией и сравни цифры.
  4. Смоделируй проблему Router Cache: создай страницу со значением из Data Cache, обнови значение через Server Action с revalidatePath, вернись на страницу soft-навигацией — и прямой ссылкой. Задокументируй разницу и добавь router.refresh().

Критерий результата: ты можешь по выводу next build предсказать, как закэшируется каждый маршрут, и объяснить, какие три кэша участвуют в отдаче страницы.