App Router vs Pages Router у Next.js
App Router vs Pages Router у Next.js - два роутери в одному фреймворку, які по-різному відповідають на одне питання: де відбувається рендеринг. Pages Router вважає, що роботу робить браузер і звертається до сервера за даними коли треба. App Router вважає, що роботу робить сервер, а браузер підключається за потребою.
Теорія
TL;DR
- Аналогія: Pages Router - це ресторан, де ти замовляєш за столиком і чекаєш поки кухня все приготує. App Router - шведський стіл, де тарілки приходять вже зібраними з сервера.
- Pages Router відправляє повний JS-бандл у браузер для гідратації. App Router стрімить server-rendered HTML і гідратує лише частини з
'use client'. - Вся різниця API в одному рядку:
getServerSidePropsпротиasync-компонента. - Новий проект: App Router. Існуючий проект на Pages: мігруй поступово, один маршрут за раз. Обидва роутери живуть в одному репо.
- Streaming, вкладені layouts, Server Actions і partial prerendering - все це є тільки в App Router.
Швидкий приклад
Pages Router фетчить дані поза компонентом і передає їх через props:
// pages/posts/[id].tsx
export async function getServerSideProps({ params }) {
const post = await fetch(`https://api.itlead.org/posts/${params.id}`).then(r => r.json())
return { props: { post } } // серіалізується в JSON, відправляється клієнту
}
export default function PostPage({ post }) {
return <h1>{post.title}</h1> // запускається в браузері після повної гідратації
}App Router об'єднує фетчинг і рендеринг в одному компоненті:
// app/posts/[id]/page.tsx
export default async function PostPage({ params }) {
const post = await fetch(`https://api.itlead.org/posts/${params.id}`).then(r => r.json())
return <h1>{post.title}</h1> // стрімиться як HTML, без повної гідратації
}Версія App Router не має окремого helper-а і не має інтерфейсу для props. fetch запускається на сервері і ніколи не потрапляє в клієнтський бандл.
Головна відмінність
Pages Router трактує фетчинг даних як file-level експорт, який виконується до рендерингу компонента. Сам компонент завжди є клієнтським і відправляється в браузер. App Router робить async server-компоненти дефолтом: вони самі фетчать дані, запускаються на сервері і відправляють HTML. Браузер гідратує лише частини позначені 'use client'. Решта залишається статичним markup-ом.
Коли що обирати
- Новий проект із SEO-вимогами: App Router. Server rendering плюс streaming дають кращий LCP на динамічному контенті.
- Існуючий код до Next.js 13: залишайся на Pages Router і мігруй нові маршрути в
app/поступово. - Статичний експорт (
output: 'export'): тільки Pages Router. React Server Components несумісні з режимом статичного експорту. - Команда, яка ще не знайома з RSC: Pages Router легше пояснити, поки команда освоює нову модель.
Таблиця порівняння
| Фіча | Pages Router (pages/) | App Router (app/) |
|---|---|---|
| З'явився | Next.js 9 (2019), дефолт до 13 | Next.js 13 (2023), стабільний 13.4+, дефолт у 14+ |
| Компонент за замовчуванням | Клієнтський | React Server Component |
| Фетчинг даних | getStaticProps, getServerSideProps | async-компоненти з fetch |
| Layouts | Глобальний _app.tsx | Вкладені layout.tsx на будь-якій глибині |
| Loading UI | Ручне управління станом | loading.tsx із Suspense з коробки |
| Обробка помилок | Глобальний _error.tsx | Per-route error.tsx |
| Streaming | Не підтримується | Так, через <Suspense> і loading.tsx |
| Metadata | <Head> компонент | generateMetadata на маршрут |
| Мутації | API route плюс client fetch | Server Actions з 'use server' |
| Розмір бандлу | Більший (повна гідратація) | Менший (server-компоненти не йдуть у браузер) |
| Кешування | Вручну | Автоматично через опції fetch |
| Коли використовувати | Legacy-проекти, статичний експорт | Нові проекти, динамічний UI, колокація даних |
Як обробляється запит
Pages Router при SSR-запиті: запустити getServerSideProps на сервері, серіалізувати результат у JSON, відправити props клієнту, гідратувати все дерево компонентів через hydrateRoot. Кожна сторінка відправляє свій код у браузер, навіть якщо вміст ніколи не змінюється після завантаження.
App Router працює інакше. Next.js сканує дерево app/ під час білду і генерує route manifest. На запит:
- Next.js проходить папки від кореня, збираючи кожен
layout.tsxпо шляху до знайденого маршруту. - Кожен компонент на цьому шляху запускається на сервері. Async-компоненти зупиняються на
await, поки runtime стрімить вже готовий HTML у браузер. - Компоненти з
'use client'серіалізуються з їхніми props у форматі RSC Payload (через React Flight по HTTP) і відправляються у браузер. - Браузер гідратує лише ці клієнтські компоненти. Server-компоненти залишаються чистим HTML і ніколи не потрапляють у React runtime браузера.
Один практичний момент: fetch всередині App Router компонентів пропатчений Next.js. Однакові виклики fetch в кількох компонентах в рамках одного запиту дедуплікуються автоматично. Якщо два компоненти звертаються до одного URL, у мережу йде лише один запит. У Pages Router такого немає.
Фетчинг даних як турбота компонента
Pages Router прив'язує фетчинг до рівня файлу. Ти пишеш getServerSideProps або getStaticProps як іменований експорт, повертаєш { props }, і сторінка отримує ці props. Це працювало, але обміняти fetch-логіку між сторінкою і глибоко вкладеним компонентом було незручно. Всі дані мали текти вниз через props від самого верху.
App Router дозволяє будь-якому компоненту бути async. Layout може фетчити. Сторінка може фетчити. Глибоко вкладений server-компонент фетчить незалежно. Кожен await обробляється паралельно де можливо, і Next.js починає стрімити готові частини поки інші ще вантажаться. Дані живуть поруч із компонентом, який їх використовує.
Важливо: відповіді fetch кешуються в App Router за замовчуванням. Використовуй { next: { revalidate: 3600 } } для time-based ревалідації або { cache: 'no-store' } коли потрібні свіжі дані кожного разу.
Поширені помилки
Позначити весь файл сторінки як 'use client'
'use client' // неправильно: вся сторінка стає клієнтським бандлом
export default function Page() {
const [open, setOpen] = useState(false)
return <div>...</div>
}Це скасовує всі переваги RSC. Весь файл їде в браузер, розмір бандлу приблизно подвоюється, TTFB зростає бо сервер не може стрімити HTML до завантаження JS. Виреж стану частину в невеликий дочірній компонент і позначай 'use client' лише його файл. Сама сторінка залишається server-компонентом.
Використовувати useState або useEffect в App Router-сторінці
// неправильно: хуки недоступні в React Server Components
export default function Page() {
const [data, setData] = useState(null)
useEffect(() => { fetchData().then(setData) }, []) // помилка білду
}Server-компоненти це async-функції, а не React-компоненти в традиційному розумінні. useState, useEffect, useContext - все це кидає помилку білду всередині app/. Використовуй async/await:
export default async function Page() {
const data = await fetchData() // запускається на сервері, хуки не потрібні
return <div>{data.title}</div>
}Чекати що провайдери з _app.tsx будуть доступні в app/
У гібридному режимі pages/_app.tsx і app/layout.tsx запускаються в окремих контекстах. React Context provider, доданий до _app.tsx, не видимий жодному маршруту в app/. Auth-провайдери і theme-провайдери треба або дублювати, або повністю переносити до app/layout.tsx після завершення міграції. Це ловить майже кожну команду на першому гібридному проекті.
Пропустити fallback у динамічних маршрутах Pages Router
export async function getStaticPaths() {
return { paths: [...], fallback: false } // 404 для шляхів не збілджених заздалегідь
}fallback: false підходить для контенту, який не росте після деплою. Для всього що додає нові шляхи з часом використовуй fallback: 'blocking' щоб увімкнути генерацію на вимогу без спалаху loading-стану.
Де зустрічається в реальних проектах
- Дашборд Vercel: App Router із вкладеними layouts що шарять навігацію і streaming для live-метрик.
- Linear: App Router parallel routes для модалок задач, що рендеряться поверх списку без перезавантаження сторінки.
- Дашборд Supabase: App Router server
fetchдля realtime-запитів до бази, колокований із компонентами що їх показують. - T3 Stack (
create-t3-app): App Router за замовчуванням з v10, в парі з tRPC і RSC. - Stripe Dashboard: гібридний режим, активно мігрує на App Router для payments UI що виграє від server-компонентів.
Питання на співбесіді
Q: Чому не можна використовувати getServerSideProps всередині app/?
A: Це конвенція Pages Router, яку Next.js читає тільки в директорії pages/. Аналог в App Router - async-компонент, що фетчить свої дані напряму. Обидві конвенції свідомо розділені і не взаємозамінні.
Q: Якщо я позначу компонент 'use client', він все ще може отримувати дані з сервера?
A: Так. Server-компонент може рендерити 'use client'-дитину і передавати їй server-fetched дані через props. Рядки, числа, прості об'єкти і масиви серіалізуються через межу без проблем. Функції не серіалізуються, тому fetch-функцію як prop передати не вийде. Для цього потрібен Server Action з 'use server'.
Q: Як Next.js дедуплікує fetch-запити в App Router?
A: fetch пропатчений в контексті App Router. Виклики з однаковим URL і однаковими опціями в рамках одного рендер-пасу дедуплікуються автоматично. У мережу йде лише один запит, скільки б компонентів його не викликали.
Q: Pages Router і App Router можуть постійно співіснувати в одному проекті?
A: Технічно так, Next.js це підтримує. На практиці спільне використання auth-провайдерів, стану сесії і layout-логіки між двома роутерами швидко ускладнюється. Більшість команд трактують гібридний режим як тимчасовий стан міграції, а не постійну архітектуру.
Q: Що ламається при деплої App Router на Edge Runtime?
A: Node.js API недоступні. fs, crypto і все що залежить від Node internals кинуть помилку на Edge в runtime. Бібліотеки на кшталт Prisma потребують адаптерів типу @prisma/adapter-edge. Використовуй Web API або обмеж Edge-деплой маршрутами без Node-специфічного коду.
Приклади
Список постів у кожному роутері
// pages/posts/index.tsx (Pages Router)
import type { GetServerSideProps } from 'next'
type Post = { id: string; title: string }
export const getServerSideProps: GetServerSideProps = async () => {
const res = await fetch('https://api.itlead.org/posts')
const posts: Post[] = await res.json()
return { props: { posts } }
}
export default function PostsPage({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}// app/posts/page.tsx (App Router)
import { db } from '@/lib/db'
export default async function PostsPage() {
const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } })
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}Версія App Router не має exported helper-а, окремої функції для даних і props-інтерфейсу. Імпорт бази даних живе у файлі сторінки і залишається на сервері. Саме цей момент реально змінює розмір бандлу в продакшені. У Pages Router довелось би тримати db всередині getServerSideProps і передавати дані через props, щоб драйвер не потрапив у браузер.
Вкладений layout зі стрімінгом
// app/dashboard/layout.tsx
import { Sidebar } from '@/components/sidebar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
)
}// app/dashboard/loading.tsx
import { DashboardSkeleton } from '@/components/dashboard-skeleton'
export default function Loading() {
return <DashboardSkeleton />
}// app/dashboard/page.tsx
import { getRecentActivity } from '@/lib/activity'
export default async function DashboardPage() {
const activity = await getRecentActivity()
return (
<section>
<h1>Recent activity</h1>
<ul>
{activity.map((item) => (
<li key={item.id}>{item.description}</li>
))}
</ul>
</section>
)
}Коли користувач переходить на /dashboard, Next.js одразу рендерить layout і sidebar, потім показує DashboardSkeleton поки getRecentActivity() ще виконується. Коли дані прийшли, скелетон міняється на реальний контент. Sidebar не ре-рендериться, бо layouts зберігають стан під час навігації всередині свого піддерева. У Pages Router це все треба було б збирати вручну: useState, спінер і useEffect з fetch на mount. Три файли тут замінюють усе це, і кожен кешується незалежно.
Server і client компоненти на одній сторінці
Типовий сценарій зі співбесіди. Що тут потребує 'use client', а що ні?
// app/search/page.tsx
import { searchPosts } from '@/lib/search'
import { SearchInput } from './search-input'
export default async function SearchPage({
searchParams,
}: {
searchParams: { q?: string }
}) {
const query = searchParams.q ?? ''
const results = query ? await searchPosts(query) : []
return (
<div>
<SearchInput initialQuery={query} />
<ul>
{results.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}// app/search/search-input.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export function SearchInput({ initialQuery }: { initialQuery: string }) {
const [value, setValue] = useState(initialQuery)
const router = useRouter()
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') router.push(`/search?q=${value}`)
}}
placeholder="Search..."
/>
)
}Сама сторінка - server-компонент: читає searchParams, викликає searchPosts на сервері, рендерить список результатів. SearchInput потребує 'use client' бо використовує useState і useRouter. Сторінка передає initialQuery як prop - це нормально, бо рядки серіалізуються через межу server/client без проблем.
Те, що багато команд неправильно роблять на першій міграції: не можна передати сам searchPosts як prop клієнтському компоненту. Функції не серіалізуються. Якщо клієнтський компонент мав би викликати пошук напряму, треба загорнути searchPosts у Server Action через 'use server' і передати action як prop.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.