next/dynamic в Next.js — коли лінь корисна, а коли критична

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

У попередній статті я розповів про 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 тільки якщо:

  1. Обгорнутий компонент асинхронний (async function)
  2. Компонент імпортується через dynamic с ssr: false
  3. У ньому є справжня асинхронна операція (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 13–15) серверні компоненти не дозволяють використовувати 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, сервер виконує дві дії, які призводять до завантаження двох різних типів файлів.

  1. Серверний HTML-файл із заглушкою:
    • Сервер отримує запит на сторінку.
    • Він миттєво створює HTML-файл, в якому замість HTML сервера вставлений тільки його loading (наприклад, <div>Loading...</div>). Це відбувається дуже швидко.
    • Цей HTML-файл відправляється в браузер першим.
  2. Окремий 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-браузеру.

👍ПодобаєтьсяСподобалось14
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

в пункте

Preloading / Prefetching

я повторил код и он работает не так как описывается. Модалка подгружается в момент нажатия на кнопку, а не хувера на неё. На 15 нексте делаю

Дякую за коментар.

Щойно перевірив — у мене все працює так, як у прикладі. Можливо, справа в тому, що ви натискаєте занадто швидко?
Чи могли б ви надіслати код, за допомогою якого намагаєтесь відтворити цю поведінку?

Дякую за статю

Читати легко і цікаво. Дякую за натхнення та цінні поради!

Я не розумію який тоді сенс ssr true якщо він не показує лоадер а треба чекати поки все сформується на сервері? Також не розумію прикладу "

ssr: true — коли SEO та перший рендер критичні

, навіщо нам робити dynamic для кнопки якщо нам треба її сформувати миттєво, що саме нам в такому випадку дає next/dynamic?

Дякую, що прочитали статтю. Дійсно, приклад із AddToCartButtonLazy може здатися надлишковим, оскільки цей компонент не містить нічого, окрім тексту.

Однак, що якби в цій кнопці відбувалася складна логіка ? Наприклад, запит на сервер, валідація форми чи інтеграція зі сторонніми бібліотеками по натисканню на цю кнопку ? Я зустрічай такий підхід не деяких проектах. У такому випадку має сенс винести її в окремий бандл.

Використання next/dynamic з ssr: true в цьому сценарії дає нам одразу дві важливі речі:

— SEO. Сервер рендерить HTML кнопки, і вона одразу доступна для пошукових систем.

— Продуктивність. JavaScript-код з усією складною логікою виноситься в окремий бандл. Основна частина сторінки завантажується швидше, а код кнопки завантажується лише тоді, коли це необхідно, не сповільнюючи початковий рендер.

Сподіваюсь мені вдалося відповісти на Ваше питання, якщо щось незрозуміло питайте.

Тобто спочатку сам хтмл кнопки рендереться відразу, але логіка яка закладена всередині кнопки завантажиться після натискання?

Ви майже праві, але з важливою поправкою, тут суть у тому як працює гідратація у Next.js

HTML-код кнопки рендериться відразу. Але логіка всередині неї не чекає натискання, вона завантажується у фоновому режимі в окремому бандлі. Після того як браузер її завантажить, React «оживляє» кнопку і додає до неї всю інтерактивність.

Тобто, початкова розмітка є, але клікабельною кнопка стає через мілісекунди, що й називається гідратацією.

Підписатись на коментарі