Ключові особливості Next.js
Next.js - React-фреймворк, який дозволяє обирати стратегію рендерингу для кожного маршруту окремо: SSG, SSR, ISR або CSR залежно від того, яких даних потребує сторінка.
Теорія
Коротко
- SSG генерує HTML при збірці і роздає з CDN. Найшвидший варіант, але дані "замерзають" до наступного деплою.
- SSR рендерить HTML на сервері при кожному запиті. Завжди актуальні дані, але є затримка сервера.
- ISR - щось між: статичний HTML, який перебудовується у фоні за розкладом.
- App Router (v13+) прибрав
getStaticProps/getServerSideProps. Тепер усе контролюють опціїfetchв async-компонентах. - Всі компоненти - серверні за замовчуванням.
'use client'додаєш тільки там, де потрібен стан або браузерні API.
Гібридний рендеринг
Суть ідеї: один застосунок, кілька стратегій рендерингу. Ти обираєш для кожного маршруту.
// SSG - HTML генерується раз при збірці, роздається з CDN
async function BlogPage() {
const res = await fetch('https://api.itlead.org/posts', {
cache: 'force-cache' // поведінка за замовчуванням в App Router
});
const posts = await res.json();
return <PostList posts={posts} />;
}
// ISR - статичний при збірці, перебудовується у фоні кожні 5 хвилин
export const revalidate = 300;
async function ProblemsPage() {
const res = await fetch('https://api.itlead.org/problems');
const problems = await res.json();
return <ProblemList problems={problems} />;
}
// SSR - свіжий HTML при кожному запиті
async function DashboardPage() {
const res = await fetch('https://api.itlead.org/user/stats', {
cache: 'no-store'
});
const stats = await res.json();
return <StatsPanel stats={stats} />;
}Правило вибору: SSG для всього, що однакове для кожного відвідувача; SSR для персоналізованих або реалтаймових даних; ISR для контенту, який змінюється, але не щосекунди.
На практиці найбільший перехід з Create React App - це відмова від ідеї, що весь застосунок має одну стратегію рендерингу.
App Router і маршрутизація через файли
Next.js 13 представив App Router. Структура папок всередині app/ безпосередньо визначає маршрути. Спеціальні файли відповідають за конкретні задачі без будь-якої конфігурації:
| Файл | Призначення |
|---|---|
page.tsx | UI маршруту, робить сегмент публічним |
layout.tsx | Обгортка, яка зберігається між навігаціями |
loading.tsx | UI завантаження на основі Suspense |
error.tsx | Error boundary для сегменту |
not-found.tsx | UI для 404 |
// app/docs/[slug]/page.tsx
// Обробляє: /docs/javascript, /docs/react, /docs/nextjs
export default async function DocPage({
params
}: {
params: { slug: string }
}) {
const doc = await getDocument(params.slug);
return <article>{doc.content}</article>;
}Головна зміна порівняно з Pages Router: getStaticProps і getServerSideProps зникли. Їх замінили опції fetch всередині async Server Components. Менше шаблонного коду, той самий рівень контролю.
Server Components (серверні компоненти)
Кожен компонент в App Router є серверним за замовчуванням. Його код виконується на сервері, ніколи не потрапляє в браузерний бандл і може безпосередньо звертатися до бази даних, змінних середовища та файлової системи.
// app/stats/page.tsx - нульовий вплив на клієнтський бандл
import { db } from '@/lib/db';
export default async function StatsPage() {
const totalUsers = await db.user.count();
const totalProblems = await db.problem.count();
return (
<div>
<p>Користувачів: {totalUsers}</p>
<p>Задач: {totalProblems}</p>
</div>
);
}Коли потрібна інтерактивність - стан, обробники подій, браузерні API - додаєш 'use client' на початок файлу. Патерн, який добре працює в продакшені: завантаження даних залишається в серверних компонентах, інтерактивна логіка виноситься в невеликі клієнтські дочірні компоненти.
'use client'
import { useState } from 'react'
export default function ThemeToggle() {
const [dark, setDark] = useState(false)
return (
<button onClick={() => setDark(!dark)}>
{dark ? 'Світла тема' : 'Темна тема'}
</button>
)
}Server Actions (серверні дії)
Server Actions дозволяють викликати серверні функції прямо з клієнтських компонентів без написання окремих API-маршрутів. Позначаєш функцію директивою 'use server' і викликаєш як звичайну функцію.
// actions/subscribe.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function subscribe(email: string) {
await db.subscriber.create({ data: { email } })
revalidatePath('/newsletter')
}// Клієнтський компонент - окремий API-маршрут не потрібен
'use client'
import { subscribe } from '@/actions/subscribe'
export default function SubscribeForm() {
return (
<form action={async (formData) => {
const email = formData.get('email') as string
await subscribe(email)
}}>
<input name="email" type="email" placeholder="Email" />
<button type="submit">Підписатися</button>
</form>
)
}Під капотом Next.js перетворює Server Action на захищений POST-запит. Типобезпека через межу клієнт-сервер без ручного написання API.
Вкладені макети (Nested Layouts)
Макети в App Router зберігаються між навігаціями. Компонент монтується один раз і залишається змонтованим, поки користувач переходить між дочірніми маршрутами.
// app/docs/layout.tsx - обгортає всі /docs/* маршрути
export default function DocsLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="flex">
<Sidebar /> {/* залишається змонтованим, зберігає позицію скролу */}
<main className="flex-1">{children}</main>
</div>
)
}Перехід з /docs/javascript на /docs/react? Бокова панель не ремонтується. Позиція скролу, відкриті секції, локальний стан макету - все зберігається. Перерисовується тільки children. Саме для цього і потрібні вкладені макети.
Вбудовані оптимізації
Три компоненти закривають більшість питань продуктивності з коробки.
next/image змінює розміри зображень, конвертує у WebP або AVIF і додає lazy loading автоматично. Ти вказуєш розміри, решту робить компонент.
import Image from 'next/image'
export default function Avatar() {
return (
<Image src="/avatar.png" width={64} height={64} alt="Аватар користувача" />
)
}next/link попередньо завантажує сторінки у фоні, коли посилання потрапляє у видиму область. Коли користувач клікає - сторінка вже завантажена.
import Link from 'next/link'
export default function Nav() {
return (
<nav>
<Link href="/problems">Задачі</Link>
<Link href="/docs">Документація</Link>
</nav>
)
}next/font завантажує шрифти Google Fonts під час збірки і хостить їх самостійно. Жодних клієнтських запитів до серверів Google, жодного зсуву макету через пізнє завантаження шрифтів.
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin', 'cyrillic'] })
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
)
}Middleware
Middleware виконується на Edge перед тим, як запит досягає застосунку. Можна робити редиректи, переписувати URL або змінювати заголовки. Edge Runtime - це не Node.js, тому fs, path та інші Node-специфічні API тут недоступні.
// middleware.ts (корінь проекту)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const locale = request.cookies.get('locale')?.value || 'en'
if (!request.nextUrl.pathname.startsWith(`/${locale}`)) {
return NextResponse.redirect(
new URL(`/${locale}${request.nextUrl.pathname}`, request.url)
)
}
}
export const config = {
matcher: ['/((?!api|_next|favicon.*))']
}Типові сценарії: перевірка автентифікації, визначення локалі, редиректи для A/B тестів, додавання заголовків безпеки.
API метаданих
Next.js має вбудований API для SEO-метаданих. Експортуєш об'єкт metadata з будь-якого page.tsx або layout.tsx:
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'JavaScript Tasks — IT Lead',
description: 'Розвязуй задачі з реальних фронтенд-співбесід',
openGraph: {
title: 'JavaScript Tasks',
description: 'Розвязуй задачі з реальних фронтенд-співбесід',
type: 'website'
}
}
export default function ProblemsPage() {
return <ProblemsList />
}Для динамічних метаданих (блог-пости, сторінки товарів) замість metadata експортуєш асинхронну функцію generateMetadata. Вона отримує параметри маршруту і може підтягувати дані для формування заголовку і опису.
Типові помилки
Забути cache: 'no-store' на SSR-сторінках. App Router кешує fetch-запити за замовчуванням. Дашборд, який викликає fetch('/api/user') без опцій, закешує відповідь при збірці і роздаватиме однакові дані всім.
// Неправильно - кешується як SSG, дає застарілі дані
const data = await fetch('/api/user');
// Правильно - свіжі дані при кожному запиті
const data = await fetch('/api/user', { cache: 'no-store' });Додавати 'use client' на всю сторінку заради одного інтерактивного елемента. Це переводить всю сторінку на CSR і прибирає серверний HTML. Стан і обробники подій треба виносити в невеликі дочірні клієнтські компоненти.
// Неправильно - вся сторінка стає CSR, SEO зникає
'use client'
export default async function Page() { ... }
// Правильно - сторінка серверна, інтерактивність ізольована
export default async function Page() {
const data = await fetchData();
return <InteractiveChild data={data} />;
}Думати, що revalidate: 0 вмикає SSR. export const revalidate = 0 не робить маршрут серверним. Сторінка все одно статична при збірці. Для реального рендерингу при кожному запиті потрібен export const dynamic = 'force-dynamic' або cache: 'no-store' у fetch-запитах.
Пропускати <Suspense> навколо компонентів, що стрімлять. Без Suspense-межі асинхронний Server Component блокує відправку будь-якого HTML до клієнта, поки не вирішиться. Загортай повільні компоненти в <Suspense fallback={<Loader />}>.
Де зустрічається в реальних проектах
- Vercel.com: SSG для документації (CDN по всьому світу), SSR для авторизованого дашборду.
- Hashnode: SSG для постів, SSR для коментарів і персоналізованих стрічок.
- E-commerce: ISR для сторінок товарів з
revalidate: 60, щоб ціни оновлювалися без деплою. - Адмін-панелі: CSR з
'use client'по всьому застосунку, бо SEO не важливий, а дані змінюються постійно.
Питання на співбесіді
Q: В чому різниця між cache: 'no-store' і export const dynamic = 'force-dynamic'?
A: cache: 'no-store' застосовується до одного fetch-запиту. dynamic = 'force-dynamic' позначає весь маршрут як динамічний і поширюється на всі дочірні компоненти, перевизначаючи будь-яке кешування на рівні маршруту.
Q: Як Turbopack впливає на час розробки?
A: Turbopack замінює Webpack як бандлер у режимі розробки. Next.js 14 дає приблизно в 700 разів швидше оновлення модулів (HMR) у dev. Продакшн-збірки в Next.js 14 все ще використовують Webpack.
Q: Що таке RSC payload і як він пов'язаний зі стрімінгом?
A: React Server Components серіалізують свій вивід у RSC payload, а не в чистий HTML. Клієнт спочатку отримує статичну HTML-оболонку, потім RSC payload стрімиться для гідратації інтерактивних частин. <Suspense> дозволяє відправити оболонку одразу, а повільні частини - після їх вирішення.
Q: Коли виникає hydration mismatch (розбіжність гідратації) і як його виправити?
A: Коли серверний HTML не збігається з тим, що React очікує відрендерити на клієнті. Часті причини: часові мітки, випадкові ID, браузерні API під час рендеру. Вирішення: useEffect для відкладення клієнтського коду або suppressHydrationWarning для нешкідливих розбіжностей.
Q: Як би ти інвалідував ISR-кеш у багаторегіональному застосунку з високим навантаженням?
A: Через revalidateTag або revalidatePath, що тригеряться вебхуком при зміні контенту. Разом з Upstash Redis як спільним кеш-сховищем між Edge-регіонами. Для маршрутів, де застарілі дані неприпустимі, переключайся на SSR з cache: 'no-store'.
Приклади
ISR-сторінка продукту
Сторінка товару, яка перебудовується у фоні кожні 60 секунд. Користувачі завжди отримують валідну закешовану відповідь. Оновлення відбувається без деплою.
// app/products/[id]/page.tsx
interface Product {
id: string;
name: string;
price: number;
}
export const revalidate = 60;
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product: Product = await fetch(
`https://api.example.com/products/${params.id}`,
{ next: { revalidate: 60 } }
).then(res => res.json());
return (
<div>
<h1>{product.name}</h1>
<p>{product.price} грн</p>
</div>
);
}
// Статичний HTML з CDN, дані товару оновлюються у фоні - без перезбіркиСтрімінговий дашборд
Заголовок рендериться одразу. Секція аналітики стрімиться після того, як її дані вирішаться. Без <Suspense> вся сторінка чекає на найповільніший компонент.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import Analytics from './analytics'
export default function Dashboard() {
return (
<>
<Header /> {/* видно через ~50мс */}
<Suspense fallback={<div>Завантажую аналітику...</div>}>
<Analytics /> {/* стрімиться через ~2с */}
</Suspense>
</>
)
}
// app/dashboard/analytics.tsx
export default async function Analytics() {
const data = await fetch('https://api.itlead.org/analytics', {
cache: 'no-store'
}).then(r => r.json());
return <Chart data={data} />;
}
// Без спінера на всю сторінку. Оболонка видна, поки аналітика завантажується.Server Action з інвалідацією кешу
Форма записує в базу даних та інвалідує відповідний кешований маршрут. Окремий API-файл не потрібен.
// actions/solution.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function submitSolution(formData: FormData) {
const code = formData.get('code') as string
const problemId = formData.get('problemId') as string
await db.solution.create({ data: { code, problemId } })
revalidatePath(`/problems/${problemId}`)
}// app/problems/[id]/submit.tsx
'use client'
import { submitSolution } from '@/actions/solution'
export default function SubmitForm({ problemId }: { problemId: string }) {
return (
<form action={submitSolution}>
<input type="hidden" name="problemId" value={problemId} />
<textarea name="code" placeholder="Твоє рішення..." rows={10} />
<button type="submit">Відправити рішення</button>
</form>
)
}
// Форма відправляє на Server Action, записує в БД, оновлює кешовану сторінку задачіКоротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.