Streaming и Loading UI в Next.js

Streaming позволяет отправлять HTML клиенту по частям, по мере готовности. Вместо того чтобы ждать завершения всех запросов, пользователь сразу видит часть страницы, а остальное подгружается.

Проблема без Streaming

Без streaming весь HTML генерируется целиком до отправки:

1

Все данные загружаются на сервере

Запрос к БД на список задач, запрос к API за статистикой, запрос на профиль. Все последовательно или параллельно, но HTML отправляется только когда все готово.

2

Пользователь ждет

Белый экран, пока сервер не соберет всю страницу. TTFB может быть несколько секунд.

Как Streaming решает проблему

С streaming Next.js отправляет части страницы по мере их готовности:

1

Мгновенный ответ

HTML с layout, навигацией и скелетонами отправляется сразу.

2

Постепенная загрузка

Данные подгружаются по мере готовности. Каждый блок заменяет свой скелетон.

loading.tsx

Самый простой способ добавить loading UI. Next.js автоматически оборачивает page.tsx в React Suspense с fallback'ом из loading.tsx:

// app/problems/loading.tsx
export default function Loading() {
  return (
    <div className="space-y-4">
      {[...Array(5)].map((_, i) => (
        <div
          key={i}
          className="h-16 bg-neutral-200 animate-pulse rounded-lg"
        />
      ))}
    </div>
  )
}

Пользователь видит скелетон сразу, а контент появляется по мере загрузки данных.

Suspense для гранулярного контроля

Для более точного контроля используй Suspense напрямую:

// app/dashboard/page.tsx
import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div>
      <h1>Дашборд</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
      <Suspense fallback={<ProgressSkeleton />}>
        <RoadmapProgress />
      </Suspense>
    </div>
  )
}

Каждый Suspense boundary стримится независимо. UserStats может появиться раньше RecentActivity.

Как это работает под капотом

// 1. Сервер отправляет HTML с fallback'ами
<div>
  <h1>Дашборд</h1>
  <div id="stats"><!-- StatsSkeleton --></div>
  <div id="activity"><!-- ActivitySkeleton --></div>
</div>

// 2. Когда данные готовы, сервер отправляет <script>
// который заменяет fallback на реальный контент
<script>
  $RC("stats", "<div>Решено задач: 42</div>")
</script>

Браузер получает поток данных по одному HTTP-соединению.

Паттерн: серверный компонент с async

Suspense работает с async серверными компонентами:

// Этот компонент будет стримиться
async function UserStats() {
  const stats = await db.user.getStats(userId)
  // Запрос может занять 2 секунды
  // Пользователь увидит скелетон, пока данные загружаются

  return (
    <div>
      <p>Решено задач: {stats.problemsSolved}</p>
      <p>Прочитано статей: {stats.docsRead}</p>
    </div>
  )
}

loading.tsx vs Suspense

loading.tsxSuspense
ОбластьВся страница (page.tsx)Конкретный компонент
ГранулярностьОдин fallback на страницуНесколько независимых
НастройкаАвтоматическаяРучная

Практический совет:

Используй loading.tsx для простых страниц с одним блоком данных. Для дашбордов и сложных страниц используй Suspense для каждого блока отдельно, чтобы контент появлялся по мере готовности.

Полезные ресурсы

Связанные темы