Skip to main content

Як працює генерація статичних сайтів (SSG) у Next.js

SSG (Static Site Generation, генерація статичних сайтів) у Next.js перетворює сторінки на HTML-файли під час next build і роздає їх з CDN без будь-якого серверного коду на кожен запит.

Теорія

TL;DR

  • SSG: випічка всіх тістечок до відкриття магазину - відвідувач бере готове, без очікування
  • Головна відмінність від SSR: рендеринг відбувається під час збірки, а не на кожен запит
  • TTFB: ~10ms з CDN проти 100-500ms для SSR
  • Використовуй SSG коли контент оновлюється рідше ніж раз на день і важливі швидкість або SEO
  • cookies() або searchParams у компоненті автоматично виключать сторінку з SSG ще під час збірки

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

tsx
// app/page.tsx - виконується один раз під час збірки, результат - статичний HTML export default async function HomePage() { const data = await fetch('https://api.example.com/posts/1', { cache: 'force-cache' // фіксуємо відповідь на момент збірки }).then(res => res.json()); return ( <div> <h1>{data.title}</h1> <p>{data.body}</p> </div> ); } // Результат: .next/server/app/index.html з даними всередині // На запит: CDN повертає HTML за ~10ms, далі JS гідратує клієнт

Цей компонент запускається один раз під час next build. Відповідь API вбудована в HTML. При запиті сервер не задіяний взагалі.

Час збірки проти часу запиту

SSR запускає Node.js на кожен запит. SSG запускає його рівно один раз. Саме ця різниця дає SSG ~10ms TTFB з CDN, тоді як SSR потребує 100-500ms на roundtrip до сервера. Компроміс: дані в SSG заморожені до наступної збірки або до спрацювання ISR.

Коли використовувати SSG

  • Документація, блоги, маркетингові сторінки: стабільний контент, важливі SEO та можливі пікові навантаження
  • Каталоги товарів з тижневими оновленнями: SSG з ISR (revalidate: 60) закриє це завдання
  • Високий трафік при невеликому бюджеті на сервер: SSG повністю усуває витрати на обчислення в рантаймі
  • Дашборди з персоналізованими даними: SSG тут не підійде, краще SSR або CSR
  • Дані в реальному часі (ціни акцій, чати): SSG не варіант

Таблиця порівняння

SSGSSRCSR
Час рендерингуnext buildКожен запитБраузер
TTFB~10ms (CDN)100-500ms2-5s (JS-бандл)
Витрати на серверНуль після деплоюЗалежать від трафікуМінімальні (статичний хост)
SEOПовний HTML відразуПовний HTML відразуСлабке (потребує гідратації)
Свіжість данихЗаморожені до ребілду або ISRЗавжди свіжіЗа запитом
API у Next.jsДефолт App Router / getStaticPropsgetServerSidePropsuseEffect
Для чогоДоки, блоги (Vercel.com)Дашборди (Stripe.com)SPA (Gmail)

Як Next.js будує статичні сторінки

Під час next build Next.js сканує всі сторінки, запускає React Server Components у Node.js, отримує дані через fetch з cache: 'force-cache', серіалізує React-дерево в HTML за допомогою renderToPipeableStream і записує .html-файли в .next/server/app. Після цього сервер у запитах не задіяний: CDN роздає все самостійно.

Для динамічних маршрутів generateStaticParams вказує збірнику, які шляхи потрібно попередньо згенерувати:

tsx
// app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return posts.map((post: { slug: string }) => ({ slug: post.slug })); } export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await fetch(`https://api.example.com/posts/${params.slug}`, { cache: 'force-cache' }).then(r => r.json()); return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> </article> ); } // Збірка генерує: /blog/my-first-post/index.html, /blog/next-tips/index.html, ...

Без generateStaticParams на динамічному маршруті Next.js переходить на SSR. Це поширена регресія продуктивності, яку команди помічають пізно.

ISR: статичні сторінки, що оновлюються

ISR (Incremental Static Regeneration, інкрементальна статична регенерація) дозволяє ревалідувати сторінку у фоні без повного ребілду. Додай export const revalidate = 60 щоб оновлювати кожні 60 секунд:

tsx
// app/blog/[slug]/page.tsx export const revalidate = 60; // фонова ревалідація кожні 60s export default async function BlogPost({ params }: { params: { slug: string } }) { const post = await fetch(`https://api.example.com/posts/${params.slug}`, { next: { revalidate: 60 } }).then(r => r.json()); return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> </article> ); } // Перший запит після 60s: повертає старий HTML, паралельно регенерує // Другий запит: отримує свіжий HTML

ISR робить контент типу "переважно статичний" практичним. Документація Stripe API Reference використовує цей підхід: сторінки залишаються актуальними без повного ребілду.

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

fetch без cache: 'force-cache' в App Router

tsx
// Неправильно: у деяких конфігураціях Next.js 14+ дефолт = no-store const data = await fetch('/api/data'); // Правильно const data = await fetch('/api/data', { cache: 'force-cache' }); // Або на рівні сторінки: export const dynamic = 'force-static';

Без force-cache сторінка стає динамічним рендером. Логи збірки покажуть ⚠ Dynamic, і CDN нічого не кешує.

Відсутній generateStaticParams для динамічних маршрутів

tsx
// app/shop/[id]/page.tsx без generateStaticParams // Результат: SSR-fallback, нуль попередньо згенерованого HTML

Кожен динамічний маршрут без generateStaticParams автоматично стає SSR. Додай його або усвідомлено прийми цей компроміс.

Використання cookies() у нібито статичному компоненті

tsx
// Компонент виглядає статичним, але це не так export default async function Dashboard({ params }: { params: { id: string } }) { const isLoggedIn = !!cookies().get('session')?.value; // примушує динамічний рендер return <div>User {params.id}</div>; }

cookies() позначає сторінку як dynamic = 'force-dynamic' під час аналізу збірки. Сторінка стає SSR незалежно від наявності generateStaticParams. Перевіряй логи next build на попередження "Dynamic Function". Мені доводилося бачити, як це дивує команди, які вже налаштували generateStaticParams і вважали, що все гаразд.

revalidate: 0 в ISR

Значення revalidate: 0 повністю вимикає статичну генерацію. Використовуй revalidate: false для чистого SSG або будь-яке додатне число для ISR.

Мутація даних у getStaticProps (Pages Router)

tsx
// Неправильно export async function getStaticProps() { const data = await fetch('/api').then(r => r.json()); data.count++; // мутація ігнорується або ламає збірку return { props: { data } }; } // Правильно export async function getStaticProps() { const data = await fetch('/api').then(r => r.json()); return { props: { data: { ...data, count: data.count + 1 } } }; }

getStaticProps має бути чистою функцією. Трансформуй дані без мутацій.

Де використовується на практиці

  • Vercel.com docs: SSG з ISR, 10M+ відвідувань на місяць без серверних обчислень на запит
  • TailwindUI.com: generateStaticParams попередньо будує 500+ сторінок із прев'ю компонентів
  • Stripe.com docs: SSG + ISR для оновлення API Reference без повного ребілду
  • TinaCMS + Next.js: headless CMS тригерить ребілд при зміні контенту, класичний Jamstack-підхід

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

Q: Як Next.js вирішує, чи рендерити сторінку статично, чи динамічно?
A: Під час next build він аналізує кожну сторінку на наявність динамічних функцій: cookies(), headers(), searchParams. Якщо знаходить, позначає сторінку force-dynamic. Вивід збірки показує ○ (Static) або λ (Dynamic) для кожного маршруту.

Q: Яка різниця між export const revalidate = 60 і next: { revalidate: 60 } у fetch?
A: Рівень сторінки встановлює дефолт для всього маршруту. Рівень fetch перевизначає для конкретного запиту і має пріоритет. В одній сторінці можна мати один запит з довгою ревалідацією і один з короткою.

Q: Чому cookies() запобігає SSG?
A: Це сигнал, що сторінка залежить від контексту конкретного запиту. Next.js не може попередньо згенерувати щось, що відрізняється для кожного користувача, тому позначає її dynamic = 'force-dynamic' і пропускає статичну генерацію.

Q: ISR проти повного ребілду: коли що обирати?
A: ISR регенерує окремі сторінки у фоні без простою. Повний ребілд зачіпає все відразу і швидший для невеликих сайтів. Для 50 сторінок підійде повний ребілд. Для 10 000 сторінок товарів ISR є єдиним практичним варіантом.

Q: Що відбувається з маршрутами, не переліченими в generateStaticParams?
A: За замовчуванням (dynamicParams = true) для невідомих шляхів використовується SSR-fallback. Встанови export const dynamicParams = false щоб повертати 404 для всього, що не було попередньо побудовано.

Приклади

Базова SSG-сторінка (App Router)

tsx
// app/docs/page.tsx export default async function DocsPage() { const docs = await fetch('https://api.itlead.org/docs', { cache: 'force-cache' }).then(res => res.json()); return ( <ul> {docs.map((doc: { slug: string; title: string }) => ( <li key={doc.slug}>{doc.title}</li> ))} </ul> ); } // Результат збірки: docs/index.html з усіма заголовками всередині // CDN роздає файл напряму, Node.js не задіяний

cache: 'force-cache' фіксує відповідь на момент збірки. Це найпростіше налаштування SSG в App Router.

Динамічні маршрути з generateStaticParams

tsx
// app/docs/[slug]/page.tsx export async function generateStaticParams() { const docs = await fetch('https://api.itlead.org/docs').then(r => r.json()); return docs.map((doc: { slug: string }) => ({ slug: doc.slug })); } export default async function DocPage({ params }: { params: { slug: string } }) { const doc = await fetch(`https://api.itlead.org/docs/${params.slug}`, { cache: 'force-cache' }).then(res => res.json()); return ( <article> <h1>{doc.title}</h1> <div>{doc.content}</div> </article> ); }

generateStaticParams запускається один раз під час збірки, повертає всі slug-и, і Next.js генерує по одному HTML-файлу на кожен. В рантаймі: чистий CDN, нуль серверів.

Pages Router з getStaticProps і getStaticPaths

tsx
// pages/docs/[slug].tsx export async function getStaticPaths() { const docs = await fetch('https://api.itlead.org/docs').then(r => r.json()); return { paths: docs.map((doc: { slug: string }) => ({ params: { slug: doc.slug } })), fallback: false // 404 для будь-якого невідомого slug }; } export async function getStaticProps({ params }: { params: { slug: string } }) { const doc = await fetch(`https://api.itlead.org/docs/${params.slug}`).then(r => r.json()); return { props: { doc } }; } export default function DocPage({ doc }: { doc: { title: string; content: string } }) { return ( <article> <h1>{doc.title}</h1> <p>{doc.content}</p> </article> ); }

fallback: false означає, що невідомі slug-и повертають 404 одразу. Використовуй fallback: 'blocking' якщо потрібен SSR-fallback для нового контенту, доданого після збірки.

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

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

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

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