next/dynamic в Next.js — коли лінь корисна, а коли критична
У попередній статті я розповів про React.lazy + Suspense — базовий механізм React для лінивого завантаження клієнтських компонентів. Це відмінний спосіб розрізати бандл на шматочки, щоб завантажувати їх по мірі потреби. Але сьогодні ми підемо набагато глибше: розберемося, навіщо Next.js придумав свою обгортку next/dynamic, чим вона відрізняється, які проблеми вирішує і в яких ситуаціях — незамінна.
Ця стаття — не просто черговий посібник. Це витяжка інформації та збірник практичних кейсів, до якого ви зможете повертатися знову і знову. Тут я покажу всі найважливіші моменти, з якими ви точно зіткнетеся і з якими стикався я.
Стаття вийшла об’ємною, тому буде розбита на дві частини:
- У Частині 1: ми розберемо основні відмінності
next/dynamicвідReact.lazy+Suspenseі подивимося на різницю їх поведінки саме в контексті «use client» та серверних компонентів. - У Частині 2: ми ще детальніше розберемо, що таке
ssr: falseтаssr: true, як цим керувати, і як ці підходи працюють у «use client», серверних компонентах, а також у гібридних сценаріях, що таке Prefetching і як він допомагає передзавантажувати код.
Я гарантую, що за 10 хвилин читання ви дізнаєтеся більше, ніж із багатогодинних відео.
Приготуйтеся — буде цікаво.
Частина 1: next/dynamic и React.lazy + Suspense для Server и Client components
Dynamic import у Server Components
Так, ви правильно читаєте. next/dynamic можна використовувати в серверних компонентах, і це працює відмінно. Більше того, іноді це єдиний спосіб вирішити певні проблеми.
Ось простий приклад:
import dynamic from 'next/dynamic';
const AdminPanel = dynamic(() => import('./AdminPanel'), {
loading: () => <div>Loading admin tools...</div>,
});
const UserDashboard = dynamic(() => import('./UserDashboard'), {
loading: () => <div>Loading dashboard...</div>,
});
export default async function DashboardPage() {
const user = await getCurrentUser();
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {user.name}!</p>
{user.role === 'admin' ? <AdminPanel userId={user.id} /> : <UserDashboard userId={user.id} />}
</div>
);
}
Магія тут у тому, що серверний компонент може умовно завантажувати клієнтські компоненти через dynamic. JavaScript-код цих компонентів буде завантажуватися окремими чанками тільки коли вони дійсно потрібні.
💡 У dynamic import ssr по замовчуванню true
Suspense + React.lazy у Server Components?
А ось тут засідка. Спробуємо те ж саме з React.lazy:
import { Suspense, lazy } from 'react'
const LazyChart = lazy(() => import('./components/Chart'))
export default async function BrokenPage() {
return (
<div>
<h1>This doesn't work</h1>
<Suspense fallback={<div>Loading...</div>}>
<LazyChart />
</Suspense>
</div>
)
}
Next.js видасть помилку типу: «React.lazy is not supported in Server Components».
Чому? Тому що React.lazy передбачає, що код виконується в браузері, де є динамічні імпорти. А серверний компонент рендериться на сервері Node.js, де логіка завантаження шматочків працює по-іншому.
Але є один нюанс з Suspense у Server Components
Suspense можна використовувати в серверних компонентах, але не для React.lazy, а для інших цілей.
import { Suspense } from 'react'
export default function ServerPage() {
return (
<div>
<h1>Server Component</h1>
<Suspense fallback={<div>Loading user...</div>}>
<AsyncUserProfile userId="123" />
</Suspense>
<Suspense fallback={<div>Loading client part...</div>}>
<ClientInteractiveWidget />
</Suspense>
</div>
)
}
async function AsyncUserProfile({ userId }: { userId: string }) {
await new Promise(resolve => setTimeout(resolve, 2000))
const user = await fetch(`https://api.example.com/user/${userId}`)
.then(res => res.json())
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
Важливе правило: Suspense у Server Components покаже fallback тільки якщо:
- Обгорнутий компонент асинхронний (
async function) - Компонент імпортується через
dynamicсssr: false - У ньому є справжня асинхронна операція (fetch, setTimeout, etc)
💡 Якщо ви уважно читали мою минулу сттатю про React.lazy + Suspense в React, то можете згадати, що для успішного fallback компонент повинен явно викидувати проміс, на відміну від Next.
Якщо компонент звичайний синхронний, то fallback просто не покажеться — React подумає, що компонент уже готовий.
Dynamic import у Client Components
next/dynamic у клієнтських компонентах працює так само, як і стандартний React.lazy:
'use client'
import dynamic from 'next/dynamic'
import { useState } from 'react'
const LazyModal = dynamic(() => import('./Modal'), {
loading: () => <div className="spinner">Loading modal...</div>
})
const LazyEditor = dynamic(() => import('./Editor'), {
loading: () => <div>Loading editor...</div>,
ssr: false
})
export default function ClientPage() {
const [showModal, setShowModal] = useState(false)
return (
<div>
<h1>Client Component</h1>
<button onClick={() => setShowModal(true)}>
Open modal
</button>
{showModal && <LazyModal onClose={() => setShowModal(false)} />}
<LazyEditor />
</div>
)
}
Особливо уважні могли помітити, що LazyModal і LazyEditor написані по-різному, хоча обидва є динамічними імпортами. Це не помилка, а важливий нюанс, який відображає їхнє призначення.
Чому в нашому прикладі LazyEditor має ssr: false
LazyEditor завжди присутній на сторінці (він не залежить від стану showModal). За замовчуванням Next.js намагався б відрендерити його на сервері.
- Навіщо
ssr: false? Ви явно вказуєте Next.js: «Не рендери цей компонент на сервері». Це потрібно, якщо компонент використовує браузерні API або просто дуже «важкий» і ви хочете, щоб початковий HTML був максимально легким.
Чому LazyModal не має ssr: false
LazyModal рендериться лише тоді, коли showModal дорівнює true. При першому завантаженні сторінки showModal завжди false.
- За чим
ssr: false? У цьому випадку флаг був би зайвим. Next.js бачить, що компонент при початковому рендерингу не буде показаний, і сам розуміє, що його не потрібно рендерити на сервері. Код для модалки буде завантажено лише тоді, коли користувач натисне кнопку.
Таким чином, у випадку LazyModal ви покладаєтеся на умовний рендеринг React, а у випадку LazyEditor — на явну інструкцію ssr: false.
React.lazy + Suspense у Client Components
У клієнтських компонентах React.lazy почувається як вдома:
'use client'
import { Suspense, lazy, useState } from 'react'
const LazyChart = lazy(() => import('./Chart'))
const LazySettings = lazy(() => import('./Settings'))
export default function ClientWithLazy() {
const [activeTab, setActiveTab] = useState('chart')
return (
<div>
<nav>
<button onClick={() => setActiveTab('chart')}>Chart</button>
<button onClick={() => setActiveTab('settings')}>Settings</button>
</nav>
{activeTab === 'chart' && (
<Suspense fallback={<div>Loading chart...</div>}>
<LazyChart />
</Suspense>
)}
{activeTab === 'settings' && (
<Suspense fallback={<div>Loading settings...</div>}>
<LazySettings />
</Suspense>
)}
</div>
)
}
Красиво і працює. Але є підводний камінь — а що якщо нам потрібно відключити SSR для важкого компонента? З React.lazy це зробити неможливо напряму.
Коли next/dynamic краще, ніж lazy?
Уявімо ситуацію: у вас є інтерактивна карта, яка використовує window, document та інші браузерні API. Якщо спробувати відрендерити її на сервері — отримаємо помилку.
З React.lazy:
'use client'
import { Suspense, lazy } from 'react'
const InteractiveMap = lazy(() => import('./InteractiveMap'))
export default function MapPage() {
return (
<Suspense fallback={<div>Loading map...</div>}>
<InteractiveMap />
</Suspense>
)
}
Цей код може впасти при SSR, якщо компонент InteractiveMap десь всередині звертається до window.
З next/dynamic:
'use client'
import dynamic from 'next/dynamic'
const InteractiveMap = dynamic(() => import('./InteractiveMap'), {
loading: () => <div>Loading map...</div>,
ssr: false
})
export default function MapPage() {
return (
<div>
<h1>Our map</h1>
<InteractiveMap />
</div>
)
}
ssr: false каже Next.js: цей компонент рендери тільки в браузері, а на сервері віддай замість нього loader.
Отже, що ми зрозуміли?
Dynamic import:
- Працює з Server і Client Components.
- Дає code-splitting і (у клієнтському файлі) може вимкнути SSR через ssr:false.
React.lazy + Suspense:
- Працює лише в Client Components.
- Не керує SSR.
- Стандартний React-підхід.
- Добре для простих випадків.
У наступній частині розберемо складніші сценарії та дізнаємося, коли який підхід обирати.
Частина 2: SSR control, гібридні сценарії та підводні камені продуктивності
У першій частині ми розібралися з відмінностями між next/dynamic та React.lazy. Тепер занурюємося в практичні питання: коли відключати SSR, як правильно міксувати серверні та клієнтські компоненти, і де можна наступити на граблі з продуктивністю.
Важливо: в App Router (Next next/dynamic з ssr: false. Такий динамічний імпорт потрібно розміщувати всередині "use client" компонента.
💡 Next.js клієнтські компоненти в App Router за замовчуванням пред-рендеряться на сервері та потім гідратуються в браузері.
1. SSR: true vs false — мистецтво вибору
💡 Параметр ssr у next/dynamic — це не просто галочка. Це рішення, що впливає на SEO, продуктивність, та стабільність. Використовуйте його усвідомлено.
ssr: false — коли компонент «токсичний» для сервера
Картографічні бібліотеки (Loqate, Leaflet, Google maps, etc) щільно зав’язані на браузерні API і не рендеряться на сервері.
Приклад клієнтської обгортки (правильний паттерн для App Router):
"use client"
import dynamic from 'next/dynamic'
export const InteractiveMap = dynamic(() => import('./InteractiveMap'), {
loading: () => (
<div>
<div>Loading map widget...</div>
</div>
),
ssr: false,
})
І серверна сторінка, яка використовує клієнтську обгортку:
import ...
export default function Page() {
return (
<div>
<h1>ssr: false — Interactive Map</h1>
<p>Client widget. Check separate chunk in Network after hydration.</p>
<InteractiveMap />
</div>
)
}
💡 Ми пишемо ssr: false не для того, щоб зробити компонент «ще більш клієнтським», а щоб дати Next.js явну інструкцію повністю пропустити його серверний рендеринг.
Коли Next.js бачить ssr: false, сервер виконує дві дії, які призводять до завантаження двох різних типів файлів.
- Серверний HTML-файл із заглушкою:
- Сервер отримує запит на сторінку.
- Він миттєво створює HTML-файл, в якому замість HTML сервера вставлений тільки його
loading(наприклад,<div>Loading...</div>). Це відбувається дуже швидко. - Цей HTML-файл відправляється в браузер першим.
- Окремий JavaScript-файл:
- У HTML-файлі, який отримав браузер, є посилання на JavaScript-файл вашого компонента.
- Браузер отримує HTML і паралельно з цим відправляє окремий запит на сервер, щоб завантажити цей JS-файл.
- Після того як JS-файл завантажиться, він замінить заглушку на справжній компонент.
Як тільки браузер завантажить JS-файл та HTML, він замінить заглушку на справжній компонент.
ssr: true — коли SEO та перший рендер критичні
Якщо компонент містить важливий контент (товар, ціна, опис), він повинен потрапляти в HTML відразу. Головна причина, через яку в нашому прикладі не використовується ssr: false, — це SEO (пошукова оптимізація) та продуктивність першого рендеру.
- Процес: сервер повністю рендерить компонент (наприклад, кнопку) та вставляє його HTML-код у фінальну сторінку.
- Результат: браузер отримує і відразу показує готовий HTML із всією інформацією та розміткою. Жодних лоадерів немає. Користувач бачить текст кнопки (
"Add to cart") за мілісекунди.
const ProductCard = dynamic(() => import('./ProductCard'), {
ssr: true
})
export default function Page() {
return (
<div>
<h1>ssr: true — Product Card</h1>
<p>Rendered on the server for SEO, hydrated on client.</p>
<ProductCard />
</div>
)
}
'use client';
import { useEffect, useState } from 'react';
export default function ProductCard() {
const [hydratedAt, setHydratedAt] = useState<string | null>(null);
useEffect(() => {
setHydratedAt(new Date().toLocaleTimeString());
console.log('ProductCard hydrated');
}, []);
return (
<div>
<div>Awesome Gadget</div>
<div>Hydrated at: {hydratedAt ?? '—'}</div>
</div>
);
}
Тут приклад набагато цікавіший, ніж здається. Ось що ми бачимо при переході на сторінку. Давайте поставимо 3G, щоб імітувати повільну швидкість інтернету:

Поглянемо на сторінку і...

Цей приклад наочно демонструє, як next/dynamic з ssr: true працює в Next.js.
Серверний рендеринг (SSR)
Next.js пред-рендерить ProductCard на сервері. У цей момент useState та useEffect не виконуються. HTML-код компонента (<div>Awesome Gadget</div>) генерується і відправляється в браузер як частина початкового HTML. Це забезпечує швидкий перший рендер та SEO-оптимізацію, оскільки пошукові роботи одразу бачать контент.
Гідратація на клієнті
Після завантаження сторінки браузер отримує статичний HTML. Потім React «оживляє» цю розмітку, прикріплюючи до неї інтерактивність. Цей процес називається гідратацією. Саме в цей момент запускається useEffect та оновлюється стан (hydratedAt).
💡 якщо потрібний компонент містить важливі для SEO дані, ми його пререндеримо на сервері та віддаємо HTML-браузеру.
2. Гібридні сценарії — Server + Client
Паттерн: серверний компонент отримує дані та рендерить SEO‑критичний контент; клієнтські інтерактивні віджети — окремо та динамічно.
"use client"
import dynamic from 'next/dynamic'
export const AddToCartButtonLazy = dynamic(() => import('./AddToCartButton'), {
ssr: false,
loading: () => <button disabled>Loading…</button>,
})
export const ClientReviewsLazy = dynamic(() => import('./ClientReviews'), {
ssr: false,
loading: () => <div>Loading reviews…</div>,
})
import { AddToCartButtonLazy, ClientReviewsLazy } from './ClientWidgets';
async function getProduct() {
await new Promise(r => setTimeout(r, 300))
return { id: 'p-1', title: 'Hybrid Product', price: 149, description: 'Server-rendered content' }
}
export default async function Page() {
const product = await getProduct()
return (
<div style={{ padding: 24 }}>
<h1>Hybrid server + client</h1>
<div>
<div>{product.title}</div>
<div>${product.price}</div>
<p>{product.description}</p>
<AddToCartButtonLazy productId={product.id} />
</div>
<div>
<h2>Reviews</h2>
<ClientReviewsLazy productId={product.id} />
</div>
</div>
)
}
Суть у тому, що ми створюємо окремий клієнтський файл, який експортує компоненти, завантажені за допомогою dynamic. Потім цей файл імпортується в серверний компонент.
Таким чином, серверний компонент може використовувати ці «use client» компоненти, не завантажуючи їх код на сервер і не викликаючи помилок.
Суть цього підходу в тому, щоб розділити контент та інтерактивність.
- Контент (SEO-критичний) рендериться на сервері, щоб пошукові системи та користувачі з повільним інтернетом отримували його миттєво.
- Інтерактивність (клієнтські віджети) завантажується за потребою, не блокуючи перший рендер сторінки.
💡 Тобто користувач відразу побачить назву товару, його ціну, і все, що важливо для SEO. І тільки після цього він побачить решту частин, таких як AddToCartButtonLazy, підвантажених окремим чанком. і правильно... а навіщо користувачу ця кнопка, якщо він ще й назву товару не встиг побачити?
Цей гібридний підхід дозволяє створити сторінку, яка миттєво відображає SEO-оптимізований контент, рендеруючи його на сервері, а потім динамічно підвантажує інтерактивні елементи в браузері, забезпечуючи кращий performance.
А в чому була б різниця з ssr: true?
У нашому прикладі з товаром ми використовували ssr: false, щоб показати, як можна відкладати завантаження JavaScript. Але якби ми зробили те ж саме з ssr: true, різниця була б величезною.
export const AddToCartButtonLazy = dynamic(() => import('./AddToCartButton'), {
ssr: true, // <--- main changes here
loading: () => <button disabled>Loading...</button>,
})
export const ClientReviewsLazy = dynamic(() => import('./ClientReviews'), {
ssr: true, // <--- and here
loading: () => <div className="p-3 border rounded">Loading reviews...</div>,
})`;
Як би це змінило поведінку?
Серверний рендеринг всього вмісту. З ssr: true сервер не буде відправляти заглушки (Loading...). Натомість він повністю відрендерить HTML-код кнопок та віджетів відгуків і відправить його в браузер у складі початкової сторінки. Користувач побачить весь контент миттєво, без затримки, але чекати доведеться значно довше.
💡 без ssr: false користувачу довелося б чекати 3 секунди поки сторіночка не приготує всі дані на сервері, і весь цей час дивитися на лоадер. Це не погано, але можна краще — відразу показувати статичні дані та паралельно підвантажувати інтерактивність, і показувати лоадер не на всю сторінку, а в конкретних місцях блоків, які ми грузимо.
Nested динамічні імпорти (багаторівнева архітектура)
'use client'
import dynamic from 'next/dynamic'
import { useState } from 'react'
const UsersManager = dynamic(() => import('./admin/UsersManager'), {
loading: () => <div>Loading users management...</div>,
})
const ProductsManager = dynamic(() => import('./admin/ProductsManager'), {
loading: () => <div>Loading product catalog...</div>,
})
export default function AdminPanel() {
const [activeSection, setActiveSection] = useState<string | null>(null)
return (
<div className="admin-panel">
<nav>
<button onClick={() => setActiveSection('users')}>Users</button>
<button onClick={() => setActiveSection('products')}>Products</button>
</nav>
<main>
{activeSection === 'users' && <UsersManager />}
{activeSection === 'products' && <ProductsManager />}
</main>
</div>
)
}'use client'
import dynamic from 'next/dynamic'
import { useState } from 'react'
const UserBulkEditor = dynamic(() => import('./UserBulkEditor'), {
loading: () => <div>Loading bulk editor...</div>,
ssr: false,
})
const UserAnalytics = dynamic(() => import('./UserAnalytics'), {
loading: () => <div>Preparing analytics...</div>,
ssr: false,
})
export default function UsersManager() {
const [activeTab, setActiveTab] = useState('list')
return (
<div>
<div className="tabs">
<button onClick={() => setActiveTab('list')}>List</button>
<button onClick={() => setActiveTab('bulk')}>Bulk Operations</button>
<button onClick={() => setActiveTab('analytics')}>Analytics</button>
</div>
{activeTab === 'bulk' && <UserBulkEditor />}
{activeTab === 'analytics' && <UserAnalytics />}
</div>
)
}Цей код показує, як створювати багаторівневу архітектуру за допомогою вкладених динамічних імпортів. Замість того, щоб завантажувати весь інтерфейс відразу, застосунок завантажує тільки той код, який потрібен користувачу в даний момент.
Preloading / Prefetching — передбачаємо дії
"use client"
import dynamic from 'next/dynamic'
import { useState } from 'react'
const HeavyModal = dynamic(() => import('./HeavyModal'), {
ssr: false,
loading: () => <div>Loading modal…</div>,
})
export default function Page() {
const [open, setOpen] = useState(false)
const preload = () => {
// Preload chunk on hover
import('./HeavyModal')
}
return (
<div>
<h1>Preload on hover</h1>
<button onMouseEnter={preload} onClick={() => setOpen(true)}>
Open details
</button>
{open && <HeavyModal onClose={() => setOpen(false)} />}
</div>
)
}
У стандартному сценарії з dynamic імпортом JavaScript-код для HeavyModal буде завантажуватися тільки після того, як користувач натисне на кнопку. Це означає, що між кліком і появою модального вікна буде затримка.
За допомогою onMouseEnter ви передзавантажуєте (prefetching) код модального вікна, як тільки користувач наводить на кнопку.
Що це дає?
- Миттєвий відклик. Коли користувач клікає, код модального вікна вже завантажений, і воно відкривається практично миттєво.
- Оптимізація. Завантаження коду відбувається тоді, коли це найбільш ймовірно. Якщо користувач передумав і не клікнув, то код просто так і залишиться в кеші, не вплинувши на продуктивність.
Тезисний висновок статті:
— Dynamic import + ssr: true в use client компоненті — користувач відразу отримує готовий HTML-код всієї сторінки. Жодних лоадерів. Навіщо? Для SEO та миттєвого відображення всього контенту, якщо він критично важливий.
— Dynamic import + ssr: false в use client компоненті — користувач відразу отримує HTML, а на місці підвантажуваного js бачить лоадер.
— Dynamic import + ssr: false в server компоненті — неможливий (вийняток: підхід з розділу Гібридні сценарії — Server + Client).
— Dynamic import + ssr: true в server компоненті (щоб підвантажити use client компонент) — якщо потрібний компонент містить важливі для SEO дані, ми його пререндеримо на сервері та віддаємо HTML-браузеру.

10 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів