Як працює генерація статичних сайтів (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 ще під час збірки
Швидкий приклад
// 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 не варіант
Таблиця порівняння
| SSG | SSR | CSR | |
|---|---|---|---|
| Час рендерингу | next build | Кожен запит | Браузер |
| TTFB | ~10ms (CDN) | 100-500ms | 2-5s (JS-бандл) |
| Витрати на сервер | Нуль після деплою | Залежать від трафіку | Мінімальні (статичний хост) |
| SEO | Повний HTML відразу | Повний HTML відразу | Слабке (потребує гідратації) |
| Свіжість даних | Заморожені до ребілду або ISR | Завжди свіжі | За запитом |
| API у Next.js | Дефолт App Router / getStaticProps | getServerSideProps | useEffect |
| Для чого | Доки, блоги (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 вказує збірнику, які шляхи потрібно попередньо згенерувати:
// 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 секунд:
// 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, паралельно регенерує
// Другий запит: отримує свіжий HTMLISR робить контент типу "переважно статичний" практичним. Документація Stripe API Reference використовує цей підхід: сторінки залишаються актуальними без повного ребілду.
Типові помилки
fetch без cache: 'force-cache' в App Router
// Неправильно: у деяких конфігураціях 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 для динамічних маршрутів
// app/shop/[id]/page.tsx без generateStaticParams
// Результат: SSR-fallback, нуль попередньо згенерованого HTMLКожен динамічний маршрут без generateStaticParams автоматично стає SSR. Додай його або усвідомлено прийми цей компроміс.
Використання cookies() у нібито статичному компоненті
// Компонент виглядає статичним, але це не так
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)
// Неправильно
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)
// 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
// 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
// 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 для нового контенту, доданого після збірки.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.