Streaming SSR — механизм потоковой серверной отрисовки, при котором HTML передаётся клиенту частями по мере готовности данных. В связке с TanStack Query это позволяет:
Классический SSR работает по принципу:
Streaming SSR меняет модель:
Обычный SSR в React имеет несколько фундаментальных ограничений.
Если один запрос выполняется 2 секунды, весь HTML задерживается.
header -> 5ms
sidebar -> 20ms
profile -> 2s
comments -> 3s
Итог: пользователь ждёт 3 секунды весь HTML.
Компоненты могут зависеть друг от друга:
Page
└── User
└── Posts
└── Comments
Каждый запрос запускается после предыдущего.
До завершения загрузки JS страница остаётся частично неинтерактивной.
Большие объёмы dehydrated state увеличивают размер HTML.
React 18 представил потоковую отрисовку через:
renderToPipeableStream
или в edge/runtime средах:
renderToReadableStream
Главная идея — разбить интерфейс на Suspense-границы.
Пример:
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
Если Posts ещё загружается:
TanStack Query решает несколько задач:
Без TanStack Query потоковый SSR быстро превращается в хаотичный набор запросов и ручной логики.
Streaming SSR наиболее эффективно работает с Suspense.
Для этого используется:
useSuspenseQuery()
или:
useQuery({
suspense: true
})
Пример:
function UserProfile() {
const { data } = useSuspenseQuery({
queryKey: ['user'],
queryFn: fetchUser
})
return <div>{data.name}</div>
}
Если запрос ещё не завершён:
Типичная схема:
Server
├── QueryClient
├── Prefetch critical queries
├── renderToPipeableStream()
├── dehydrate()
└── stream chunks
Client
├── hydrateRoot()
├── HydrationBoundary
├── hydrate(queryCache)
└── interactive UI
На сервере QueryClient создаётся отдельно для каждого запроса.
import { QueryClient } from '@tanstack/react-query'
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000
}
}
})
}
Нельзя переиспользовать один QueryClient между запросами пользователей.
Это приводит к:
Критически важные запросы обычно загружаются заранее.
await queryClient.prefetchQuery({
queryKey: ['user'],
queryFn: fetchUser
})
После этого данные попадают в кеш сервера.
Сервер сериализует кеш:
import { dehydrate } from '@tanstack/react-query'
const dehydratedState = dehydrate(queryClient)
Это состояние передаётся клиенту.
Клиент восстанавливает кеш:
<HydrationBoundary state={dehydratedState}>
<App />
</HydrationBoundary>
После гидратации:
Главный принцип — разделение интерфейса на независимые потоки.
Пример:
<>
<Header />
<Suspense fallback={<ProfileSkeleton />}>
<Profile />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</>
Каждый блок рендерится независимо.
При вызове:
useSuspenseQuery()
TanStack Query:
Это фундаментальный механизм Streaming SSR.
Во время стриминга React формирует HTML чанками.
Упрощённо:
chunk #1:
<header>...</header>
<sidebar>...</sidebar>
chunk #2:
<profile>...</profile>
chunk #3:
<comments>...</comments>
Пользователь начинает видеть страницу почти мгновенно.
Node.js SSR:
import { renderToPipeableStream } from 'react-dom/server'
Пример:
const stream = renderToPipeableStream(
<App />,
{
onShellReady() {
response.statusCode = 200
stream.pipe(response)
}
}
)
onShellReady вызывается, когда готов базовый HTML
shell.
Для edge runtime используется:
renderToReadableStream()
Например:
В App Router стриминг встроен по умолчанию.
Структура:
app/
├── page.tsx
├── loading.tsx
└── layout.tsx
loading.tsx автоматически становится Suspense
fallback.
Часто используется схема:
Server Components
└── HydrationBoundary
└── Client Components
└── useSuspenseQuery()
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { getQueryClient } from './getQueryClient'
export default async function Page() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
)
}
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
export function Posts() {
const { data } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts
})
return (
<ul>
{data.map(post => (
<li key={post.id}>
{post.title}
</li>
))}
</ul>
)
}
Streaming SSR тесно связан с partial hydration.
Гидратируется не вся страница одновременно, а отдельные сегменты.
Преимущества:
Интерфейс отображается постепенно:
1. Header
2. Navigation
3. User profile
4. Feed
5. Comments
6. Recommendations
Пользователь получает usable UI быстрее.
В Next.js:
app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />
}
Пока сервер загружает данные:
Skeleton особенно важен для стриминга.
Плохой fallback:
<Suspense fallback={<div>Loading...</div>}>
Хороший fallback:
<Suspense fallback={<PostsSkeleton />}>
Причины:
Suspense не обрабатывает ошибки.
Нужен Error Boundary:
<ErrorBoundary fallback={<ErrorScreen />}>
<Suspense fallback={<Loader />}>
<Posts />
</Suspense>
</ErrorBoundary>
TanStack Query предоставляет:
<QueryErrorResetBoundary>
Пример:
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onRe set={reset}>
<Suspense fallback={<Loader />}>
<Posts />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
Неправильная SSR-конфигурация приводит к:
Правильная гидратация предотвращает это.
Ключевые условия:
staleTime > 0
и корректный:
dehydrate()
Для SSR почти всегда нужен положительный staleTime.
Плохо:
staleTime: 0
Клиент немедленно перезапросит данные.
Лучше:
staleTime: 60_000
В SSR важно контролировать память.
gcTime: 5 * 60 * 1000
Большие значения:
Типичная ошибка:
const queryClient = new QueryClient()
на уровне модуля.
Правильно:
export function createQueryClient() {
return new QueryClient()
}
для каждого request.
Streaming SSR не требует prefetch всех запросов.
Обычно prefetch делают только для:
Второстепенные данные могут стримиться позже.
Некоторые запросы можно вообще не загружать на сервере.
Например:
- recommendations
- analytics
- notifications
- chat widgets
Такие блоки выгоднее оставить клиентскими.
Хорошая архитектура:
Critical:
├── auth
├── product
├── seo
└── navigation
Non-critical:
├── comments
├── recommendations
├── ads
└── analytics
Streaming SSR поддерживает вложенные границы.
<Suspense fallback={<PageSkeleton />}>
<Page>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</Page>
</Suspense>
Неправильная вложенность создаёт последовательные ожидания.
Плохо:
<User>
<Posts>
<Comments>
Лучше:
<>
<User />
<Posts />
<Comments />
</>
в независимых Suspense boundaries.
TanStack Query автоматически дедуплицирует запросы.
Можно запускать параллельно:
await Promise.all([
queryClient.prefetchQuery(...),
queryClient.prefetchQuery(...),
queryClient.prefetchQuery(...)
])
SSR остаётся SEO-friendly.
Поисковые системы получают:
Streaming не ухудшает индексацию.
Streaming SSR значительно уменьшает TTFB.
Без стриминга:
TTFB = ожидание всех запросов
Со стримингом:
TTFB = готовность shell
В React Server Components:
Типичная стратегия:
RSC:
├── SEO
├── metadata
├── static content
Client Query:
├── live feed
├── realtime updates
├── mutations
└── interactive state
HydrationBoundary может использоваться многократно.
Например:
<HydrationBoundary state={stateA}>
<SectionA />
</HydrationBoundary>
<HydrationBoundary state={stateB}>
<SectionB />
</HydrationBoundary>
Это позволяет стримить части кеша независимо.
Большой dehydrated state увеличивает HTML.
Оптимизация:
dehydrate(queryClient, {
shouldDehydrateQuery: query =>
query.meta?.ssr === true
})
Не стоит сериализовать:
Infinite queries могут создавать огромный dehydrated payload.
Проблема:
20 pages × 50 items = massive HTML
Оптимизация:
initialPageParam
и серверная загрузка только первой страницы.
Если пользователь прервал соединение:
TanStack Query поддерживает AbortSignal:
queryFn: async ({ signal }) => {
const response = await fetch(url, { signal })
return response.json()
}
На сервере retry обычно отключают.
queries: {
retry: false
}
Причины:
Streaming SSR хорошо сочетается с CDN.
Можно кешировать:
В Next.js App Router Streaming SSR тесно связан с React Flight protocol.
Передаются:
TanStack Query Devtools помогают анализировать:
Типичные причины:
- staleTime = 0
- hydrate mismatch
- queryKey mismatch
- разный queryFn
- сериализация undefined
QueryKey должен быть полностью идентичным:
Сервер:
['posts', page]
Клиент:
['posts', page]
Любое отличие ломает гидратацию.
dehydrate сериализует JSON-compatible данные.
Проблемы вызывают:
Перед SSR полезно преобразовывать:
return {
createdAt: date.toISOString()
}
Наибольший выигрыш наблюдается при:
Механизм может быть неоправдан при:
Streaming SSR особенно эффективен вместе с realtime-обновлениями.
Схема:
1. Initial HTML stream
2. Hydration
3. WebSocket updates
4. Query cache updates
Оптимальная стратегия для современных React-приложений:
Server:
├── critical SSR
├── stream shell
├── prefetch SEO data
└── partial hydration
Client:
├── TanStack Query
├── realtime sync
├── mutations
└── background refetch
Page
├── Header
├── Navigation
├── Suspense(User)
├── Suspense(Feed)
├── Suspense(Comments)
├── Suspense(Recommendations)
└── Footer
Каждый блок: