Skip to main content

Стрімінг та завантаження інтерфейсу в Next.js

Стрімінг (streaming) у Next.js відправляє HTML браузеру частинами, коли кожна частина готова. Користувач бачить контент поступово, а не чекає на порожній екран.

Теорія

TL;DR

  • Без стрімінгу: сервер чекає на всі дані, потім відправляє повний HTML. TTFB може досягати кількох секунд.
  • Зі стрімінгом: скелет сторінки приходить одразу, дані підвантажуються по мірі готовності запитів.
  • loading.tsx = один fallback на всю сторінку, без додаткового налаштування.
  • Suspense = незалежні fallback'и для кожного компонента, потребує ручного обгортання.
  • Під капотом: одне HTTP-з'єднання залишається відкритим, сервер надсилає чанки через невеликі <script>-теги.

Швидкий приклад

tsx
// app/problems/loading.tsx // Next.js підхопить цей файл автоматично 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> ) }

Поклади цей файл поруч із page.tsx. Next.js загорне сторінку в Suspense з цим компонентом як fallback. На цьому налаштування закінчується.

Чому традиційний SSR блокує сторінку

Типовий дашборд звертається до трьох джерел: запит до бази для статистики користувача, API-виклик для останньої активності, ще один запит для прогресу по дорожній карті. Навіть якщо запустити їх паралельно через Promise.all, сервер однаково чекатиме на найповільніший перед тим, як відправити хоч байт HTML.

Найповільніший запит визначає TTFB всієї сторінки. Одна повільна залежність затримує все.

Як стрімінг змінює час відповіді

Next.js відправляє оболонку сторінки (layout, навігація, скелети) одразу, як тільки React починає рендерити. Кожна границя Suspense - це точка фlushing'у. Коли дані за цією границею готові, сервер надсилає невеликий скрипт, який замінює скелет на реальний контент.

Користувач бачить щось корисне вже за 200ms. Важкі запити виконуються у фоні.

Suspense для тонкого контролю

loading.tsx трактує всю сторінку як одну границю. Для простих сторінок з одним джерелом даних це нормально. Але для дашбордів один повільний запит блокує весь підхід зі скелетом.

Загортай кожен повільний компонент у власний Suspense:

tsx
// app/dashboard/page.tsx import { Suspense } from 'react' export default function DashboardPage() { return ( <div> <h1>Дашборд</h1> <Suspense fallback={<StatsSkeleton />}> <UserStats /> {/* запит 2с */} </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> {/* запит 800ms */} </Suspense> <Suspense fallback={<ProgressSkeleton />}> <RoadmapProgress /> {/* запит 1.5с */} </Suspense> </div> ) }

RecentActivity завершується першим і рендериться приблизно на 800ms. UserStats і RoadmapProgress з'являються коли їхні запити вирішуються. Жодна границя не чекає на іншу.

Як це працює під капотом

Браузер відкриває одне HTTP-з'єднання до сервера. Сервер відправляє початковий HTML зі скелетами-заглушками і тримає з'єднання живим. Коли запит вирішується, сервер відправляє <script>-тег:

tsx
// 1. Початковий HTML, який отримує браузер <div id="$stats"><!-- StatsSkeleton HTML --></div> // 2. Сервер надсилає цей чанк приблизно через 800ms <div hidden id="S:1"> <div>Вирішено задач: 42</div> </div> <script>$RC("$stats", "S:1")</script>

$RC - внутрішня функція React для заміни заглушки. Браузер не робить жодного додаткового запиту. Я перевіряв це у вкладці Network: один довгий запит, кілька чанків даних що приходять у різний час.

loading.tsx проти Suspense

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

Обидва використовують однаковий механізм всередині. loading.tsx - це файловий shortcut, який Next.js перетворює на границю Suspense на рівні маршруту.

Типові помилки

Загортати все в одну границю. Якщо обгорнути весь дашборд одним Suspense, повертаємось до тієї ж проблеми. Швидкі компоненти чекатимуть на повільні.

tsx
// Погано: одна границя, одне очікування для всього <Suspense fallback={<DashboardSkeleton />}> <UserStats /> <RecentActivity /> <RoadmapProgress /> </Suspense> // Краще: кожен компонент стрімить незалежно <Suspense fallback={<StatsSkeleton />}><UserStats /></Suspense> <Suspense fallback={<ActivitySkeleton />}><RecentActivity /></Suspense> <Suspense fallback={<ProgressSkeleton />}><RoadmapProgress /></Suspense>

Використовувати loading.tsx для дашбордів. Він покриває весь маршрут. Якщо один блок завантажується 3 секунди, користувач бачить повний скелет 3 секунди, навіть якщо два інших блоки були готові вже на 500ms.

Стрімінг із клієнтськими компонентами. Патерн працює тільки якщо компонент сам очікує даних на сервері:

tsx
// Стрімить коректно - async серверний компонент async function UserStats() { const stats = await db.user.getStats(userId) return <div>{stats.problemsSolved}</div> } // Не стрімить - клієнтський компонент отримує дані після гідратації 'use client' function UserStats() { const [stats, setStats] = useState(null) useEffect(() => { fetch('/api/stats').then(r => r.json()).then(setStats) }, []) return stats ? <div>{stats.problemsSolved}</div> : null }

Клієнтський варіант викликає waterfall: сторінка завантажується, гідратується, потім робить запит. Жодного скелета під час реального отримання даних.

Де зустрічається в реальних проєктах

  • Продуктові дашборди: кожен віджет отримує власну границю Suspense, оболонка з'являється миттєво.
  • Блог з коментарями: стаття рендериться відразу, секція коментарів завантажується у фоні.
  • Сторінка товару в e-commerce: деталі товару стрімляться першими, відгуки завантажуються незалежно.
  • Будь-який маршрут де один запит повільніший за інші: виноси той компонент за Suspense.

Питання на співбесіді

Q: Чи працює стрімінг із getServerSideProps?
A: Ні. Стрімінг потребує App Router. getServerSideProps - це Pages Router, він не підтримує границі Suspense таким чином.

Q: Що станеться, якщо async компонент кидає помилку під час стрімінгу?
A: React шукає найближчу границю error.tsx. Якщо вона є поруч із page.tsx, вона перехопить помилку. Без неї помилка може зіпсувати вже надіслану оболонку у браузері.

Q: Чи можна вкладати Suspense одне в одне?
A: Так. Внутрішні границі вирішуються і стрімляться першими. Зовнішні перехоплюють компоненти без внутрішньої границі. Вкладеність необмежена, але варто тримати її навмисною.

Q: Як стрімінг впливає на SEO?
A: Googlebot добре обробляє стрімінг. Повний HTML присутній у тілі відповіді, просто доставляється частинами. Контент, відрендерений на сервері таким чином, індексується.

Q: У чому різниця між стрімінгом та ISR?
A: ISR кешує відрендерений HTML і ревалідує його за розкладом, це зменшує навантаження на сервер. Стрімінг генерує свіжий HTML на кожен запит, але відправляє його поступово, що зменшує TTFB для динамічних сторінок. Вони вирішують різні проблеми.

Приклади

Базовий: loading.tsx для списку задач

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> ) } // app/problems/page.tsx export default async function ProblemsPage() { const problems = await db.problems.findMany() // може займати 1-2с return ( <ul> {problems.map(p => <li key={p.id}>{p.title}</li>)} </ul> ) }

Користувач одразу бачить п'ять скелетних рядків. Коли запит до бази повертає результат, реальний список замінює їх. Жодного додаткового налаштування.

Середній рівень: дашборд з незалежними границями

tsx
// app/dashboard/page.tsx import { Suspense } from 'react' export default function DashboardPage() { return ( <div className="grid gap-6"> <Suspense fallback={<StatsSkeleton />}> <UserStats /> </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> </Suspense> </div> ) } async function UserStats() { const stats = await db.users.getStats() // 2с return <div>Вирішено задач: {stats.count}</div> } async function RecentActivity() { const activity = await db.activity.recent() // 500ms return <ul>{activity.map(a => <li key={a.id}>{a.label}</li>)}</ul> }

RecentActivity з'являється на ~500ms. UserStats - на ~2с. Без окремих границь обидва чекали б повні 2 секунди.

Senior: обробка помилок під час стрімінгу

tsx
// app/dashboard/error.tsx 'use client' export default function DashboardError({ error, reset, }: { error: Error reset: () => void }) { return ( <div> <p>Не вдалось завантажити цю секцію.</p> <button onClick={reset}>Спробувати ще раз</button> </div> ) } // app/dashboard/page.tsx export default function DashboardPage() { return ( <div> <Suspense fallback={<StatsSkeleton />}> <UserStats /> {/* якщо кинуть помилку, error.tsx перехопить */} </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> {/* рендериться нормально незважаючи ні на що */} </Suspense> </div> ) }

Якщо UserStats кидає помилку під час стрімінгу, error.tsx перехоплює її і показує кнопку повтору. RecentActivity знаходиться в окремій границі і рендериться нормально. Один невдалий запит не ламає решту сторінки.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?