App Router vs Pages Router у Next.js
App Router vs Pages Router це вибір маршрутизації який робить кожен новий Next.js-проект. Pages Router це старіша client-first система де кожна сторінка рендериться в браузері і тягне серверні дані через helper-и на кшталт getServerSideProps. App Router це новіша система, представлена в Next.js 13 і стабільна з 13.4, де кожен компонент за замовчуванням це React Server Component, дані фетчаться всередині async-компонентів, а layouts вкладаються автоматично.
Теорія
Карта: два роутери, одна мета
Обидва роутери вирішують одну проблему, перетворити папку з файлами на робочий веб-застосунок. Вони просто по-різному вирішують де саме робиться робота. Pages Router запускає компонент на клієнті і викликає helper на сервері коли потрібні дані. App Router запускає компонент на сервері і відправляє в браузер лише шматочок markup-у.
Розглянемо п'ять речей по порядку. Навіщо існує другий роутер. Як кожен з них мапить файли на URL-и. Мінімальний код щоб запустити один маршрут у кожному. Порівняльна таблиця. І поширені помилки які ловлять людей на співбесідах і в реальних міграційних проектах.
Навіщо Next.js зробив другий роутер
Pages Router довго добре служив. Проблема була в тому, що кожна сторінка це клієнтський компонент, навіть якщо вона показує лише статичні дані. Це означало що браузер мав завантажити React, hydration-код і будь-яку бібліотеку яку сторінка імпортувала, навіть для сторінки яка ніколи не ре-рендерить. Це також означало що фетчинг даних жив у незграбних helper-функціях (getServerSideProps, getStaticProps), які не могли ділити стан з компонентом який вони годували.
React-команда розв'язала це через Server Components, компоненти які запускаються на сервері, віддають HTML у браузер, і ніколи не надсилають свій код клієнту. Pages Router не міг адаптувати цю модель чисто, бо його ментальна модель припускає що все є клієнтським. App Router це перебудова навколо моделі Server Components з першого дня.
Це питання зараз зустрічається майже на кожній співбесіді з Next.js. Інтерв'юер не питає про назву директорії, він перевіряє чи розумієш ти чому стара модель потребувала заміни.
Порівняння в одній таблиці
| Аспект | Pages Router | App Router |
|---|---|---|
| Директорія | pages/ | app/ |
| Компонент за замовчуванням | Клієнтський (React component) | Серверний (React Server Component) |
| Фетчинг даних | getServerSideProps, getStaticProps, getInitialProps | async-компоненти з fetch, плюс Server Actions |
| Layouts | Один глобальний wrapper у _app.tsx | Вкладені layout.tsx на будь-якій глибині |
| Loading UI | Пишеш сам | Файл loading.tsx з Suspense з коробки |
| Error boundaries | Глобальний _error.tsx | Per-route error.tsx |
| Streaming | Не підтримується | Так, через <Suspense> і loading.tsx |
| Metadata | Кастомний Head компонент | Функція generateMetadata на маршрут |
| Обробка форм | API routes плюс fetch на клієнті | Server Actions, без окремого API route |
| Серверна мутація | API route плюс form POST | 'use server' функція, повертається у компонент |
Як кожен роутер мапить файли на URL-и
Pages Router використовує pages/ і перетворює кожен .tsx файл на маршрут. pages/index.tsx стає /, pages/problems/index.tsx стає /problems, pages/[id].tsx стає /:id. Спеціальні файли починаються з підкреслення: _app.tsx обгортає все, _document.tsx переписує HTML-шел, _error.tsx обробляє винятки глобально.
App Router використовує app/ і іншу конвенцію. Маршрутизація керується файлами з фіксованими іменами: page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, template.tsx. Структура папок все ще формує URL, тож app/problems/page.tsx стає /problems. Але routing-файли можуть сидіти лише у файлах з цими конкретними іменами. Саме так Next.js розуміє що файл це маршрут, layout або loading-стан без підкреслень або інших конвенцій-маркерів.
Мінімальний маршрут у кожному роутері
Pages Router, з серверними даними:
// pages/problems/index.tsx
export async function getServerSideProps() {
const res = await fetch('https://api.itlead.org/problems')
const problems = await res.json()
return { props: { problems } }
}
export default function ProblemsPage({ problems }) {
return (
<ul>
{problems.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}App Router, той самий маршрут:
// app/problems/page.tsx
import { db } from '@/lib/db'
export default async function ProblemsPage() {
const problems = await db.problem.findMany()
return (
<ul>
{problems.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}Дві відмінності у цьому крихітному прикладі окупають себе щодня. Версія App Router не має окремої функції getServerSideProps, бо сам компонент запускається на сервері. Вона також імпортує @/lib/db прямо у файл сторінки, і цей імпорт ніколи не потрапляє у клієнтський bundle. У Pages Router тобі довелось би тримати імпорти БД у helper-і і передавати дані через props, щоб не злити драйвер у браузер.
Що відбувається коли застосунок стартує
Це послідовність яку Next.js проходить для App Router-застосунку на першому запиті до /problems:
- Крок 1: Next.js читає дерево
app/під час білду і перетворює коженpage.tsx,layout.tsxіloading.tsxна route manifest. - Крок 2: Приходить запит на
/problems. Next.js йде по дереву від кореня, збирає коженlayout.tsxпо дорозі, і зупиняється на відповідномуpage.tsx. - Крок 3: Кожен компонент на цьому шляху запускається на сервері. Async-компоненти зупиняються на своїх
await, runtime стрімить той HTML який вже готовий, а браузер отримує його як інкрементальний документ. - Крок 4: Будь-який дочірній компонент позначений
'use client'серіалізується з його props і відправляється у браузер як React component bundle. - Крок 5: Браузер гідратує лише клієнтські компоненти. Серверні компоненти залишаються як чистий HTML і ніколи не потрапляють у React runtime у браузері.
Pages Router навпаки запускає один getServerSideProps на сервері, відправляє результуючі props клієнту, і гідратує все дерево сторінки одразу. Streaming можливий в теорії, але ніхто так не будує, бо роутер для цього не спроектований.
Фетчинг даних без helper-функцій
Pages Router трактує фетчинг даних як щось що відбувається поза компонентом. Ти оголошуєш getServerSideProps або getStaticProps на рівні файлу, повертаєш { props }, і функція сторінки отримує ці props. Це працювало, але змушувало кожну data-залежну сторінку мати специфічну форму, і ускладнювало шарінг fetch-логіки між сторінкою і вкладеним компонентом.
App Router дозволяє будь-якому компоненту бути async. Layout може фетчити. Сторінка може фетчити. Вкладений серверний компонент може фетчити. Кожен await незалежний, і Next.js може почати стрімити готові частини поки решта ще вантажиться. Результат: фетчинг даних перестає бути file-level турботою і стає component-level турботою, саме так як React мав би працювати з самого початку, перш ніж SSR змусив зробити гак через getInitialProps.
Одна річ на яку варто зважати: fetch всередині App Router-компонента пропатчений. Next.js дедуплікує однакові fetch-и по компонентах в одному запиті і кешує відповіді на основі Next.js cache tags. Якщо два компоненти фетчать той самий URL, на мережу піде лише один запит.
Головна відмінність в одному реченні
Технічно: Pages Router це client-first file-based роутер з helper-ами для серверної роботи, App Router це server-first file-based роутер з явним opt-out на клієнтський рендеринг через 'use client'.
Простіше: Pages Router відправляє все в браузер і дає тобі відкушувати шматочки назад у сервер. App Router тримає все на сервері і дає тобі відкушувати шматочки вперед у браузер. Напрямок перевернутий, і саме з цього напрямку випливає кожна конкретна відмінність.
Правила які роутери не обходять
- Всередині
pages/Next.js дивиться лише на.tsx,.ts,.jsx,.js,.mdі.mdxфайли. Все інше статичне. - Всередині
app/лише файли з іменамиpage.tsx,layout.tsx,loading.tsx,error.tsx,not-found.tsx,template.tsx,default.tsxіroute.tsє routing-файлами. Будь-який інший файл у папці це ко-локований приватний модуль. - Компонент в
app/за замовчуванням серверний. Додавання'use client'зверху файлу робить його і його імпорти клієнтськими. Видалення директиви не перемикає файл назад, якщо він все ще викликає client-only API на кшталтuseState. getServerSideProps,getStaticPropsіgetInitialPropsце Pages Router only і ігноруються всерединіapp/. Зворотне теж правда:asyncpage-компоненти не підтримуються всерединіpages/.- Обидва роутери можуть співіснувати в одному проекті. Якщо той самий URL визначений в обох деревах, App Router перемагає. Це інтендований шлях міграції, переноси маршрути по одному, перевіряй в продакшені, переноси наступний.
Поширені помилки
Чи App Router це просто перейменування Pages Router з новою назвою папки?
Ні. Назва папки це найменша частина зміни. App Router це інша модель рендерингу побудована навколо React Server Components, streaming-у і вкладених layouts. Pages Router все ще стара client-first модель, і обидва роутери співіснують бо багато команд не можуть мігрувати за одну ніч. Трактування їх як косметичних варіантів це найшвидший шлях заплутатись, коли useState починає кидати сумнозвісну помилку "You're importing a component that needs useState" всередині App Router-сторінки.
Чи додавання 'use client' до файлу робить всю сторінку клієнтським компонентом?
Не зовсім. 'use client' позначає межу. Файл з директивою і все що він імпортує транзитивно стає клієнтським. Але батьківський серверний компонент все ще може рендерити 'use client'-дитину і передавати їй server-fetched props вниз. На проекті з Next.js 14 який я мігрував минулого кварталу, найбільшим сюрпризом було скільки наших листових компонентів потребували 'use client' бо торкалися window або використовували React Context. Ми позначили близько 40% листових компонентів і залишили решту як серверні, і це виявилось типовим розподілом.
Чи варто мігрувати існуючий Pages Router-застосунок на App Router прямо зараз?
Залежить. Pages Router все ще підтримується і отримує bug fixes. Міграція корисна коли ти хочеш server components, streaming або per-route layouts яких Pages Router дати не може. Для стабільного застосунку без болю міграція рідко окупається. Обидва роутери можуть жити в одному проекті, тож прагматичний хід, це використовувати App Router для нових маршрутів і залишити існуючі маршрути як є, поки не буде конкретної причини їх міняти.
Як App Router з'єднується з рештою Next.js
App Router це не просто нова папка роутера, це шлях яким прибувають нові Next.js-фічі. Server Actions для мутацій, partial prerendering для гібридних статичних і динамічних сторінок, нова модель кешування Next.js, generateMetadata для per-route SEO, parallel routes, intercepting routes і streaming скрізь, усе це живе на стороні App Router. Pages Router отримує bug fixes і випадкові compatibility patches, але frontier рухається лише в один бік.
Саме тому питання співбесіди варте глибокої відповіді. Людина яка запитує зазвичай перевіряє чи розумієш ти траєкторію фреймворку, не просто яка директорія тримає який файл.
Приклади
Список постів у кожному роутері
// 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-версія не має експортованого helper-а, не має окремої функції для даних, і не має інтерфейсу props для сторінки. Імпорт бази даних живе у файлі сторінки і ніколи не потрапляє у клієнт, бо весь файл це серверний компонент. Саме ця остання деталь реально змінює розмір твого bundle.
Вкладений 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 який фетчить на mount.
Тонкий момент: кожен з цих трьох файлів кешується незалежно. Sidebar залишається гідратованим коли змінюється лише контент сторінки.
Змішування 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 posts..."
/>
)
}Сторінка сама по собі серверний компонент. Вона читає searchParams, викликає searchPosts на сервері, і рендерить список результатів. SearchInput це клієнтський компонент бо йому потрібен useState і useRouter для досвіду друкування. Сторінка передає initialQuery вниз як prop, і це нормально бо рядки і числа серіалізуються чисто через межу server/client.
Пастка: ти не можеш передати сам searchPosts як prop клієнтському компоненту. Функції не серіалізуються. Якщо клієнту потрібно було б викликати пошук, ти б перетворив searchPosts на Server Action через 'use server' і передав би action як prop. Це саме той розподіл який багато команд неправильно роблять на першій міграції.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.