Skip to main content

Ключові особливості 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.

Гібридний рендеринг

Суть ідеї: один застосунок, кілька стратегій рендерингу. Ти обираєш для кожного маршруту.

tsx
// 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.tsxUI маршруту, робить сегмент публічним
layout.tsxОбгортка, яка зберігається між навігаціями
loading.tsxUI завантаження на основі Suspense
error.tsxError boundary для сегменту
not-found.tsxUI для 404
tsx
// 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 є серверним за замовчуванням. Його код виконується на сервері, ніколи не потрапляє в браузерний бандл і може безпосередньо звертатися до бази даних, змінних середовища та файлової системи.

tsx
// 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' на початок файлу. Патерн, який добре працює в продакшені: завантаження даних залишається в серверних компонентах, інтерактивна логіка виноситься в невеликі клієнтські дочірні компоненти.

tsx
'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' і викликаєш як звичайну функцію.

tsx
// 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') }
tsx
// Клієнтський компонент - окремий 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 зберігаються між навігаціями. Компонент монтується один раз і залишається змонтованим, поки користувач переходить між дочірніми маршрутами.

tsx
// 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 автоматично. Ти вказуєш розміри, решту робить компонент.

tsx
import Image from 'next/image' export default function Avatar() { return ( <Image src="/avatar.png" width={64} height={64} alt="Аватар користувача" /> ) }

next/link попередньо завантажує сторінки у фоні, коли посилання потрапляє у видиму область. Коли користувач клікає - сторінка вже завантажена.

tsx
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, жодного зсуву макету через пізнє завантаження шрифтів.

tsx
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 тут недоступні.

tsx
// 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:

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') без опцій, закешує відповідь при збірці і роздаватиме однакові дані всім.

tsx
// Неправильно - кешується як SSG, дає застарілі дані const data = await fetch('/api/user'); // Правильно - свіжі дані при кожному запиті const data = await fetch('/api/user', { cache: 'no-store' });

Додавати 'use client' на всю сторінку заради одного інтерактивного елемента. Це переводить всю сторінку на CSR і прибирає серверний HTML. Стан і обробники подій треба виносити в невеликі дочірні клієнтські компоненти.

tsx
// Неправильно - вся сторінка стає 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 секунд. Користувачі завжди отримують валідну закешовану відповідь. Оновлення відбувається без деплою.

tsx
// 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> вся сторінка чекає на найповільніший компонент.

tsx
// 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-файл не потрібен.

tsx
// 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}`) }
tsx
// 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, записує в БД, оновлює кешовану сторінку задачі

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

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

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

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