Привет, Хабр!
Сегодня я хочу поделиться с вами руководством, как реализовать Static Site Generation (SSG) в React без использования сторонних фреймворков, таких как Next.js, TanStack Start, React Router as Framework и им подобных. Сразу оговорюсь: я не считаю их чем-то «плохим» и не агитирую против их применения. Всё гораздо проще: иногда по тем или иным причинам нет возможности использовать эти инструменты, или самостоятельная реализация оказывается предпочтительнее из-за количества изменений в кодовой базе.
Краткое предисловие
Опытные разработчики могут сразу перейти к реализации (раздел «Практика»). Остальным рекомендую ознакомиться с базовыми принципами.
Static Site Generation (SSG) — это метод создания веб-сайтов, при котором страницы генерируются на этапе сборки, а не на сервере или в браузере пользователя. Часто этот подход ещё называют pre-rendering. Если сравнить его с серверным (SSR) или клиентским (CSR), то можно сказать, что мы берём лучшее из каждого подхода, а именно:
высокая скорость загрузки;
готовая разметка для SEO и пользователей;
дешевизна хостинга;
надёжность и безопасность.
На иллюстрации ниже изображён пошаговый процесс рендеринга каждой стратегии.

Думаю, вы согласитесь, что визуально процесс SSG выглядит значительно проще, чем SSR и CSR. Ведь вам не нужно для каждого запроса:
Запрашивать данные из API и обрабатывать ошибки — данные вы уже получили во время сборки, а так как собрались и выкатились, значит, ошибок нет.
Рендерить приложение с нуля и перерисовывать его — разметка уже сгенерирована, остаётся только гидратация.
Генерировать HTML — он уже был создан на этапе сборки.
С точки зрения инфраструктуры, вам не нужно:
Разворачивать Node.js-сервер для рендеринга React-приложения, оплачивая вычислительные ресурсы (процессор и память).
Беспокоиться о состоянии Node.js из-за большого количества запросов или утечки памяти, которые могут привести к простою приложения или увеличению задержек.
И, конечно, нельзя забывать о кибербезопасности. В SSR-проектах значительно повышается риск XSS-уязвимостей.
Как видите, все эти этапы не только требуют времени, но и создают дополнительные точки отказа.
Теперь, когда мы разобрались с преимуществами SSG, стоит упомянуть случаи, когда его использование нецелесообразно или невозможно:
авторизованные зоны с ролевой моделью;
высокодинамичный контент;
данные реального времени, когда разметка должна обновляться мгновенно.
Типичные примеры таких сценариев:
административные панели и личные кабинеты;
крупные интернет-магазины с обширным ассортиментом;
новостные порталы с постоянно обновляемой лентой.
Практика
Для начала вам потребуется готовое приложение. Рекомендую использовать проект с уже настроенной сборкой на Webpack или Vite. В дальнейших примерах я буду использовать Vite — этот инструмент я также рекомендую и вам.
Если ваше приложение содержит несколько страниц, то необходимо использовать SSR‑совместимый роутер, такой как React Router. А вот TanStack Router (без использования TanStack Start) не поддерживает SSR и может вызывать проблемы с гидратацией.
Для работы с API‑данными при генерации страниц рекомендуется использовать TanStack Query или Redux Toolkit.
Если у вас нет подходящего проекта, то можете воспользоваться заранее подготовленным шаблоном.
Чтобы сосредоточиться на ключевых аспектах SSG, я не буду разбирать создание CSR-приложения с нуля и углубляться в базовые настройки сборки. Основное внимание уделю модификациям для реализации статической генерации.
Настройка package.json
Рассмотрим базовую конфигурацию файла package.json
и скриптов сборки. В исходном виде он имеет следующую структуру:
"scripts": {
"dev": "exec vite",
"build": "vite build"
}
Добавим несколько новых команд в конфигурацию:
очистка директории сборки для предотвращения побочных эффектов;
сборка серверного бандла;
запуск скрипта генерации HTML-страниц.
А также модифицируем процесс production-сборки таким образом, чтобы он выполнял последовательно следующие команды:
очистку директории сборки;
сборку клиентского и серверного кода;
запуск скрипта генерации HTML-страниц.
"scripts": {
"dev": "exec vite",
"clean": "rm -rf dist/",
"build:client": "vite build",
"build:server": "vite build --ssr src/entry-server.tsx",
"ssg": "node dist/server/entry-server.js",
"build": "run-s clean build:* ssg"
}
vite.config.ts
Типичная конфигурация Vite для CSR-приложений может включать в себя следующие базовые настройки:
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths(), tailwindcss(), react()]
});
Необходимо добавить в конфигурацию обработку серверного кода. В предыдущей главе мы уже настроили команду сборки серверного кода с флагом --ssr
, что позволяет использовать переменную isSsrBuild
. Для оптимальной конфигурации серверной сборки рекомендую настроить следующие параметры:
outDir
: укажите отдельную директорию, отличную от клиентской сборки. Это нужно для изоляции от клиентских статических файлов;copyPublicDir
: отключите копирование папки с публичными файлами, так как она уже копируется в рамках сборки клиентского кода.
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig(({ isSsrBuild }) => {
if (isSsrBuild) {
return {
build: {
outDir: 'dist/server',
copyPublicDir: false
},
plugins: [tsconfigPaths()]
};
} else {
return {
plugins: [tsconfigPaths(), tailwindcss(), react()]
};
}
});
index.html
Для корректной работы SSG необходимо модифицировать исходный HTML-файл, добавив специальные якорные точки:
head‑outlet
— место для вставки: мета‑тегов, предзагрузки ресурсов, других служебных тегов<head>
;ssg‑outlet
— якорь для вставки отрендеренного React‑приложения;rqs‑outlet
(опционально) — требуется, если приложение работает с API.
Пример разметки:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<link rel="preconnect" href="https://cdn.dummyjson.com" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap"
rel="stylesheet"
/>
<!--head-outlet-->
</head>
<body class="font-roboto antialiased">
<div id="root"><!--ssg-outlet--></div>
<script>
<!--rqs-outlet-->
</script>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
Обратите внимание, что точкой входа в приложение указан файл entry-client.tsx
. Его мы будем использовать в дальнейшей работе с клиентским кодом.
entry-client.tsx
Перейдём к конфигурации приложения и его рендерингу.
import { QueryClientProvider, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { createBrowserRouter } from 'react-router';
import createRoutes from '@/createRoutes.tsx';
import '@/index.css';
declare global {
interface Window {
__REACT_QUERY_STATE__: string;
}
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity
}
}
});
const routes = createRoutes(queryClient);
const router = createBrowserRouter(routes);
const dehydratedState = window.__REACT_QUERY_STATE__;
const container = document.getElementById('root');
if (container) {
const app = (
<StrictMode>
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
<RouterProvider router={router} />
<ReactQueryDevtools />
</HydrationBoundary>
</QueryClientProvider>
</StrictMode>
);
if (container.children.length) {
hydrateRoot(container, app);
} else {
const root = createRoot(container);
root.render(app);
}
} else {
throw new Error('Container not found, must be HTMLElement');
}
Ключевые аспекты:
Конфигруация
QueryClient
параметраstaleTime: Infinity
позволяет избежать повторных запросов к API — это одно из основных преимуществ SSG перед другими стратегиями рендеринга, о котором я писал ранее.window.__REACT_QUERY_STATE__
в предыдущем пункте мы добавляли якорные точки — вот эта как раз одна из них. Необходима для инициализации полученных ранее данных при сборке статической страницы. Чуть позже я покажу, как из якорной точки это становится объектом с данными.Чтобы приложение могло работать как в SSG, так и в CSR‑режиме, мы проверяем наличие готовой разметки в контейнере. При обнаружении существующей разметки выполняется гидратация, в противном случае инициируется полный рендеринг приложения с нуля.
Остальная часть кода представляет собой стандартное использование API соответствующих инструментов без дополнительных модификаций.
createRoutes.tsx
Как вы могли заметить ранее, в нашем коде применяется функция createRoutes
. В этой главе мы разберём её логику и принцип работы.
import type { RouteObject } from 'react-router';
import type { QueryClient } from '@tanstack/react-query';
import Layout from '@/components/layout';
import MainPage from '@/pages/main';
import ProductPage from '@/pages/product';
import { productsQuery, productQuery } from '@/queries.ts';
const createRoutes = (queryClient: QueryClient): RouteObject[] => {
return [
{
path: '/',
Component: Layout,
children: [
{
index: true,
Component: MainPage,
loader: async () => {
if (import.meta.env.SSR) {
await queryClient.prefetchQuery(productsQuery());
}
}
},
{
path: '/products/:id',
Component: ProductPage,
loader: async ({ params }) => {
if (import.meta.env.SSR) {
await queryClient.prefetchQuery(productQuery(params.id));
}
}
}
]
}
];
};
export default createRoutes;
Используя функциональность, предоставляемую React Router, мы настраиваем маршрутизацию нашего приложения. При этом мы не используем динамические импорты, так как при их применении компоненты выносятся в отдельные чанки. Зачастую, если мы говорим о небольших лендингах, это не имеет особого смысла, поскольку соединение с сервером будет устанавливаться гораздо дольше, чем загрузятся условные 5 килобайтов. Но есть ещё одна проблема: метод серверного рендеринга renderToString
не умеет работать с динамическими импортами, поэтому для такого рода сборки, как правило, используют библиотеку loadable components. Однако мы не будем применять её — чуть позже я покажу, как избежать её использования.
Для предварительной загрузки данных мы используем стандартный API React Router. Переменная окружения import.meta.env.SSR
задаётся флагом --ssr
, который ранее был установлен в package.json
. Чтобы запросы к API выполнялись только на этапе сборки, мы обернули соответствующий код в проверку условия import.meta.env.SSR
.
Обратите внимание на компонент Layout
в этом файле. В нём реализован небольшой, но важный технический приём, который нам необходимо применить. Рассмотрим этот компонент.
components/layout/index.tsx
import { Outlet } from 'react-router';
import { HTML_DIVIDER } from '@/constants.ts';
const Layout = () => (
<>
{import.meta.env.SSR && HTML_DIVIDER}
<header className="p-20 text-center">THIS IS TOPLINE</header>
<Outlet />
<footer className="p-20 text-center">THIS IS FOOTER</footer>
</>
);
export default Layout;
Константа HTML_DIVIDER
содержит текстовое значение !--pre--
. Её необходимо разместить в самом начале JXS-разметки вашего приложения. Чтобы понять назначение этого элемента, давайте проанализируем JSX-разметку одного из компонентов.
function MainPage() {
const { data, isPending, isError } = useQuery(productsQuery());
if (isPending) {
return <div className="animate-pulse p-50 font-bold text-pink-500">Loading...</div>;
}
if (isError) {
return <div className="p-50 font-bold text-red-400">Something went wrong...</div>;
}
return (
<>
<title>Top products</title>
<meta name="description" content={`Count of top products ${data.length}`} />
<div className="px-20">
{data?.map(({ title, brand, price, id }) => (
<NavLink key={id} to={`/products/${id}`} className="cursor-pointer block hover:opacity-70">
<span>{title}</span>
<span className="ml-14">{brand}</span>
<span className="ml-14">{price}</span>
</NavLink>
))}
</div>
</>
);
}
Обратите внимание на теги <title>
и <meta>
— после обработки React вынесет их в верхнюю часть сгенерированной разметки. Однако этого недостаточно, чтобы они попали в <head>
документа. Здесь нам как раз и пригодится HTML_DIVIDER
.
Схематично процесс выглядит так:

Вся эта логика реализована в файле entry-server.tsx
, куда мы скоро перейдём.
Существует и альтернативный подход — полностью генерировать HTML с помощью React. Однако в этом случае вам потребуется дополнительно настроить Vite для корректной работы, включая поддержку Hot Module Replacement (HMR), чтобы избежать возможных сбоев в процессе разработки. На мой взгляд, предложенный мной способ в данном случае надёжнее.
Если перечисленные подходы вам не подходят или вы работаете с React 18, обратите внимание на библиотеку react-helmet-async — она предлагает удобный способ управления метаданными.
entry-server.tsx
Мы подошли к заключительному этапу работы с файлом, который будет генерировать готовые HTML‑страницы с контентом. В основном, здесь используется стандартный API библиотек TanStack Query и React Router — их базовые возможности хорошо описаны в официальной документации. В этом руководстве я сосредоточусь исключительно на наиболее интересных и неочевидных аспектах реализации. Давайте начнём с детального рассмотрения кода.
import { QueryClientProvider, dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import fs from 'node:fs';
import path from 'node:path';
import { StrictMode } from 'react';
import { renderToString } from 'react-dom/server';
import serialize from 'serialize-javascript';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router';
import type { StaticHandlerContext } from 'react-router';
import createRoutes from '@/createRoutes.tsx';
import { HTML_DIVIDER } from '@/constants';
const outDir = 'dist';
const routesForSSG = [
{
routePath: '/',
fileName: '/index.html'
},
...[1, 2, 3, 4, 5].map((id) => ({
routePath: `/products/${id}`,
fileName: `/products/${id}/index.html`
}))
];
function render() {
const template = fs.readFileSync(`${dist}/index.html`, 'utf-8');
routesForSSG.forEach(async ({ routePath, fileName }) => {
console.info(`♻️ generating ${fileName}`);
const t0 = performance.now();
const request = new Request(`https://whatever.com${routePath}`);
const queryClient = new QueryClient();
const routes = createRoutes(queryClient);
const { query, dataRoutes } = createStaticHandler(routes);
const context = (await query(request)) as StaticHandlerContext;
const router = createStaticRouter(dataRoutes, context);
const rqs = dehydrate(queryClient);
const htmlString = renderToString(
<StrictMode>
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={rqs}>
<StaticRouterProvider router={router} context={context} />
<ReactQueryDevtools />
</HydrationBoundary>
</QueryClientProvider>
</StrictMode>
);
const [head, body] = htmlString.split(HTML_DIVIDER);
const html = template
.replace('<!--head-outlet-->', head)
.replace('<!--ssg-outlet-->', body)
.replace('<!--rqs-outlet-->', `window.__REACT_QUERY_STATE__ = ${serialize(rqs)};`);
const folder = path.resolve(`${outDir}${fileName.replace('index.html', '')}`);
if (!fs.existsSync(folder)) {
fs.mkdirSync(folder, { recursive: true });
}
fs.writeFileSync(path.resolve(`${outDir}${fileName}`), html);
const t1 = performance.now();
console.info(`✅ ${fileName} processed in ${Math.round(t1 - t0)}ms`);
});
}
render();
Первое, что бросается в глаза, это библиотека serialize‑javascript, которая решает критически важную задачу: обеспечивает безопасную передачу данных при серверном рендеринге. В нашем случае её основное назначение — предотвращение XSS‑атак с помощью автоматического экранирования опасных символов и специальной обработки данных.
Обратите внимание на массив routesForSSG
, именно в него мы добавляем страницы, предназначенные для предварительного рендеринга. В поле fileName
необходимо указывать полный путь к файлу вместе с его именем. Такой подход специально разработан для удобной интеграции с NGINX: благодаря точному соответствию путей, сервер сможет обрабатывать URL без дополнительной модификации исходного запроса.
Если требуется обработать все страницы приложения, то можно автоматически сгенерировать их, проанализировав структуру папок. Для этого достаточно пройтись по всем вложенным директориям на первом уровне внутри папки
pages
и создать страницы на основе этой структуры.
Мы разделяем на две части полученную из renderToString
разметку с помощью HTML_DIVIDER
:
Первый фрагмент содержит мета-теги и другие элементы, которые помещаются в
<head>
страницы.Второй фрагмент содержит разметку приложения и будет помещён в предназначенный для него контейнер.
Подробная схема этого процесса представлена выше.
Перейдём к происхождению данных в window.__REACT_QUERY_STATE__
(о чём я упоминал в главе про entry-client.tsx
):
При переходе по URL срабатывает
loader
(рассмотренный в главе проcreateRoutes.tsx
).queryClient.prefetchQuery
выполняет предзагрузку необходимых данных.Роутер ожидает разрешения промиса от
loader
.dehydrate
создаёт сериализуемое представление кеша запросов.serialize-javascript
обрабатывает данные для безопасной вставки.Результат добавляется в HTML-разметку.
HydrationBoundary
будет использоваться для восстановления этого состояния на клиенте.
Завершающий этап: осталось выполнить команду npm run build
— и ваше приложение готово к развёртыванию!
Весь код из примера выше можно найти здесь.
Code splitting
Что делать, если ваши страницы весят много (например, более 50 КБ) или используют тяжёлые зависимости? В таком случае стоит рассмотреть разделение кода на чанки, которые будут загружаться динамически — только когда пользователь переходит на соответствующие страницы.
Давайте разберём необходимые изменения в коде для реализации этого подхода. Кроме того, вы узнаете, как отказаться от loadable components, включая сценарии с серверным рендерингом.
Настройка динамических импортов
Для начала перейдём в файл createRoutes.tsx
. Первым шагом нужно импортировать функцию lazy
из React. Далее потребуется заменить все обычные импорты компонентов страниц на динамические с использованием lazy
. Это позволит разделить код на чанки, которые будут загружаться по мере необходимости. Пример:
import type { RouteObject } from 'react-router';
import type { QueryClient } from '@tanstack/react-query';
import { lazy } from 'react';
import Layout from '@/components/layout';
import { productsQuery, productQuery } from '@/queries.ts';
const createRoutes = (queryClient: QueryClient): RouteObject[] => {
return [
{
path: '/',
Component: Layout,
children: [
{
index: true,
Component: lazy(() => import('@/pages/main')),
loader: async () => {
if (import.meta.env.SSR) {
await queryClient.prefetchQuery(productsQuery());
}
}
},
{
path: '/products/:id',
Component: lazy(() => import('@/pages/product')),
loader: async ({ params }) => {
if (import.meta.env.SSR) {
await queryClient.prefetchQuery(productQuery(params.id));
}
}
}
]
}
];
};
export default createRoutes;
Генерация страниц с помощью renderToPipeableStream
Поскольку renderToString
не поддерживает потоковую передачу данных и асинхронное ожидание, мы заменяем его на renderToPipeableStream
. Однако в нашем случае мы не будем использовать потоковую передачу в чистом виде, вместо этого мы:
полностью сохраним поток в памяти;
выполним те же операции, что и с обычным рендерингом.
import { QueryClientProvider, dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import fs from 'node:fs';
import path from 'node:path';
import { Writable } from 'node:stream';
import { StrictMode } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import serialize from 'serialize-javascript';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router';
import type { StaticHandlerContext } from 'react-router';
import createRoutes from '@/createRoutes.tsx';
import { HTML_DIVIDER } from '@/constants';
const outDir = 'dist';
const routesForSSG = [
{
routePath: '/',
fileName: '/index.html'
},
...[1, 2, 3, 4, 5].map((id) => ({
routePath: `/products/${id}`,
fileName: `/products/${id}/index.html`
}))
];
function render() {
const template = fs.readFileSync(`${dist}/index.html`, 'utf-8');
routesForSSG.forEach(async ({ routePath, fileName }) => {
console.info(`♻️ generating ${fileName}`);
const t0 = performance.now();
const request = new Request(`https://whatever.com${routePath}`);
const queryClient = new QueryClient();
const routes = createRoutes(queryClient);
const { query, dataRoutes } = createStaticHandler(routes);
const context = (await query(request)) as StaticHandlerContext;
const router = createStaticRouter(dataRoutes, context);
const state = dehydrate(queryClient);
const { pipe } = renderToPipeableStream(
<StrictMode>
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={state}>
<StaticRouterProvider router={router} context={context} />
<ReactQueryDevtools />
</HydrationBoundary>
</QueryClientProvider>
</StrictMode>,
{
onAllReady() {
const chunks: string[] = [];
const writeable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
}
});
writeable.on('finish', () => {
const [head, body] = chunks.join('').split(HTML_DIVIDER);
const rqs = serialize(state);
const html = template
.replace('<!--head-outlet-->', head)
.replace('<!--ssg-outlet-->', body)
.replace('<!--rqs-outlet-->', `window.__REACT_QUERY_STATE__ = ${rqs};`);
const folder = path.resolve(`${outDir}${fileName.replace('index.html', '')}`);
if (!fs.existsSync(folder)) {
fs.mkdirSync(folder, { recursive: true });
}
fs.writeFileSync(path.resolve(`${outDir}${fileName}`), html);
const t1 = performance.now();
console.info(`✅ ${fileName} processed in ${Math.round(t1 - t0)}ms`);
});
pipe(writeable);
}
}
);
});
}
render();
Ура. Нам удалось реализовать функциональность без подключения дополнительных зависимостей — в частности, без использования библиотеки loadable components.
Весь код из примера выше можно найти здесь.
Итог
Реализация Static Site Generation (SSG) с помощью React и Vite (webpack тоже подойдет) позволяет создавать быстрые, SEO‑дружественные статические сайты с минимальными накладными расходами.
Как вы могли убедиться, реализация получилась простой и гибкой. И если в будущем у вас возникнут вопросы:
Можно ли добавить SEO и UX оптимизации, не переписывая приложение с CSR на SSR? А сделать SSG не переезжая на какой-нибудь framework?
Вы сможете уверенно ответить: «Да».
Если же у вас уже размещён простой лендинг без динамики на SSR, это тоже повод задуматься о том, чтобы высвободить ресурсы (процессор и память) и ускорить отдачу вашего приложения.
Спасибо за внимание!