Skip to main content

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:

tsx
// 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 об'єднує фетчинг і рендеринг в одному компоненті:

tsx
// 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), дефолт до 13Next.js 13 (2023), стабільний 13.4+, дефолт у 14+
Компонент за замовчуваннямКлієнтськийReact Server Component
Фетчинг данихgetStaticProps, getServerSidePropsasync-компоненти з fetch
LayoutsГлобальний _app.tsxВкладені layout.tsx на будь-якій глибині
Loading UIРучне управління станомloading.tsx із Suspense з коробки
Обробка помилокГлобальний _error.tsxPer-route error.tsx
StreamingНе підтримуєтьсяТак, через <Suspense> і loading.tsx
Metadata<Head> компонентgenerateMetadata на маршрут
МутаціїAPI route плюс client fetchServer Actions з 'use server'
Розмір бандлуБільший (повна гідратація)Менший (server-компоненти не йдуть у браузер)
КешуванняВручнуАвтоматично через опції fetch
Коли використовуватиLegacy-проекти, статичний експортНові проекти, динамічний UI, колокація даних

Як обробляється запит

Pages Router при SSR-запиті: запустити getServerSideProps на сервері, серіалізувати результат у JSON, відправити props клієнту, гідратувати все дерево компонентів через hydrateRoot. Кожна сторінка відправляє свій код у браузер, навіть якщо вміст ніколи не змінюється після завантаження.

App Router працює інакше. Next.js сканує дерево app/ під час білду і генерує route manifest. На запит:

  1. Next.js проходить папки від кореня, збираючи кожен layout.tsx по шляху до знайденого маршруту.
  2. Кожен компонент на цьому шляху запускається на сервері. Async-компоненти зупиняються на await, поки runtime стрімить вже готовий HTML у браузер.
  3. Компоненти з 'use client' серіалізуються з їхніми props у форматі RSC Payload (через React Flight по HTTP) і відправляються у браузер.
  4. Браузер гідратує лише ці клієнтські компоненти. 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'

tsx
'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-сторінці

tsx
// неправильно: хуки недоступні в 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:

tsx
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

tsx
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-специфічного коду.

Приклади

Список постів у кожному роутері

tsx
// 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> ) }
tsx
// 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 зі стрімінгом

tsx
// 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> ) }
tsx
// app/dashboard/loading.tsx import { DashboardSkeleton } from '@/components/dashboard-skeleton' export default function Loading() { return <DashboardSkeleton /> }
tsx
// 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', а що ні?

tsx
// 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> ) }
tsx
// 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.

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

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

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

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