Разработка SSR (Server-Side Rendering) для веб-приложения
Server-Side Rendering — это когда сервер генерирует полный HTML перед отправкой браузеру. Пользователь видит контент сразу, без ожидания загрузки JS-бандла и выполнения клиентского рендера. Для SEO, для первой загрузки на медленных соединениях, для доступности — SSR даёт измеримые преимущества перед чисто клиентским приложением.
Но SSR — это не кнопка. Это архитектурное решение с компромиссами, которые нужно понимать и правильно балансировать.
Модели SSR и их применение
Полный SSR (традиционный): каждый запрос → серверный рендер → полный HTML-ответ. Нет состояния на клиенте до гидратации.
SSR с гидратацией: сервер рендерит HTML, клиент загружает тот же JS-код и «оживляет» статичный HTML — прикрепляет события, восстанавливает состояние.
Streaming SSR: HTML отправляется браузеру по мере готовности частей страницы, не ожидая полного рендера. Первые байты достигают браузера быстрее.
SSR с кэшированием: результат рендера кэшируется на заданное время — сервер не рендерит одно и то же при каждом запросе.
Реализация на Next.js (React)
Next.js — наиболее зрелый SSR-фреймворк для React. App Router (Next.js 13+) предлагает React Server Components как модель по умолчанию:
// app/products/[id]/page.tsx — серверный компонент
import { notFound } from 'next/navigation';
interface Props {
params: { id: string };
}
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60 }, // ISR: кэш 60 секунд
});
if (!res.ok) return null;
return res.json() as Promise<Product>;
}
export default async function ProductPage({ params }: Props) {
const product = await getProduct(params.id);
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductActions productId={product.id} /> {/* Клиентский компонент */}
</article>
);
}
// Метаданные для SEO — тоже серверные
export async function generateMetadata({ params }: Props) {
const product = await getProduct(params.id);
return {
title: product?.name ?? 'Продукт не найден',
description: product?.description,
openGraph: { images: [product?.image] },
};
}
// app/products/[id]/product-actions.tsx — клиентский компонент
'use client';
import { useState } from 'react';
export function ProductActions({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
async function addToCart() {
setLoading(true);
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId }),
});
setLoading(false);
}
return (
<button onClick={addToCart} disabled={loading}>
{loading ? 'Добавляем...' : 'В корзину'}
</button>
);
}
Реализация на Nuxt 3 (Vue)
<!-- pages/products/[id].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: product, error } = await useFetch<Product>(
`/api/products/${route.params.id}`,
{ key: `product-${route.params.id}` }
);
if (error.value || !product.value) {
throw createError({ statusCode: 404 });
}
useSeoMeta({
title: product.value.name,
description: product.value.description,
ogImage: product.value.image,
});
</script>
<template>
<article>
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
<ClientOnly>
<ProductActions :product-id="product.id" />
</ClientOnly>
</article>
</template>
Гидратация: подводные камни
Hydration mismatch — самая частая проблема SSR. Если серверный и клиентский HTML различаются, React/Vue выбрасывают предупреждение или полностью перерендеривают компонент:
// Проблема: new Date() даёт разный результат на сервере и клиенте
function LastUpdated() {
return <span>{new Date().toLocaleString()}</span>; // Mismatch!
}
// Решение: suppressHydrationWarning для динамических значений
function LastUpdated({ timestamp }: { timestamp: string }) {
return (
<time suppressHydrationWarning dateTime={timestamp}>
{new Date(timestamp).toLocaleString()}
</time>
);
}
Для браузер-зависимого кода (localStorage, window.innerWidth) — отложенный рендер:
'use client';
import { useState, useEffect } from 'react';
function ThemeToggle() {
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
setTheme(localStorage.getItem('theme') ?? 'light');
}, []);
if (!theme) return null; // Не рендерим до монтирования
return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
Кэширование на уровне сервера
// lib/cache.ts — Redis-кэш для тяжёлых запросов
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export async function cachedFetch<T>(
key: string,
fetcher: () => Promise<T>,
ttl = 300 // секунды
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await fetcher();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
}
// Использование в серверном компоненте
const categories = await cachedFetch(
'categories:all',
() => db.category.findMany({ orderBy: { name: 'asc' } }),
3600
);
Метрики и мониторинг
SSR вводит серверную латентность в цепочку рендера. Важно отслеживать:
| Метрика | Целевое значение |
|---|---|
| TTFB (Time to First Byte) | < 200ms |
| LCP (Largest Contentful Paint) | < 2.5s |
| FCP (First Contentful Paint) | < 1.8s |
| Серверный рендер p95 | < 500ms |
Инструменты: Vercel Analytics, Sentry Performance, OpenTelemetry + Jaeger для трейсинга серверных запросов.
Деплой и инфраструктура
- Vercel / Netlify — автоматический деплой Next.js/Nuxt, Edge Runtime для низкой латентности
- Node.js на VPS — через PM2 или Docker, нужен реверс-прокси (Nginx)
- Cloudflare Workers — только edge-совместимый код (нет Node.js API)
- AWS App Runner / ECS — для enterprise с требованиями к изоляции
Сроки реализации
- Неделя 1–2: выбор стека (Next.js/Nuxt/SvelteKit), настройка роутинга, серверные компоненты для статичных страниц
- Неделя 3: динамические маршруты, серверные обработчики данных, SEO-метаданные
- Неделя 4: клиентские компоненты для интерактивных частей, решение hydration mismatches
- Неделя 5: кэширование (Redis/in-memory), оптимизация TTFB
- Неделя 6: нагрузочное тестирование, настройка мониторинга, деплой







