Skip to main content

Next.js

На мой взгляд, Next.js - это лучший на сегодняшний день инструмент для разработки веб-приложений.

Обратите внимание: руководство актуально для Next.js версии 14.

Введение

Что такое Next.js?

Next.js - это фреймворк React для создания клиент-серверных (fullstack) веб-приложений. Мы используем компоненты React для разработки UI (user interface - пользовательский интерфейс) и Next.js для дополнительных возможностей и оптимизаций.

Под капотом Next.js также абстрагирует и автоматически настраивает инструменты, необходимые React, такие как сборка, компиляция и др. Это позволяет сосредоточиться на разработке приложения вместо того, чтобы тратить время на настройку этих инструментов.

Next.js помогает разрабатывать интерактивные, динамичные и быстрые приложения React.

Основные возможности

Некоторые из основных возможностей, предоставляемых Next.js:

ВозможностьОписание
Маршрутизация (далее также - роутинг)Основанный на файловой системе маршрутизатор (далее также - роутер), разработанный на основе серверных компонентов, поддерживающий макеты (layouts), вложенный роутинг, состояние загрузки, обработку ошибок и др.
РендерингКлиентский и серверный рендеринг с помощью соответствующих компонентов. Возможность дальнейшей оптимизации с помощью статического и динамического рендеринга на сервере за счет граничной (edge) потоковой передачи Next.js и среды выполнения Node.js
Запрос/получение данныхУпрощенное получение данных с помощью async/await в серверных компонентах. Расширенный Fetch API для мемоизации запросов, кеширования и ревалидации данных
СтилизацияПоддержка разных способов стилизации, включая модули CSS, TailwindCSS и CSS-в-JS
ОптимизацииОптимизация изображений, шрифтов и скриптов для улучшения показателей Core Web Vitals приложения и UX (user experience - пользовательский опыт)
TypeScriptУлучшенная поддержка TS с лучшей проверкой типов и более эффективной компиляцией, а также кастомный плагин TS и средство проверки типов

Установка

Требования к системе:

  • Node.js 18.17+
  • macOS, Windows (включая WSL) и Linux

Для создания нового проекта рекомендуется использовать CLI (command line interface - интерфейс командной строки) create-next-app:

npx create-next-app@latest

Ручная установка Next.js.

Структура проекта Next.js

Директории верхнего уровня

Директории верхнего уровня предназначены для организации кода приложения и статических ресурсов.


ДиректорияНазначение
appРоутер приложения
pagesРоутер страниц
publicСтатические файлы
srcОпциональная директория кода приложения

Файлы верхнего уровня

Файлы верхнего уровня используются для настройки приложения, управления зависимостями, запуска посредников (middleware - промежуточное программное обеспечение), интеграции инструментов мониторинга и определения переменных окружения.

ФайлНазначение
next.config.jsНастройки Next.js
package.jsonЗависимости и скрипты проекта
instrumentation.tsТелеметрия
middleware.tsПосредники
.envПеременные окружения
.env.localПеременные локального окружения
.env.productionПеременные производственного окружения
.env.developmentПеременные рабочего окружения
.eslintrc.jsonНастройки ESLint
.gitignoreФайлы и директории, игнорируемые Git
next-env.d.tsФайл определений типов Next.js
tsconfig.jsonНастройки TypeScript
jsconfig.jsonНастройки JavaScript

Соглашения о файлах директории app

В роутере приложения используются следующие соглашения для определения роутов и обработки метаданных:

Файлы роутов

ФайлРасширениеНазначение
layout.js .jsx .tsxМакет
page.js .jsx .tsxСтраница
loading.js .jsx .tsxUI загрузки
not-found.js .jsx .tsxUI отсутствующей страницы
error.js .jsx .tsxUI ошибки
global-error.js .jsx .tsxUI глобальной ошибки
route.js .tsКонечная точка API
template.js .jsx .tsxПовторно используемый макет
default.js .jsx .tsxРезервная страница параллельных роутов

Вложенные роуты

НазваниеНазначение
folderСегмент роута
folder/folderВложенный сегмент роута

Динамические роуты

НазваниеНазначение
[folder]Сегмент динамического роута
[...folder]Сегмент роута-перехватчика
[[..folder]]Сегмент опционального роута-перехватчика (catch-all route)

Группы роутов и закрытые директории

НазваниеНазначение
(folder)Группировка роутов без влияния на роутинг (вид URL (uniform resource locator - единообразный указатель местонахождения ресурса))
_folderОпциональная директория, потомки которой не влияют на роутинг

Параллельные и перехваченные роуты

НазваниеНазначение
@folderИменованный слот
(.)folderПерехват роута того же уровня
(..)folderПерехват роута верхнего уровня
(..)(..)folderПерехват роута на два уровня выше
(...)folderПерехват роута корневого уровня

Соглашения о файлах с метаданными

Иконки приложения

ФайлРасширениеНазначение
favicon.icoФавиконка
icon.ico .jpg .jpeg .png .svgИконка приложения
icon.js .ts .tsxГенерируемая иконка приложения
apple-icon.jpg .jpeg, .pngИконка приложения Apple
apple-icon.js .ts .tsxГенерируемая иконка приложения Apple

Изображения Open Graph и Twitter

ФайлРасширениеНазначение
opengraph-image.jpg .jpeg .png .gifИзображение Open Graph
opengraph-image.js .ts .tsxГенерируемое изображение Open Graph
twitter-image.jpg .jpeg .png .gifИзображение Twitter
twitter-image.js .ts .tsxГенерируемое изображение Twitter

SEO (search engine optimization - оптимизация движка поиска)

ФайлРасширениеНазначение
sitemap.xmlSitemap
sitemap.js .tsГенерируемый Sitemap
robots.txtRobots
robots.js .tsГенерируемый Robots

Роутинг

Основы

Терминология


  • tree (дерево) - соглашение о визуализации иерархической структуры. Например, дерево компонентов с родительским и дочерними компонентами, структура директории и др.
  • subtree (поддерево) - часть дерева, начинающаяся в новом корне (root) и заканчивающаяся в листьях (leaves)
  • root (корень) - первый узел дерева или поддерева, такой как корневой макет
  • leaf (лист) - узел поддерева, не имеющий потомков, такой как последней сегмент URL

  • URL segment (сегмент URL) - часть URL, отделенная слэшем
  • URL path (путь URL) - часть URL, следующая за доменом (состоит из сегментов)

Роутер app

В версии 13 Next.js представил новый роутер приложения, разработанный на основе серверных компонентов React, поддерживающий общие (shared) макеты, вложенный роутинг, состояние загрузки, обработку ошибок и др.

Роуты приложения теперь находятся в директории app. Раньше такой директорией являлась pages. Эти директории могут существовать совместно, например, во время перехода с pages на app (или до того, как это сделают библиотеки экосистемы Next.js), но app имеет приоритет.

По умолчанию компоненты внутри app являются серверными. Существуют также клиентские компоненты. Мы поговорим об этом позже.

Роли директорий и файлов

В Next.js используется роутинг на основе файловой системы, где:

  • директории используются для определения роутов. Роут - это единичный путь вложенных директорий, следующий иерархии файловой системы от корневой директории к финальной листовой директории, содержащей файл page.js
  • файлы используются для создания UI, отображаемого для сегмента роута

Сегменты роута

Каждая директория в роуте представляет его сегмент. Каждый сегмент роута связан с соответствующим сегментом URL.


Вложенные роуты

Для создания вложенных роутов директории вкладываются друг в друга. Например, мы можем добавить новый роут /dashboard/settings путем вложения двух новых директорий в директорию app.

Роут /dashboard/settings состоит из трех сегментов:

  • / (корневой сегмент)
  • dashboard (сегмент)
  • settings (листовой сегмент)

Соглашения о файлах

Next.js предоставляет специальные файлы для создания UI с определенным поведением во вложенных роутах:

НазваниеНазначение
layoutОбщий UI для сегмента и его потомков
pageУникальный UI роута. Делает роут открытым (публично доступным)
loadingUI загрузки для сегмента и его потомков
not-foundUI отсутствующей страницы для сегмента и его потомков
errorUI ошибки для сегмента и его потомков
global-errorUI глобальной ошибки
routeСерверная конечная точка API
templateСпециальный повторно используемый UI макета
defaultРезервный UI для параллельных роутов

Для специальных файлов могут использоваться расширения .js, .jsx или .tsx.

Иерархия компонентов

Компоненты, определенные в специальных файлах сегмента роута, рендерятся в следующем порядке:

-layout.js -template.js -error.js (React error boundary (предохранитель)) -loading.js (React suspense boundary (для ленивой загрузка, частичного рендеринга и др.)) -not-found.js (предохранитель) -page.js или вложенный layout.js


Во вложенном роуте компоненты сегмента будет вложенными внутри компонентов их родительского сегмента.


Совместное размещение

В дополнение к специальным, мы можем добавлять собственные файлы (компоненты, стили, тесты и др.) в директории app.

Это возможно благодаря тому, что публично доступным является только содержимое файлов page.js и route.js.


Продвинутые паттерны роутинга

Роутер приложения также позволяет реализовывать более продвинутые паттерны роутинга:

  • параллельные роуты - позволяют одновременно показывать несколько страниц в одном отображении (view). На страницы можно переходить независимо. Параллельные роуты используются для разделения отображений, содержащих собственную навигацию. Примером такого компонента может служить панель управления (dashboard)
  • перехватывающие роуты - позволяют перехватывать роут и показывать его содержимое в контексте другого роута. Они используются в случаях, когда важно сохранить контекст текущей страницы. Примером такой ситуации может служить просмотр всех задач при редактировании одной из них и увеличение изображения в галерее

Эти паттерны позволяют разрабатывать более богатые и сложные UI.

Определение роутов

Создание роута

В Next.js для определения роутов используются директории.

Каждая директория представляет сегмент роута, связанный с сегментом URL. Для создания вложенного роута директории вкладываются друг в друга.


Для того, чтобы сделать сегмент роута доступным публично, используется специальный файл page.js.


В этом примере URL /dashboard/analytics не доступен публично, поскольку в соответствующей директории нет файла page.js. Эта директория может использоваться для хранения компонентов, таблиц стилей, изображений и других файлов.

Создание UI

Для создания UI для сегмента роута используются специальные файлы. Самыми распространенными являются страницы для отображения уникального UI роута и макеты для отображения UI, который является общим для нескольких роутов.

Например, для создания первой страницы добавьте файл page.js в директорию app и экспортируйте по умолчанию какой-нибудь компонент:

// app/page.tsx
export default function Page() {
return <h1>Привет, Next.js!</h1>
}

Страницы и макеты

Страницы

Страница - это UI, который является уникальным для роута. Страница представляет собой экспортируемый по умолчанию из файла page.js компонент.

Например, для создания страницы index добавьте файл page.js в директорию app:


// `app/page.tsx` - это UI для URL `/`
export default function Page() {
return <h1>Главная страница</h1>
}

Макеты

Макет - это UI, который является общим для нескольких роутов. При навигации (переходах между страницами) состояние макета сохраняется, он остается интерактивным и не рендерится повторно. Макеты также могут быть вложенными.

Макет представляет собой компонент, экспортируемый по умолчанию из файла layout.js. Этот компонент должен принимать проп children, который заполняется (populate) дочерним макетом (при наличии) или страницей в процессе рендеринга.

Например, следующий макет будет общим для страниц /dashboard и /dashboard/settings:


// app/page.tsx
export default function DashboardLayout({
children, // страница или вложенный макет
}: {
children: React.ReactNode
}) {
return (
<section>
{/* Общий UI, например, шапка или боковая панель */}
<nav></nav>

{children}
</section>
)
}

Корневой макет (обязательный)

Корневой макет определяется на верхнем уровне директории app и применяется ко всем роутам приложения. Этот макет является обязательным и должен содержать теги html и body. Он позволяет модифицировать начальный HTML, возвращаемый сервером.

// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
{/* UI макета */}
<main>{children}</main>
</body>
</html>
)
}

Вложенные макеты

По умолчанию макеты в иерархии директорий являются вложенными. Это означает, что они оборачивают дочерние макеты в проп children. Макеты могу вкладываться друг в друга путем добавления layout.js в определенные сегменты роута (директории).

Например, для создания макета роута /dashboard добавьте новый файл layout.js в директорию dashboard:


// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return <section>{children}</section>
}

При комбинации этих двух макетов, корневой макет (app/layout.js) будет оборачивать макет панели управления (app/dashboard/layout.js), который будет оборачивать сегменты роута внутри app/dashboard/*.

Два макета буду вложены друг в друга следующим образом:


Шаблоны

Шаблоны похожи на макеты в том, что они оборачивают каждый дочерний макет или страницу. В отличие от макетов, которые сохраняются между роутами и поддерживают состояние, шаблоны создают новый экземпляр для каждого потомка при навигации. Это означает, что когда пользователь перемещается между роутами, которые делят шаблон, монтируется новый экземпляр компонента, элементы DOM (document object model - объектная модель документа) создаются заново, состояние сбрасывается и эффекты заново синхронизируются.

В некоторых случаях шаблоны предпочтительнее макетов:

  • на странице имеется функционал, зависящий от useEffect (например, логирование показов страницы) и useState (например, постраничная форма обратной связи)
  • необходимо изменить дефолтное поведение фреймворка. Например, компонент Suspense внутри макета показывает резервный контент только при первой загрузке макета, а не при переключении страниц. Для шаблонов резервный контент отображается при каждой навигации

Шаблон определяется путем дефолтного экспорта компонента из файла template.js. Этот компонент должен принимать проп children.


// app/template.tsx
export default function Template({ children }: { children: React.ReactNode }) {
return <div>{children}</div>
}

С точки зрения вложенности, шаблон рендерится между макетом и его потомками. Упрощенно это выглядит так:

<Layout>
{/* Обратите внимание, что шаблон имеет уникальный ключ */}
<Template key={routeParam}>{children}</Template>
</Layout>

Метаданные

Metadata API позволяет модифицировать элементы head, такие как title и meta, в директории app.

Метаданные могут определяться путем экспорта объекта metadata или функции generateMetadata в файлах layout.js или page.js.

// app/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
title: 'Next.js',
}

export default function Page() {
return '...'
}

Навигация

Существует 4 способа навигации между роутами:

  • компонент Link
  • хук useRouter (клиентские компоненты)
  • функция redirect (серверные компоненты)
  • нативный History API

Компонент Link

Link - это встроенный компонент, расширяющий HTML-элемент a для предоставления предварительного получения данных (prefetching) и клиентской навигации между роутами. Это основной и рекомендуемый способ навигации между роутами в Next.js.

Этот компонент импортируется из next/link и принимает проп href:

// app/page.tsx
import Link from 'next/link'

export default function Page() {
return <Link href="/dashboard">Панель управления</Link>
}

Примеры

Ссылка на динамические сегменты

При ссылке на динамические сегменты можно использовать шаблонные литералы и интерполяцию для генерации списка ссылок. Пример генерации списка постов блога:

// app/blog/PostList.js
import Link from 'next/link'

export default function PostList({ posts }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
)
}

Проверка активных ссылок

Для определения активности ссылки можно использовать хук usePathname. Например, для добавления класса к активной ссылке можно проверять совпадение pathname со значением пропа href ссылки:

// app/components/links.tsx
'use client'

import { usePathname } from 'next/navigation'
import Link from 'next/link'

export function Links() {
const pathname = usePathname()

return (
<nav>
<ul>
<li>
<Link className={`link ${pathname === '/' ? 'active' : ''}`} href="/">
Главная
</Link>
</li>
<li>
<Link
className={`link ${pathname === '/about' ? 'active' : ''}`}
href="/about"
>
Контакты
</Link>
</li>
</ul>
</nav>
)
}

Прокрутка к id

Дефолтный поведением роутера приложения является прокрутка в начало новой страницы или сохранение положения прокрутки при навигации вперед-назад.

Для прокрутки к определенному id при навигации можно добавить хэш (#) к URL или передать хэш в проп href. Это возможно благодаря тому, что компонент Link рендерится в элемент a.

<Link href="/dashboard#settings">Настройки</Link>

// Результат
<a href="/dashboard#settings">Настройки</a>

Отключение восстановления прокрутки

Для отключения дефолтного поведения прокрутки можно передать scroll={false} в компонент Link или scroll: false в методы router.push или router.replace.

// next/link
<Link href="/dashboard" scroll={false}>
Панель управления
</Link>
// useRouter
import { useRouter } from 'next/navigation'

const router = useRouter()

router.push('/dashboard', { scroll: false })

Хук useRouter

Хук useRouter позволяет программно менять роуты в клиентских компонентах:

// app/page.tsx
'use client'

import { useRouter } from 'next/navigation'

export default function Page() {
const router = useRouter()

return (
<button type="button" onClick={() => router.push('/dashboard')}>
Панель управления
</button>
)
}

Функция redirect

В серверных компонентах вместо хука useRouter следует использовать функцию redirect для программной навигации между роутами:

// app/team/[id]/page.tsx
import { redirect } from 'next/navigation'

async function fetchTeam(id: string) {
const res = await fetch('https://...')
if (!res.ok) return undefined
return res.json()
}

export default async function Profile({ params }: { params: { id: string } }) {
const team = await fetchTeam(params.id)

if (!team) {
redirect('/login')
}

// ...
}

Нативный History API

Next.js позволяет использовать нативные методы window.history.pushState и window.history.replaceState для обновления стека истории браузера без перезагрузки страницы.

Вызовы методов pushState и replaceState интегрированы в роутер Next.js, что позволяет выполнять синхронизацию с хуками usePathname и useSearchParams.

window.history.pushState

Этот метод позволяет добавлять новую сущность в стек истории браузера. Пользователь может возвращаться к предыдущему состоянию. Пример сортировки списка товаров:

'use client'

import { useSearchParams } from 'next/navigation'

export default function SortProducts() {
const searchParams = useSearchParams()

function updateSorting(sortOrder: string) {
const params = new URLSearchParams(searchParams.toString())
params.set('sort', sortOrder)
window.history.pushState(null, '', `?${params.toString()}`)
}

return (
<>
<button onClick={() => updateSorting('asc')}>Сортировать по убыванию</button>
<button onClick={() => updateSorting('desc')}>Сортировать по возрастанию</button>
</>
)
}

window.history.replaceState

Этот метод позволяет заменять текущую сущность в стеке истории браузера. Пользователь не может возвращаться к предыдущему состоянию. Пример переключения языка приложения:

'use client'

import { usePathname } from 'next/navigation'

export function LocaleSwitcher() {
const pathname = usePathname()

function switchLocale(locale: string) {
// Например, '/en/about' или '/fr/contact'
const newPath = `/${locale}${pathname}`
window.history.replaceState(null, '', newPath)
}

return (
<>
<button onClick={() => switchLocale('en')}>Английский</button>
<button onClick={() => switchLocale('fr')}>Французский</button>
</>
)
}

Как работают роутинг и навигация?

Роутер приложения использует гибридный подход для роутинга и навигации. На сервере код приложения автоматически разделяется на части (code splitting) по сегментам роута. На клиенте Next.js предварительно получает данные (prefetching) и кеширует сегменты роута. Это означает, что когда пользователь переходит к новому роуту, браузер не перезагружает страницу, и только изменившиеся сегменты роута рендерятся повторно - это улучшает опыт навигации и производительность.

1. Разделение кода

Разделение кода позволяет разделить код приложения на небольшие части (chunks) для загрузки и выполнения браузером. Это уменьшает количество передаваемых данных и время выполнения каждого запроса, что улучшает производительность.

Серверные компоненты позволяют автоматически разделять код по сегментам роута. Это означает, что при навигации загружается только код, необходимый для текущего роута.

2. Предварительное получение данных

Предварительное получение данных - это способ предварительной загрузки данных роута в фоновом режиме перед посещением роута пользователем.

Существует два способа предварительного получения данных роута:

  • компонент Link - данные роута автоматически запрашиваются при попадании ссылки в область видимости. Это происходит при загрузке страницы или во время прокрутки
  • метод router.prefetch - для программного предварительного получения данных роута может использоваться роутер, возвращаемый хуком useRouter

Поведение Link в части предварительного получения данных различается для статических и динамических роутов:

  • статические роуты - значением prefetch по умолчанию является true. Весь роут предварительно запрашивается и кешируется
  • динамические роуты - только общий макет дерева компонентов до первого файла loading.js предварительно запрашивается и кешируется на 30 секунд. Это уменьшает цену запроса всего динамического роута и позволяет незамедлительно отображать состояние загрузки для лучшего визуального отклика на действия пользователей

Предварительное получение данных можно отключить путем установки prefetch в значение false.

3. Кеширование

Next.js использует клиентский кеш в памяти, который называется кешем роутера (router cache). При навигации пользователя по приложению полезная нагрузка серверных компонентов, предварительно запрошенных сегментов роута и посещенные роуты записываются в кеш.

Это означает максимальное использование кеша при навигации вместо отправки запросов на сервер - улучшение производительности путем уменьшения количества запросов и передаваемых между клиентом и сервером данных.

4. Частичный рендеринг

Частичный рендеринг означает, что при навигации повторно рендерятся только сегменты роута, которые изменились, а любые общие сегменты сохраняются.

Например, при навигации между двумя соседними роутами, /dashboard/settings и /dashboard/analytics, будут отрендерены страницы settings и analytics, а общий макет dashboard будет сохранен.


Без частичного рендеринга каждая навигация будет приводить к повторному рендерингу всей страницы на клиенте. Рендеринг только изменившихся сегментов уменьшает количество передаваемых данных и время выполнения, что приводит к улучшению производительности.

5. Мягкая навигация

Браузеры выполняют "жесткую навигацию" (hard navigation) при переключении между страницами. Роутер приложения Next.js выполняет "мягкую навигацию" (soft navigation) между страницами, обеспечивая повторный рендеринг только изменившихся сегментов роута (частичный рендеринг). Это позволяет сохранять клиентское состояние в процессе навигации.

6. Навигация вперед-назад

По умолчанию Next.js сохраняет положение прокрутки для навигации вперед-назад и повторно использует сегменты роута из кеша роутера.

UI загрузки и потоковая передача данных

Специальный файл loading.js помогает создавать осмысленный UI загрузки с помощью компонента Suspense. Он позволяет мгновенно отображать состояние загрузки во время получения содержимого сегмента роута. Новое содержимое автоматически добавляется после завершения рендеринга.


Мгновенное состояние загрузки

Мгновенное состояние загрузки - это резервный UI, который отображается сразу после навигации. Мы можем предварительно рендерить индикаторы загрузки, такие как скелеты и спинеры или небольшие части будущего экрана, такие как обложка, заголовок и др. Это помогает пользователю понять, что приложение работает и обеспечивает лучший UX.

Создайте состояние загрузки путем добавления файла loading.js в директорию.


export default function Loading() {
// Мы можем добавлять любой UI внутрь `Loading`, такой как скелет
return <LoadingSkeleton />
}

В той же директории loading.js будет вложен в layout.js. Он будет оборачивать page.js и его потомков в компонент Suspense.


Потоковая передача данных с помощью Suspense

В дополнение к loading.js мы можем создавать Suspense для своих компонентов UI. Роутер приложения поддерживает потоковую передачу (streaming, далее также - стриминг) как для Node.js, так и для граничной среды выполнения.

Что такое стриминг?

Для того, чтобы понять, что такое стриминг в React и Next.js, нужно понимать рендеринг на стороне сервера (server side rendering, SSR) и его ограничения.

В SSR существует несколько шагов, который должны завершиться перед тем, как пользователь сможет увидеть и взаимодействовать со страницей:

  1. Все данные, необходимые странице, запрашиваются у сервера.
  2. Сервер рендерит HTML для страницы.
  3. HTML, CSS и JavaScript для страницы отправляются клиенту.
  4. Неинтерактивный UI отображается с помощью сгенерированного HTML и CSS.
  5. React гидратирует UI для того, чтобы сделать его интерактивным.

Эти шаги являются последовательными и блокирующими. Сервер может отрендерить HTML для страницы только после получения всех данных. На клиенте React может гидратировать UI только после загрузки кода всех компонентов.

SSR с помощью React и Next.js помогает улучшить производительной загрузки путем отображения неинтерактивной страницы пользователю как можно быстрее.


Однако это все равно может быть медленным, поскольку получение данных на сервере должно быть завершено перед отображением страницы пользователю.

Стриминг позволяет разбить HTML страницы на небольшие части (chunks) и отправлять их клиенту по-отдельности.


Это позволяет отображать части страницы быстрее, без ожидания получения всех данных для UI.

Стриминг хорошо работает с компонентной моделью React, поскольку каждый компонент может рассматриваться как "чанк" (chunk - часть). Компоненты, которые имеют высший приоритет (например, информация о товаре) или не зависят от данных (например, макет), могут быть отправлены первыми и React может начать их гидратацию раньше. Компоненты с более низким приоритетом (например, отзывы или связанные товары) могут быть отправлены в том же запросе к серверу после получения всех данных.


Стриминг особенно полезен в случаях, когда мы хотим избежать блокировки рендеринга страницы долгими запросами, поскольку это может ухудшить Time To First Byte (TTFB) и First Contentful Paint (FCP). Это также помогает улучшить Time to Interactive (TTI), особенно на медленных устройствах.

Пример

<Suspense> оборачивает компонент, выполняющий асинхронную операцию (например, запрос данных), отображает резервный UI (например, скелет или спинер) во время выполнения операции и заменяет содержимое компонента после завершения операции.

import { Suspense } from 'react'
import { PostFeed, Weather } from './Components'

export default function Posts() {
return (
<section>
<Suspense fallback={<p>Загрузка ленты новостей...</p>}>
<PostFeed />
</Suspense>
<Suspense fallback={<p>Загрузка погоды...</p>}>
<Weather />
</Suspense>
</section>
)
}

Использование Suspense дает следующие преимущества:

  1. Потоковый серверный рендеринг - прогрессивный рендеринг HTML.
  2. Выборочная гидратация - React гидратирует компоненты на основе их приоритетов.

SEO

  • Next.js ожидает получения данных в функции generateMetadata перед началом стриминга UI клиенту. Это гарантирует, что первая часть такого ответа будет содержать теги <head>
  • поскольку стриминг является серверным, он не влияет на SEO

Статус-коды

При стриминге статус-код 200 является индикатором успешного запроса.

Сервер может отправлять клиенту ошибки в потоковом контенте, например, когда используются функции redirect или notFound, но статус-код обновляться не будет. Это не влияет на SEO.

Обработка ошибок

Файл error.js позволяет мягко обрабатывать ошибки времени выполнения во вложенных роутах:

  • автоматически оборачивает сегмент роута и его потомков в React Error Boundary (предохранитель)
  • создает UI ошибки, привязанный к определенному сегменту, используя иерархию файловой системы для настройки детализации
  • изолирует ошибки на уровне сегмента, что позволяет нормально функционировать остальной части приложения
  • добавляет функционал восстановления после ошибки без полной перезагрузки страницы

Создайте UI ошибки, добавив файл error.js в директорию и экспортировав из него компонент по умолчанию.


// app/dashboard/error.tsx
'use client' // компоненты `Error` должны быть клиентскими

import { useEffect } from 'react'

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Отправляем ошибку в сервис обработки ошибок
console.error(error)
}, [error])

return (
<div>
<h2>Что-то пошло не так</h2>
<button
onClick={
// Пытаемся восстановиться путем повторного рендеринга сегмента
() => reset()
}
>
Попробовать снова
</button>
</div>
)
}

Как error.js работает?


  • error.js автоматически создает предохранитель, оборачивающий вложенный дочерний сегмент или компонент page.js
  • компонент, экспортируемый из error.js, используется в качестве резервного компонента
  • при возникновении ошибки внутри предохранителя, ошибка перехватывается? и рендерится резервный компонент
  • когда резервный компонент активен, макеты выше предохранителя сохраняют состояние и остаются интерактивными, а компонент ошибки может предоставлять возможности по восстановлению

Восстановление после ошибки

Причина ошибки может временной. В этом случае повторное выполнение операции, например, может решить проблему.

Компонент ошибки может использовать функцию reset для восстановления. Эта функция повторно рендерит содержимое предохранителя. При успехе резервный компонент ошибки заменяется результатом повторного рендеринга.

// app/dashboard/error.tsx
'use client'

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Что-то пошло не так</h2>
<button onClick={() => reset()}>Попробовать снова</button>
</div>
)
}

Вложенные роуты

Компоненты, созданные с помощью специальных файлов, рендерятся в определенном порядке.

Например, вложенный роут с двумя сегментами, включающими layout.js и error.js, рендерится в такую (упрощенную) иерархию:


Иерархия компонентов влияет на поведение error.js во вложенном роуте:

  • ошибки всплывают к ближайшему родительскому предохранителю. Это означает, что error.js будет обрабатывать ошибки всех вложенных дочерних сегментов. Детализация UI ошибки достигается размещением файлов error.js на разных уровнях (в разных директориях) роута
  • error.js не обрабатывает ошибки, возникшие в layout.js того же уровня, поскольку предохранитель оборачивается в макет

Обработка ошибок в макетах

error.js не перехватывает ошибки, возникшие в layout.js или template.js того же уровня. Это объясняется тем, что layout.js или template.js содержат важный общий UI для нескольких соседних роутов, который должен функционировать, несмотря на ошибку.

Для обработки ошибок, возникающих в layout.js или template.js, используется error.js родительского сегмента.

Для обработки ошибок, возникающих в корневом макете или шаблоне, используется global-error.js.

Обработка ошибок в корневом макете

Предохранитель global-error.js оборачивает все приложение, его резервный компонент заменяет корневой макет, поэтому он должен содержать теги <html> и <body>.

global-error.js - это наименее детальный UI ошибки, который может рассматриваться на перехватчик всех ошибок приложения. Он не предназначен для частого использования, поскольку большая часть ошибок должна перехватываться и обрабатываться соответствующими error.js.

Даже при наличии global-error.js рекомендуется определять корневой error.js, чей резервный компонент будет рендериться внутри корневого макета - глобальный общий UI и бренд, например, будут сохраняться.

// app/global-error.tsx
'use client'

export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>Что-то пошло не так</h2>
<button onClick={() => reset()}>Попробовать снова</button>
</body>
</html>
)
}

Обработка серверных ошибок

Если ошибка возникает внутри серверного компонента, Next.js перенаправляет объект Error (лишенный конфиденциальной информации об ошибке в производственной среде) в ближайший файл error.js в качестве пропа error.

В продакшне Error содержит только свойства message и digest. Это мера безопасности, позволяющая избежать утечки потенциально конфиденциальной информации, содержащейся в ошибке.

message содержит общее сообщение об ошибке, digest - автоматически генерируемый хэш ошибки, который может использоваться для поиска соответствующей ошибки в логах сервера.

В режиме разработки Error сериализуется и содержит message оригинальной ошибки для облегчения отладки.

Перенаправление

В Next.js существует несколько способов обработки перенаправлений.

APIНазначениеГдеСтатус-код
redirectПеренаправляет пользователя после мутации или событияСерверные компоненты, серверные операции, обработчики роута307 (временное) или 303 (серверная операция)
permanentRedirectПеренаправляет пользователя после мутации или событияСерверные компоненты, серверные операции, обработчики роута308 (постоянное)
useRouterВыполняет навигацию на стороне клиентаОбработчики событий в клиентских компонентах-
redirectsПеренаправляет входящий запрос на основе путиФайл next.config.js307 (временное) или 308 (постоянное)
NextResponse.redirectПеренаправляет входящий запрос на основе условияПосредникЛюбой

Функция redirect

Функция redirect позволяет перенаправлять пользователя на другой URL. Ее можно вызывать в серверных компонентах, серверных операциях и обработчиках роута.

redirect часто используется после мутации или события. Пример создания поста:

// app/actions.tsx
'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function createPost(id: string) {
try {
// Обращение к базе данных
} catch (error) {
// Обработка ошибок
}

revalidatePath('/posts') // обновление кешированных постов
redirect(`/post/${id}`) // перенаправление на страницу нового поста
}

Функция permanentRedirect

Функция permanentRedirect позволяет постоянно (permanently) перенаправлять пользователя на другой URL. Ее можно вызывать в серверных компонентах, серверных операциях и обработчиках роута.

permanentRedirect часто используется после мутации или события, которое меняет канонический URL сущности, например, обновления URL профиля пользователя после изменения имени пользователя:

// app/actions.ts
'use server'

import { permanentRedirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'

export async function updateUsername(username: string, formData: FormData) {
try {
// Обращение к БД
} catch (error) {
// Обработка ошибок
}

revalidateTag('username') // обновляем все ссылки на `username`
permanentRedirect(`/profile/${username}`) // перенаправляем в новый профиль пользователя
}

Хук useRouter

Для выполнения перенаправления в обработчике события в клиентском компоненте используется метод push роутера, возвращаемого хуком useRouter, например:

// app/page.tsx
'use client'

import { useRouter } from 'next/navigation'

export default function Page() {
const router = useRouter()

return (
<button type="button" onClick={() => router.push('/dashboard')}>
Панель управления
</button>
)
}

redirects

Настройка redirects файла next.config.js позволяет перенаправлять входящие запросы на другой URL. Это может быть полезным, когда мы изменили структуру URL страниц и нам известен список перенаправлений.

redirects поддерживает поиск совпадения с путем, заголовками, куки и строкой запроса, что предоставляет гибкость для перенаправления пользователя на основе входящего запроса.

Для использования redirects достаточно добавить следующую настройку в next.config.js:

module.exports = {
async redirects() {
return [
// Обычное перенаправление
{
source: '/about',
destination: '/',
permanent: true,
},
// Поиск совпадения пути с подстановочными знаками
{
source: '/blog/:slug',
destination: '/news/:slug',
permanent: true,
},
]
},
}

NextResponse.redirect

Посредник позволяет запускать код перед завершением запроса. Функция NextResponse.redirect позволяет перенаправлять пользователя на другой URL на основе входящего запроса. Это может быть полезным, когда мы хотим перенаправлять пользователя на основе определенного условия (например, аутентификация, управление сессией и др.) или у нас имеется большое количество перенаправлений.

Пример перенаправления неавторизованного пользователя на страницу /login:

// middleware.ts
import { NextResponse, NextRequest } from 'next/server'
import { authenticate } from 'auth-provider'

export function middleware(request: NextRequest) {
const isAuthenticated = authenticate(request)

// Если пользователь авторизован, пропускаем запрос
if (isAuthenticated) {
return NextResponse.next()
}

// Если пользователь не авторизован, перенаправляем его на страницу авторизации
return NextResponse.redirect(new URL('/login', request.url))
}

// Посредник запускается для любого роута панели управления
export const config = {
matcher: '/dashboard/:path*',
}

Группы роутов

В директории app вложенные директории, как правило, влияют на URL. Однако, мы можем сделать директорию группой роутов, чтобы она не включалась в URL роута.

Это позволяет организовать сегменты роута и файлы проекта в логические группы без влияния на структуру URL.

Группы роутов могут быть полезны для:

  • организации роутов в группы, например, по разделам сайта, назначению или командам
  • реализации вложенных макетов на том же уровне роута
    • создания нескольких вложенных макетов в том же сегменте, включая несколько корневых макетов
    • добавления макета в набор роутов в общем сегменте

Соглашение

Для создания группы роутов достаточно обернуть название директории в круглые скобки: (folderName).

Примеры

Организация роутов без влияния на URL

Для организации роутов без влияния на URL, создайте группы для того, чтобы держать связанные роуты вместе. Директории в скобках не включаются в URL ((marketing) или (shop)).


Несмотря на то, что роуты внутри (marketing) и (shop) используют одну иерархию URL, мы можем создавать разные макеты для каждой группы путем добавления в директории файлов layout.js.


Создание макета для определенных сегментов

Для создания макета для определенных роутов нужно создать группу роутов ((shop)) с файлом layout.js в ней и переместить туда роуты, которые должны использовать один макет (account и cart). Роуты, не входящие в группу, не будут использовать общий макет (checkout).


Создание нескольких корневых макетов

Для создания нескольких корневых макетов нужно удалить верхнеуровневый layout.js и добавить layout.js в каждую группу. Это может быть полезным для разделения приложения на части, которые имеют совершенно разный UI или UX. Теги <html> и <body> должны содержаться в каждом макете.


В приведенном примере (marketing) и (shop) имеют собственные корневые макеты.

Организация проекта и совместное размещение файлов

Кроме соглашений о файлах и директориях для роутинга, Next.js не ограничивает нас в организации и совместном размещении (colocation) файлов проекта.

Безопасная колокация по умолчанию

В директории app вложенная иерархия директорий определяет структуру роутов.

Каждая директория представляет сегмент роута и определяет соответствующий сегмент URL.

Однако директории по умолчанию являются закрытыми (не доступны публично).


Публично доступным является только содержимое файлов page.js и route.js.


Это означает, что файлы проекта, размещаемые в сегментах роута, никогда не буду доступны извне.


Возможности организации проекта

Next.js предоставляет несколько возможностей по организации кода проекта.

Закрытые директории

Директории, названия которых начинаются с нижнего подчеркивания, являются закрытыми: _folderName.

Такие директории исключаются из роутинга.


Поскольку файлы в директории app могут безопасно размещаться по умолчанию, закрытые директории для этого не требуются. Однако они могут использоваться для:

  • отделения логики UI от логики роутинга
  • организации внутренних файлов проекта и экосистемы Next.js
  • сортировки и группировки файлов в редакторах кода
  • предотвращения конфликтов с будущими соглашениями о названиях файлов Next.js

Группы роутов

Директории, названия которых заключены в круглые скобки, считаются группами роутов: (folderName).

Такие директории нужны для организации файлов и не влияют на URL роута.


Группы роутов могут быть полезны для:

  • организации роутов в группы, например, по разделам сайта, назначению или командам
  • реализации вложенных макетов на том же уровне роута
    • создания нескольких вложенных макетов в том же сегменте, включая несколько корневых макетов
    • добавления макета в набор роутов в общем сегменте

Директория src

Next.js поддерживает хранение кода приложения (включая директорию app) в опциональной директории src. Это позволяет отделить код приложения от файлов настроек.


Синонимы путей модулей

Next.js поддерживает синонимы путей модулей, которые облегчают чтение и поддержку импортов в глубоко вложенных файлах проекта:

// app/dashboard/settings/analytics/page.js
// До
import { Button } from '../../../components/button'

// После
import { Button } from '@/components/button'

Стратегии организации проекта

Когда речь заходит об организации файлов и директорий в проекте Next.js, не существует правильных или неправильных подходов.

Ниже предлагается несколько распространенных стратегий. Какой вариант выбрать или придумать свой, зависит от потребностей приложения.

Хранение файлов проекта за пределами директории app

Данная стратегия предполагает хранение кода приложения в общих директориях в корне проекта и использование директории app только для целей роутинга.


Хранение файлов в директориях верхнего уровня директории app

Данная стратегия предполагает хранение кода приложения в общих директориях на верхнем уровне директории app.


Разделение файлов по функционалу или роуту

Данная стратегия предполагает хранение глобального общего кода приложения в корне директории app и разделение более специфического кода на сегменты роута, которые этот код используют.


Динамические роуты

Динамические сегменты предназначены для создания роутов во время запросов или их предварительного рендеринга во время сборки, когда мы не знаем точных названий сегментов во время разработки и хотим создавать роуты из динамических данных.

Соглашение

Динамический сегмент создается путем оборачивания названия директории в квадратные скобки: [folderName], например, [id] или [slug].

Динамические сегменты передаются макету, странице, роуту и функции generateMetadata в качестве пропа params.

Пример

Блог может включать роут app/blog/[slug]/page.js, где [slug] - это динамический сегмент постов блога.

// app/blog/[slug]/page.tsx
export default function Page({ params }: { params: { slug: string } }) {
return <div>Пост: {params.slug}</div>
}
РоутПример URLparams
app/blog/[slug]/page.js/blog/a{ slug: 'a' }
app/blog/[slug]/page.js/blog/b{ slug: 'b' }
app/blog/[slug]/page.js/blog/c{ slug: 'c' }

Генерация статических параметров

Функция generateStaticParams может быть использована в сочетании с динамическими сегментами для статической генерации роутов по время сборки, а не во время запроса.

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())

return posts.map((post) => ({
slug: post.slug,
}))
}

Основным преимуществом generateStaticParams является умное извлечение данных. Если содержимое запрашивается с помощью функции fetch в generateStaticParams, запросы автоматически мемоизируются. Это означает, что fetch с одинаковыми параметрами в разных generateStaticParams, макетах и страницах будет выполнен один раз, что снижает время сборки.

Сегменты-перехватчики

Динамические сегменты могут расширяться до перехватчиков (catch-all) путем добавления многоточия к названию директории в квадратных скобках: [...folderName].

Например, app/shop/[...slug]/page.js будет совпадать не только с /shop/clothes, но также с /shop/clothes/tops, /shop/clothes/tops/t-shirts и т.д.

РоутПример URLparams
app/shop/[...slug]/page.js/shop/a{ slug: ['a'] }
app/shop/[...slug]/page.js/shop/a/b{ slug: ['a', 'b'] }
app/shop/[...slug]/page.js/shop/a/b/c{ slug: ['a', 'b', 'c'] }

Опциональные сегменты-перехватчики

Сегменты-перехватчики можно сделать опциональными, заключив параметр в двойные квадратные скобки: [[...folderName]].

Например, app/shop/[[...slug]]/page.js будет совпадать также с /shop в дополнение к /shop/clothes, /shop/clothes/tops и /shop/clothes/tops/t-shirts.

Отличие опциональных сегментов-перехватчиков от обычных состоит в том, что опциональные совпадают с роутом без параметра (/shop).

РоутПример URLparams
app/shop/[[...slug]]/page.js/shop{}
app/shop/[[...slug]]/page.js/shop/a{ slug: ['a'] }
app/shop/[[...slug]]/page.js/shop/a/b{ slug: ['a', 'b'] }
app/shop/[[...slug]]/page.js/shop/a/b/c{ slug: ['a', 'b', 'c'] }

TypeScript

params можно типизировать, например:

export default function Page({ params }: { params: { slug: string } }) {
return <h1>Страница</h1>
}
РоутТип params
app/blog/[slug]/page.js{ slug: string }
app/shop/[...slug]/page.js{ slug: string[] }
app/shop/[[...slug]]/page.js{ slug?: string[] }
app/[categoryId]/[itemId]/page.js{ categoryId: string, itemId: string }

Параллельные роуты

Параллельные роуты позволяют одновременно или условно рендерить несколько страниц в одном макете. Они полезны для высокодинамичных разделов страницы, таких как панели управления и ленты новостей.

В качестве примера рассмотрим панель управления, в которой параллельные роуты используются для одновременного рендеринга страниц team и analytics:


Слоты

Параллельные роуты создаются с помощью именованных слотов. Слоты определяются с помощью @folderName. Пример определения двух слотов, @analytics и @team:


Слоты передаются как пропы общему родительскому макету. В приведенном примере компонент в app/layout.js принимает пропы analytics и team и может рендерить их одновременно, наряду с пропом children:

// app/layout.tsx
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<>
{children}
{team}
{analytics}
</>
)
}

Слоты не являются сегментами роута и не влияют на URL. Например, для /dashboard/@analytics/views URL будет выглядеть как /dashboard/views, поскольку @analytics - это слот.

Активное состояние и навигация

По умолчанию Next.js отслеживает активное состояние (подстраницу) каждого слота. Однако контент, который рендерится внутри слота, будет зависеть от типа навигации:

  • мягкая навигация - во время клиентской навигации Next.js выполняет частичный рендеринг, меняет подстраницу в слоте, сохраняя активные подстраницы других слотов, даже если они не совпадают с текущим URL
  • жесткая навигация - после полной перезагрузки страницы (перезагрузки браузера) Next.js не может определить активное состояние слотов, которые не совпадают с текущим URL. Поэтому он рендерит файл default.js для не совпавших слотов или страницу ошибки 404, если default.js отсутствует

default.js

Файл default.js позволяет определить резервный контент для несовпадающих слотов во время начальной загрузки или полной перезагрузки страницы.

Рассмотрим следующую структуру директорий. Слот @team содержит страницу /settings, а слот @analytics не содержит.


При переходе на /dashboard/settings, слот @team отрендерит страницу /settings, сохранив активную страницу для слота @analytics.

При перезагрузке Next.js отрендерит default.js для @analytics. Если default.js отсутствует, рендерится 404.js.

Кроме того, поскольку children - это неявный слот, для него также требуется default.js для рендеринга резервного контента, когда Next.js не может восстановить активное состояние родительской страницы.

useSelectedLayoutSegment(s)

Хуки useSelectedLayoutSegment и useSelectedLayoutSegments принимают параметр parallelRoutesKey, который позволяет читать активный сегмент роута в слоте:

// app/layout.tsx
'use client'

import { useSelectedLayoutSegment } from 'next/navigation'

export default function Layout({ auth }: { auth: React.ReactNode }) {
const loginSegments = useSelectedLayoutSegment('auth')
// ...
}

Когда пользователь переходит на /login, loginSegments возвращает "login".

Примеры

Условные роуты

Параллельные роуты могут использоваться для условного рендеринга роутов, например, на основе роли пользователя. Пример рендеринга разных панелей управления для ролей admin и user:


// app/dashboard/layout.tsx
import { checkUserRole } from '@/lib/auth'

export default function Layout({
user,
admin,
}: {
user: React.ReactNode
admin: React.ReactNode
}) {
const role = checkUserRole()

return <>{role === 'admin' ? admin : user}</>
}

Группы табов

Если добавить в слот файл layout.js, то макет будет доступен отдельно. Это полезно для создания табов.

Пример слота @analytics с двумя подстраницами, /page-views и /visitors:


// app/dashboard/@analytics/layout.tsx
import Link from 'next/link'

export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav>
<Link href="/dashboard/page-views">Просмотры страницы</Link>
<Link href="/dashboard/visitors">Посетители</Link>
</nav>
<div>{children}</div>
</>
)
}

Модальные окна

Параллельные роуты могут использоваться совместно с перехватывающими роутами для создания модалок. Это позволяет решать такие задачи, как:

  • содержимое модалки доступно через URL
  • сохранение контекста при перезагрузке страницы
  • закрытие модалки при навигации "назад"
  • повторное открытие модалки при навигации "вперед"

Рассмотрим паттерн UI, когда пользователь может открыть модалку авторизации из макета с помощью клиентской навигации или через отдельную страницу /login:


Начнем с создания роута /login, который рендерит основную страницу авторизации:


// app/login/page.tsx
import { Login } from '@/app/ui/login'

export default function Page() {
return <Login />
}

Добавляем файл default.js в слот @auth, возвращающий null. Это гарантирует, что будет рендерится только активная модалка.

// app/@auth/default.tsx
export default function Default() {
return null
}

В слоте @auth перехватываем роут /login путем изменения названия директории на (.)login. Импортируем компонент Modal и его потомков в файл (.)login/page.tsx:

import { Modal } from '@/app/ui/modal'
import { Login } from '@/app/ui/login'

export default function Page() {
return (
<Modal>
<Login />
</Modal>
)
}

Теперь для открытия/закрытия модалки можно использовать роутер Next.js. Это обеспечивает правильное обновление URL при открытии модалки, а также при навигации "вперед-назад".

Для открытия модалки передаем слот @auth как проп в родительский макет и рендерим его наряду с пропом children:

// app/layout.tsx
import Link from 'next/link'

export default function Layout({
auth,
children,
}: {
auth: React.ReactNode
children: React.ReactNode
}) {
return (
<>
<nav>
<Link href="/login">Открыть модальное окно</Link>
</nav>
<div>{auth}</div>
<div>{children}</div>
</>
)
}

Когда пользователь кликает по ссылке, открывается модалка вместо перехода на страницу /login. Однако при перезагрузке или начальной загрузке пользователь окажется на основной странице авторизации.

Для закрытия окна можно вызвать метод router.back или использовать компонент Link:

// app/ui/modal.tsx
'use client'

import { useRouter } from 'next/navigation'

export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter()

return (
<>
<button
onClick={() => {
router.back()
}}
>
Закрыть модальное окно
</button>
<div>{children}</div>
</>
)
}

При использовании компонента Link для того, чтобы уйти со страницы, которая больше не должна отображать слот @auth, можно использовать роут-перехватчик, возвращающий null:

// app/ui/modal.tsx
import Link from 'next/link'

export function Modal({ children }: { children: React.ReactNode }) {
return (
<>
<Link href="/">Закрыть модальное окно</Link>
<div>{children}</div>
</>
)
}
// app/@auth/[...catchAll]/page.tsx
export default function CatchAll() {
return null
}

UI загрузки и ошибки

Параллельные роуты могут передаваться по-отдельности, что позволяет определить состояния загрузки и ошибки для каждого роута:


Перехватывающие роуты

Перехватывающие роуты позволяют загружать роут из другой части приложения в текущий макет. Эта парадигма роутинга может быть полезна, когда мы хотим отображать контент роута без перемещения пользователя в другой контекст.

Например, при клике по фото в ленте новостей, мы можем отображать фото в модалке, перекрывающей ленту. В этом случае Next.js перехватывает роут /photo/123, маскирует URL и перекрываем им /feed.


Однако при переходе к фото путем клика по ссылке или после перезагрузки страницы, вместо модалки должна рендериться страница фотографии. В этих случаях роут не должен перехватываться.

Соглашение

Перехватывающие роуты определяются с помощью (..), что похоже на относительный путь ../, но для сегментов.

Мы можем использовать:

  • (.) для поиска совпадений с сегментами того же уровня
  • (..) для поиска совпадений с сегментами уровнем выше
  • (..)(..) для поиска совпадений с сегментами двумя уровнями выше
  • (...) для поиска совпадений с сегментами, начиная с корня директории app

Например, мы можем перехватить сегмент photo из сегмента feed, создав директорию (..)photo:


Примеры

Модальные окна

Перехватывающие роуты могут использоваться совместно с параллельными роутами для создания модалок. Это позволяет решать такие задачи, как:

  • содержимое модалки доступно через URL
  • сохранение контекста при перезагрузке страницы
  • закрытие модалки при навигации "назад"
  • повторное открытие модалки при навигации "вперед"

Рассмотрим паттерн UI, когда пользователь может открыть модалку с фото из галереи с помощью клиентской навигации или клика по ссылке:


В этом примере путь к сегменту photo может быть перехвачен с помощью (..), поскольку @modal - это не слот и не сегмент. Это означает, что роут photo находится всего на один уровень выше, несмотря на то, что в иерархии файловой системы он находится на два уровня выше.

Обработчики роута

Обработчики роута позволяют создавать кастомные обработчики запросов для роутов с помощью Request и Response Web API.


Соглашение

Обработчик роута определяется в файле route.js внутри директории app:

// app/api/route.ts
export const dynamic = 'force-dynamic' // по умолчанию `auto`
export async function GET(request: Request) {}

В одном сегменте роута не может быть одновременно и route.js, и page.js.

Поддерживаемые методы HTTP

Поддерживаются следующие методы HTTP: GET, POST, PUT, PATCH, DELETE, HEAD и OPTIONS. При использовании неподдерживаемого метода Next.js возвращает ответ 405 Method Not Allowed.

NextRequest и NextResponse

Next.js расширяет Request и Response API с помощью NextRequest и NextResponse, соответственно, предоставляя утилиты для продвинутых случаев использования.

Поведение

Кеширование

Обработчики роута кешируются по умолчанию при использовании метода GET и объекта Response:

// app/items/route.ts
export async function GET() {
const res = await fetch('https://data.mongodb-api.com/...', {
headers: {
'Content-Type': 'application/json',
'API-Key': process.env.DATA_API_KEY,
},
})
const data = await res.json()

return Response.json({ data })
}

Отключение кеширования

Автоматическое кеширование отключается в случае:

  • использования объекта Request с методом GET
  • использования других методов HTTP
  • использования динамических функций, таких как cookies и headers
  • ручного определения динамического режима с помощью настроек сегмента роута

Пример использования объекта Request:

// app/products/api/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')

const res = await fetch(`https://data.mongodb-api.com/product/${id}`, {
headers: {
'Content-Type': 'application/json',
'API-Key': process.env.DATA_API_KEY,
},
})
const product = await res.json()

return Response.json({ product })
}

Пример использования метода POST:

// app/items/route.ts
export async function POST() {
const res = await fetch('https://data.mongodb-api.com/...', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'API-Key': process.env.DATA_API_KEY!,
},
body: JSON.stringify({ time: new Date().toISOString() }),
})
const data = await res.json()

return Response.json(data)
}

Разрешение роута

route можно считать простейшим примитивом роутинга:

  • они не участвуют в макетировании или клиентских навигациях, как page
  • в одном роуте не может быть одновременно и route.js, и page.js

Каждый route.js или page.js потребляет все глаголы HTTP для этого роута:

// app/page.js
export default function Page() {
return <h1>Привет, Next.js!</h1>
}

// ❌ Конфликт
// app/route.js
export async function POST(request) {}

Примеры

Ревалидация кешированных данных

Кешированные данные можно обновлять с помощью настройки next.revalidate:

// app/items/route.ts
export async function GET() {
const res = await fetch('https://data.mongodb-api.com/...', {
next: { revalidate: 60 }, // Ревалидировать каждые 60 секунд
})
const data = await res.json()

return Response.json(data)
}

Также можно использовать настройку сегмента роута revalidate:

export const revalidate = 60

Динамические функции

В обработчиках роутов могут использоваться динамические функции, такие как cookies и headers.

cookies

Функция cookies из next/headers позволяет читать и устанавливать куки. Эта серверная функция может вызываться прямо в обработчике роута или из другой функции.

В качестве альтернативы можно вернуть новый Response с заголовком Set-Cookie:

// app/api/route.ts
import { cookies } from 'next/headers'

export async function GET(request: Request) {
const cookieStore = cookies()
const token = cookieStore.get('token')

return new Response('Привет, Next.js!', {
status: 200,
headers: { 'Set-Cookie': `token=${token.value}` },
})
}

Мы также можем использовать Request для чтения куки из запроса:

// app/api/route.ts
import type { NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
const token = request.cookies.get('token')
}

headers

Функция headers из next/headers позволяет читать и устанавливать заголовки. Эта серверная функция может вызываться прямо в обработчике роута или из другой функции.

Экземпляр headers доступен только для чтения. Для установки заголовков нужно вернуть новый Response с новыми headers:

// app/api/route.ts
import { headers } from 'next/headers'

export async function GET(request: Request) {
const headersList = headers()
const referer = headersList.get('referer')

return new Response('Привет, Next.js!', {
status: 200,
headers: { referer: referer },
})
}

Мы также можем использовать Request для чтения заголовка из запроса:

// app/api/route.ts
import { type NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
const requestHeaders = new Headers(request.headers)
}

redirect

// app/api/route.ts
import { redirect } from 'next/navigation'

export async function GET(request: Request) {
redirect('https://nextjs.org/')
}

Динамические сегменты роута

Обработчики роута могут использовать динамические сегменты роута для создания обработчиков запроса из динамических данных:

// app/items/[slug]/route.ts
export async function GET(
request: Request,
{ params }: { params: { slug: string } }
) {
const slug = params.slug // 'a', 'b' или 'c'
}
РоутПример URLparams
app/items/[slug]/route.js/items/a{ slug: 'a' }
app/items/[slug]/route.js/items/b{ slug: 'b' }
app/items/[slug]/route.js/items/c{ slug: 'c' }

Строка запроса URL

Объект запроса, передаваемый обработчику роута, это экземпляр NextRequest, который содержит некоторые дополнительные методы, облегчающие обработку запроса:

// app/api/search/route.ts
import type { NextRequest } from 'next/server'

export function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('query')
// `query` будет иметь значение `"hello"` для `/api/search?query=hello`
}

Потоковая передача

Потоковая передача данных часто используется совместно с большими языковыми моделями, такими как OpenAI, для контента, генерируемого искусственным интеллектом:

// app/api/chat/route.ts
import OpenAI from 'openai'
import { OpenAIStream, StreamingTextResponse } from 'ai'

const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})

export const runtime = 'edge'

export async function POST(req: Request) {
const { messages } = await req.json()
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
stream: true,
messages,
})

const stream = OpenAIStream(response)

return new StreamingTextResponse(stream)
}

Эти абстракции используют Web API для создания потока. Это также можно сделать вручную:

// app/api/route.ts
// https://developer.mozilla.org/docs/Web/API/ReadableStream#convert_async_iterator_to_stream
function iteratorToStream(iterator: any) {
return new ReadableStream({
async pull(controller) {
const { value, done } = await iterator.next()

if (done) {
controller.close()
} else {
controller.enqueue(value)
}
},
})
}

function sleep(time: number) {
return new Promise((resolve) => {
setTimeout(resolve, time)
})
}

const encoder = new TextEncoder()

async function* makeIterator() {
yield encoder.encode('<p>Один</p>')
await sleep(200)
yield encoder.encode('<p>Два</p>')
await sleep(200)
yield encoder.encode('<p>Три</p>')
}

export async function GET() {
const iterator = makeIterator()
const stream = iteratorToStream(iterator)

return new Response(stream)
}

Тело запроса

Тело запроса можно читать с помощью стандартных методов:

// app/items/route.ts
export async function POST(request: Request) {
const res = await request.json()

return Response.json({ res })
}

FormData

FormData можно читать с помощью функции request.formData:

// app/items/route.ts
export async function POST(request: Request) {
const formData = await request.formData()
const name = formData.get('name')
const email = formData.get('email')

return Response.json({ name, email })
}

Поскольку все данные FormData являются строками, для валидации запроса и извлечения данных в нужном формате (например, number) можно воспользоваться zod-form-data.

CORS

Заголовки CORS можно устанавливать с помощью стандартных методов:

// app/api/route.ts
export const dynamic = 'force-dynamic' // по умолчанию `auto`

export async function GET(request: Request) {
return new Response('Привет, Next.js!', {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
})
}

Веб-хуки

Обработчики роута можно использовать для получения веб-хуков из сторонних сервисов:

// app/api/route.ts
export async function POST(request: Request) {
try {
const text = await request.text()
// Обработка полезной нагрузки веб-хука
} catch (error) {
return new Response(`Ошибка: ${error.message}`, {
status: 400,
})
}

return new Response('Успех', {
status: 200,
})
}

Граничная и Node.js среды выполнения

Обработчики роута имеют изоморфный Web API для поддержки граничной (edge) и Node.js сред выполнения, включая поддержку стриминга. Поскольку обработчики роута имеют такую же конфигурацию сегмента роута, что и страницы и макеты, они поддерживают продвинутые возможности, такие как статическая регенерация.

Для определения среды выполнения используется настройка runtime:

export const runtime = 'edge' // по умолчанию `nodejs`

Ответы не UI

Обработчики роута могут возвращать контент, не связанный с UI. Обратите внимание, что sitemap.xml, robots.txt, иконки приложения и изображения OpenGraph имеют встроенную поддержку.

// app/rss.xml/route.ts
export const dynamic = 'force-dynamic'

export async function GET() {
return new Response(
`<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">

<channel>
<title>Документация Next.js</title>
<link>https://nextjs.org/docs</link>
<description>Фреймворк React для веба</description>
</channel>

</rss>`,
{
headers: {
'Content-Type': 'text/xml',
},
}
)
}

Настройки сегмента роута

Обработчики роута имеют такую же конфигурацию сегмента роута, что и страницы и макеты:

// app/items/route.ts
export const dynamic = 'auto'
export const dynamicParams = true
export const revalidate = false
export const fetchCache = 'auto'
export const runtime = 'nodejs'
export const preferredRegion = 'auto'

Посредники

Посредник (middleware) позволяет запускать код перед завершением обработки запроса для модификации ответа путем перезаписи, перенаправления, изменения заголовков запроса или ответа и раннего ответа.

Посредник запускается перед возвратом кешированного контента и совпадением роутов.

Соглашение

Для определения посредника используется файл middleware.js в корне проекта (на одном уровне с директорией app или src).

Пример

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// Эта функция может быть асинхронной
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url))
}

export const config = {
matcher: '/about/:path*',
}

Совпадающие пути

Посредник вызывается для каждого роута приложения. Порядок выполнения следующий:

  1. headers из next.config.js.
  2. redirects из next.config.js.
  3. Посредник (перезаписи, перенаправления и др.).
  4. beforeFiles (перезаписи) из next.config.js
  5. Роуты файловой системы (public/, _next/static/, app/ и др.)
  6. afterFiles (перезаписи) из next.config.js.
  7. Динамические роуты (/blog/[slug]).
  8. fallback (перезаписи) из next.config.js.

Существует два способа определить, для каких путей запускается посредник:

  1. Кастомная настройка "матчера" (matcher).
  2. Условные инструкции.

Матчер

matcher позволяет определять пути для запуска посредника:

export const config = {
matcher: '/about/:path*',
}

Путей может быть несколько:

export const config = {
matcher: ['/about/:path*', '/dashboard/:path*'],
}

matcher поддерживает все возможности регулярных выражений:

export const config = {
matcher: [
/*
* Совпадает со всеми путями, кроме тех, которые начинаются с:
* - api (роуты API)
* - _next/static (статические файлы)
* - _next/image (файлы оптимизированных изображений)
* - favicon.ico
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}

Запросы на предварительное получение данных роутов (компонент Link), которые не должны проходить через посредника, можно игнорировать с помощью массива missing:

export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}

Пути для поиска совпадения должны соответствовать следующим условиям:

  • начинаться с /
  • могут включать именованные параметры: /about/:path совпадает с /about/a и /about/b, но не с /about/a/c
  • могут содержать модификаторы именованных параметров (начинающиеся с :): /about/:path* совпадает с /about/a/b/c, поскольку * - нуль и более. ? - это нуль или один, а + - это один и более
  • могут использовать регулярки, заключенные в круглые скобки: /about/(.*) - это тоже самое, что /about/:path*

Условные инструкции

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/about')) {
return NextResponse.rewrite(new URL('/about-2', request.url))
}

if (request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.rewrite(new URL('/dashboard/user', request.url))
}
}

NextResponse

NextResponse API позволяет:

  • перенаправлять (redirect) входящий запрос на другой URL
  • перезаписывать (rewrite) ответ путем отображения определенного URL
  • устанавливать заголовки для роутов API, getServerSideProps и назначений rewrite
  • устанавливать куки ответа
  • устанавливать заголовки ответа

Для генерации ответа в посреднике можно:

  • rewrite в роут (страницу или обработчик роута), генерирующие ответ
  • вернуть NextResponse напрямую

Использование куки

Куки - это обычные заголовки. В Request они находятся в заголовке Cookie, в Response - в заголовке Set-Cookie. Next.js предоставляет удобный способ для доступа и управления куки через расширение cookies на NextRequest и NextResponse.

  1. Для запроса cookies предоставляет методы get, getAll, set, delete, has и clear.
  2. Для ответа cookies предоставляет методы get, getAll, set и delete.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
// Предположим, что в запросе есть заголовок "Cookie:nextjs=fast"
// Получаем куки из запроса с помощью `RequestCookies` API
let cookie = request.cookies.get('nextjs')
console.log(cookie) // => { name: 'nextjs', value: 'fast', Path: '/' }
const allCookies = request.cookies.getAll()
console.log(allCookies) // => [{ name: 'nextjs', value: 'fast' }]

request.cookies.has('nextjs') // => true
request.cookies.delete('nextjs')
request.cookies.has('nextjs') // => false

// Устанавливаем куки в ответ с помощью `ResponseCookies` API
const response = NextResponse.next()
response.cookies.set('vercel', 'fast')
response.cookies.set({
name: 'vercel',
value: 'fast',
path: '/',
})
cookie = response.cookies.get('vercel')
console.log(cookie) // => { name: 'vercel', value: 'fast', Path: '/' }
// Ответ будет содержать заголовок `Set-Cookie:vercel=fast;path=/`

return response
}

Установка заголовков

Для установки заголовков запросов и ответов можно использовать API NextResponse (доступно с Next.js@13.0.0):

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
// Клонируем заголовки запроса и добавляем новый заголовок `x-hello-from-middleware1`
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-hello-from-middleware1', 'hello')

// Заголовки запроса также можно устанавливать в `NextResponse.rewrite`
const response = NextResponse.next({
request: {
// Новые заголовки запроса
headers: requestHeaders,
},
})

// Устанавливаем новый заголовок ответа `x-hello-from-middleware2`
response.headers.set('x-hello-from-middleware2', 'hello')
return response
}

Генерация ответа

Посредник может возвращать Response или NextResponse (доступно с Next.js@13.1.0):

import { NextRequest } from 'next/server'
import { isAuthenticated } from '@lib/auth'

// Ограничиваем посредника путями, начинающимися с `/api/`
export const config = {
matcher: '/api/:function*',
}

export function middleware(request: NextRequest) {
// Вызываем функцию аутентификации для проверки запроса
if (!isAuthenticated(request)) {
// Отвечаем JSON, содержащим сообщение об ошибке
return Response.json(
{ success: false, message: 'Провал аутентификации' },
{ status: 401 }
)
}
}

waitUntil и NextFetchEvent

Объект NextFetchEvent расширяет нативный объект FetchEvent и предоставляет метод waitUntil.

Метод waitUntil принимает промис в качестве параметра и увеличивает время жизни посредника до разрешения промиса. Это может быть полезным для выполнения работы в фоновом режиме.

import { NextResponse } from 'next/server'
import type { NextFetchEvent, NextRequest } from 'next/server'

export function middleware(req: NextRequest, event: NextFetchEvent) {
event.waitUntil(
fetch('https://my-analytics-platform.com', {
method: 'POST',
body: JSON.stringify({ pathname: req.nextUrl.pathname }),
})
)

return NextResponse.next()
}

Продвинутые флаги посредника

В Next.js@13.1 появилось два новых флага для посредника: skipMiddlewareUrlNormalize и skipTrailingSlashRedirect.

skipTrailingSlashRedirect отключает перенаправления Next.js для добавления или удаления завершающих слэшей. Это позволяет кастомной обработке в посреднике поддерживать завершающие слэши для одних путей и удалять для других, что может облегчить инкрементальные миграции.

// next.config.js
module.exports = {
skipTrailingSlashRedirect: true,
}
// middleware.js
const legacyPrefixes = ['/docs', '/blog']

export default async function middleware(req) {
const { pathname } = req.nextUrl

if (legacyPrefixes.some((prefix) => pathname.startsWith(prefix))) {
return NextResponse.next()
}

// Обработка завершающих слэшей
if (
!pathname.endsWith('/') &&
!pathname.match(/((?!\.well-known(?:\/.*)?)(?:[^/]+\/)*[^/]+\.\w+)/)
) {
req.nextUrl.pathname += '/'
return NextResponse.redirect(req.nextUrl)
}
}

skipMiddlewareUrlNormalize позволяет отключить нормализацию URL в Next.js для того, чтобы сделать обработку прямых и клиентских переходов одинаковой. В некоторых случаях эта настройка предоставляет полный контроль использования оригинального URL.

// next.config.js
module.exports = {
skipMiddlewareUrlNormalize: true,
}
// middleware.js
export default async function middleware(req) {
const { pathname } = req.nextUrl

// GET /_next/data/build-id/hello.json

console.log(pathname)
// С флагом название пути имеет вид `/_next/data/build-id/hello.json`
// Без флага оно будет нормализовано до `/hello`
}

Среда выполнения

В настоящее время посредник поддерживает только граничную среду выполнения, Node.js не поддерживается.

Получение данных

Получение данных, их кеширование и ревалидация

В Next.js существует четыре способа получения данных:

  1. На сервере с помощью fetch.
  2. На сервере с помощью сторонних библиотек.
  3. На клиенте с помощью обработчика роута.
  4. На клиенте с помощью сторонних библиотек.

Получение данных на сервере с помощью fetch

Next.js расширяет нативный Fetch API, позволяя настраивать кеширование и ревалидацию каждого запроса. Next.js также расширяет fetch для автоматической мемоизации запросов в процессе рендеринга дерева компонентов.

fetch можно использовать вместе с async/await в серверных компонентах, обработчиках роута и серверных операциях.

Пример:

// app/page.tsx
async function getData() {
const res = await fetch('https://api.example.com/...')
// Возвращаемое значение не сериализуется,
// что позволяет возвращать Date, Map, Set и др.

if (!res.ok) {
// Это активирует ближайшего предохранителя `error.js`
throw new Error('Провал получения данных')
}

return res.json()
}

export default async function Page() {
const data = await getData()

return <main></main>
}

Кеширование данных

Кеширование сохраняет данные, поэтому их не нужно запрашивать из источника данных при каждом запросе.

По умолчанию Next.js автоматически кеширует результат вызова fetch в кеше данных (data cache) на сервере. Это означает, что данные могут быть получены во время сборки или выполнения, кешированы и повторно использованы при каждом запросе.

// 'force-cache' является значением по умолчанию и может быть опущено
fetch('https://...', { cache: 'force-cache' })

Запросы fetch, которые используют метод POST, также автоматически кешируются, за исключением случаев их использования в обработчиках роута.

Ревалидация данных

Ревалидация - это процесс очистки кеша данных и повторного запроса свежих данных. Это полезно, когда данные изменились, и мы хотим убедиться в отображении актуальной информации.

Кешированные данные могут быть ревалидированы двумя способами:

  • ревалидация на основе времени - автоматическая ревалидация данных по истечении определенного периода времени. Это полезно для данных, которые меняются редко и актуальность которых не является критичной
  • ревалидация по запросу - ручная ревалидация данных на основе события (например, отправки формы). Ревалидация по запросу может использовать подходы на основе тега и на основе пути для групповой ревалидации данных. Это полезно, когда мы хотим убедиться в отображении свежих данных как можно быстрее

Ревалидация на основе времени

Для ревалидации данных по истечении определенного временного интервала можно использовать настройку fetch, которая называется next.revalidate, для установки времени жизни кеша ресурса (в секундах):

// Ревалидировать данные хотя бы раз в час
fetch('https://...', { next: { revalidate: 3600 } })

Для ревалидации всех запросов fetch в сегменте роута можно использовать настройку сегмента роута revalidate:

export const revalidate = 3600 // ревалидировать данные хотя бы раз в час

При наличии нескольких fetch с разной частотой ревалидации в статическом роуте, для ревалидации всех запросов используется наименьшее время. В динамических роутах каждый fetch ревалидируется независимо.

Ревалидация по запросу

Данные могут ревалидироваться по запросу по пути (revalidatePath) и по тегу кеша (revalidateTag) в серверной операции или обработчике роута.

Next.js имеет систему тегирования кеша для инвалидации запросов fetch в роутах.

  1. При использовании fetch мы можем пометить сущности кеша одним или более тегом.
  2. Затем мы вызываем функцию revalidateTag для ревалидации всех сущностей, связанных с этим тегом.

Пример добавления тега collection:

//app/page.tsx
export default async function Page() {
const res = await fetch('https://...', { next: { tags: ['collection'] } })
const data = await res.json()
// ...
}

Пример ревалидации кеша с тегом collection в серверной операции:

// app/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export default async function action() {
revalidateTag('collection')
}

Обработка ошибок и ревалидация

Если во время ревалидации данных возникла ошибка, из кеша буду доставляться последние успешно сгенерированные данные. При следующем запросе Next.js снова попробует ревалидировать данные.

Отключение кеширования

Запросы fetch не кешируются в следующих случаях:

  • в fetch добавлена настройка cache: 'no-store'
  • в fetch добавлена настройка revalidate: 0
  • fetch находится в обработчике роута, который использует метод POST
  • fetch вызывается после использования функций cookies или headers
  • используется настройка сегмента роута const dynamic = 'force-dynamic'
  • кеширование отключено с помощью настройки сегмента роута fetchCache
  • fetch использует заголовки Authorization и Cookie и выше по дереву компонентов имеется некешируемый запрос

Отдельные запросы fetch

Для отключения кеширования отдельного запроса нужно установить настройку cache в fetch в значение 'no-store'. Это сделает запрос динамическим (данные будут запрашиваться из источника данных при каждом запросе):

fetch('https://...', { cache: 'no-store' })

Несколько запросов fetch

Для настройки кеширования нескольких fetch в сегменте роута (например, макете или странице) можно использовать настройки сегмента роута.

Однако рекомендуется настраивать кеширование каждого fetch индивидуально. Это делает кеширование более точным.

Получение данных на сервере с помощью сторонних библиотек

При использовании сторонней библиотеки, которая не поддерживает или не предоставляет fetch (например, база данных, CMS или клиент ORM), кеширование и ревалидацию таких запросов можно настроить с помощью настроек сегмента роута и функции cache из React.

Кешируются данные или нет зависит от того, статическим или динамическим является роут. Если сегмент является статическим, результат запроса кешируется и ревалидируется как часть роута. Если сегмент является динамическим, результат запроса не кешируется и данные повторно запрашиваются при каждом рендеринге роута.

В рассматриваемых случаях также можно использовать экспериментальное API unstable_cache.

Пример

В следующем примере:

  • функция cache используется для мемоизации запроса данных
  • настройка revalidate установлена в значение 3600 в макете и на странице. Это означает, что данные будут кешироваться и ревалидироваться хотя бы раз в час
// app/utils.ts
import { cache } from 'react'

export const getItem = cache(async (id: string) => {
const item = await db.item.findUnique({ id })
return item
})

Несмотря на то, что функция getItem вызывается дважды, в БД будет отправлен только один запрос.

// app/item/[id]/layout.tsx
import { getItem } from '@/utils/get-item'

export const revalidate = 3600

export default async function Layout({
params: { id },
}: {
params: { id: string }
}) {
const item = await getItem(id)
// ...
}
// app/item/[id]/page.tsx
import { getItem } from '@/utils/get-item'

export const revalidate = 3600

export default async function Page({
params: { id },
}: {
params: { id: string }
}) {
const item = await getItem(id)
// ...
}

Получение данных на клиенте через обработчик роута

Для получения данных можно обратиться к обработчику роута из клиента. Обработчики роута выполняются на сервере и возвращают данные клиенту. Это полезно, когда мы хотим скрыть чувствительную информацию, такую как токены API, от доступа извне.

Получение данных на клиенте с помощью сторонних библиотек

Данные на клиенте можно получать с помощью сторонних библиотек, таких как SWR или TanStack Query. Эти библиотеки предоставляют собственные API для мемоизации запросов, кеширования, ревалидации и мутирования данных.

Серверные операции и мутации

Серверные операции - это асинхронные функции, выполняющиеся на сервере. Они могут использоваться в серверных и клиентских компонентах для обработки отправки форм и мутаций данных в приложениях Next.js.

Соглашение

Серверная операция определяется с помощью директивы use server. Эту директиву можно поместить в начале асинхронной функции-серверной операции или в начале отдельного файла. В последнем случае все экспортируемые из файла функции будут считаться серверными операциями.

Серверные компоненты

Серверные компоненты могут использовать директиву use server уровня функции или модуля:

// app/page.tsx
// Серверный компонент
export default function Page() {
// Серверная операция
async function create() {
'use server'
// ...
}

return (
// ...
)
}

Клиентские компоненты

Клиентские компоненты могут импортировать только операции, которые используют директиву use server уровня модуля.

Для вызова серверной операции в клиентском компоненте создайте отдельный файл и поместите в его начало директиву use server. Все функции такого файла будут считаться серверными операциями и могут повторно использоваться как клиентскими, так и серверными компонентами:

// app/actions.ts
'use server'

export async function create() {
// ...
}
// app/ui/button.tsx
'use client'
import { create } from '@/app/actions'

export function Button() {
return (
// ...
)
}

Серверная операция может передаваться в клиентский компонент как проп:

// `updateItem` - серверная операция
<ClientComponent updateItem={updateItem} />
// app/client-component.jsx
'use client'

export default function ClientComponent({ updateItem }) {
return <form action={updateItem}>{/* ... */}</form>
}

Поведение

  • Серверные операции могут вызываться с помощью атрибута action элемента form:
    • серверные компоненты поддерживают прогрессивное улучшение по умолчанию. Это означает, что форма будет отправлена на сервер, даже если JS не успел загрузиться или отключен
    • в клиентских компонентах формы, вызывающие серверные операции, будут помещать отправки в очередь, если JS не успел загрузиться, ожидая гидратацию клиента
    • после гидратации браузер не перезагружается после отправки формы
  • серверные операции не ограничены элементом form и могут вызываться из обработчиков событий, useEffect, сторонних библиотек и других элементов формы, таких как button
  • серверные операции интегрируются с архитектурой кеширования и ревалидации Next.js. При вызове операции Next.js может вернуть обновленный UI и новые данные в одном ответе
  • за сценой операции используют метод POST, и только этот метод может использоваться для их вызова
  • аргументы и возвращаемые значения серверных операций должны быть сериализуемыми. Список сериализуемых значений
  • серверные операции - это функции. Это означает, что они могут использоваться в любом месте приложения
  • серверные операции наследуют среду выполнения страницы или макета, на которых они используются
  • серверные операции наследуют конфигурацию сегмента роута страницы или макета, на которых они используются

Примеры

Формы

React расширяет элемент form, позволяя вызывать серверные операции с помощью пропа action.

При вызове в форме операция автоматически получает объект FormData. Для управления полями формы не нужен хук useState, данные можно извлекать с помощью нативных методов FormData:

// app/invoices/page.tsx
export default function Page() {
async function createInvoice(formData: FormData) {
'use server'

const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
}

// Мутируем данные
// Ревалидируем кеш
}

return <form action={createInvoice}>...</form>
}

Передача дополнительных аргументов

Для передачи в серверную операцию дополнительных аргументов можно использовать метод bind:

// app/client-component.tsx
'use client'

import { updateUser } from './actions'

export function UserProfile({ userId }: { userId: string }) {
// !
const updateUserWithId = updateUser.bind(null, userId)

return (
<form action={updateUserWithId}>
<input type="text" name="name" />
<button type="submit">Обновить имя пользователя</button>
</form>
)
}

Серверная операция получит userId в дополнение к данным формы:

// app/actions.js
'use server'

export async function updateUser(userId, formData) {
// ...
}

Состояние ожидания

Для отображения состояния ожидания во время отправки формы можно использовать хук useFormStatus.

  • useFormStatus возвращает статус родительской формы, т.е. компонент, в котором вызывается этот хук, должен быть потомком элемента form
  • useFormStatus - это хук, поэтому он может вызываться только в клиентских компонентах
// app/submit-button.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
const { pending } = useFormStatus()

return (
<button type="submit" disabled={pending}>
Отправить
</button>
)
}

После этого компонент SubmitButton может использоваться в любой форме:

// app/page.tsx
import { SubmitButton } from '@/app/submit-button'
import { createItem } from '@/app/actions'

export default async function Home() {
return (
<form action={createItem}>
<input type="text" name="field-name" />
<SubmitButton />
</form>
)
}

Серверная валидация и обработка ошибок

Для базовой валидации форм на стороне клиента рекомендуется использовать валидацию HTML, такую как required и type="email".

Для более продвинутой валидации на сервере можно использовать библиотеку вроде zod для проверки полей формы перед мутацией данных:

// app/actions.ts
'use server'

import { z } from 'zod'

const schema = z.object({
email: z.string({
invalid_type_error: 'Невалидный email',
}),
})

export default async function createUser(formData: FormData) {
const validatedFields = schema.safeParse({
email: formData.get('email'),
})

// Ранний возврат при невалидности данных формы
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}

// Мутирование данных
}

После валидации полей на сервере, можно вернуть сериализуемый объект в операции и использовать хук useFormState для отображения сообщения пользователю.

  • При передаче операции в useFormState, сигнатура операции меняется для получения prevState или initialState в качестве первого аргумента
  • useFormState - это хук, поэтому он может использоваться только в клиентских компонентах
// app/actions.ts
'use server'

export async function createUser(prevState: any, formData: FormData) {
// ...
return {
message: 'Пожалуйста, введите валидный email',
}
}

Мы можем передать операцию в useFormState и использовать возвращаемый state для отображения сообщения об ошибке:

// app/ui/signup.tsx
'use client'

import { useFormState } from 'react-dom'
import { createUser } from '@/app/actions'

const initialState = {
message: '',
}

export function Signup() {
const [state, formAction] = useFormState(createUser, initialState)

return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input type="text" id="email" name="email" required />
{/* ... */}
{state?.message && (
<p className="error">
{state.message}
</p>
)}
<button>Зарегистрироваться</button>
</form>
)
}

Оптимистичные обновления

Для оптимистичного обновления UI до завершения операции (до получения ответа от сервера) можно использовать хук useOptimistic:

// app/page.tsx
'use client'

import { useOptimistic } from 'react'
import { send } from './actions'

type Message = {
message: string
}

export function Thread({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic<Message[]>(
messages,
(state: Message[], newMessage: string) => [
...state,
{ message: newMessage },
]
)

return (
<div>
{optimisticMessages.map((m, k) => (
<div key={k}>{m.message}</div>
))}
<form
action={async (formData: FormData) => {
const message = formData.get('message')
addOptimisticMessage(message)
await send(message)
}}
>
<input type="text" name="message" />
<button type="submit">Отправить</button>
</form>
</div>
)
}

Вложенные элементы

Мы можем вызывать серверные операции в элементах, вложенных в form, таких как button, <input type="submit"> и <input type="image">. Эти элементы принимают проп formAction или обработчики событий.

Это полезно в случаях, когда мы хотим вызывать несколько серверных операций в одной форме. Например, мы можем создать кнопку для сохранения черновика поста в дополнение к кнопке его публикации.

Программная отправка формы

Форму можно отправлять с помощью метода requestSubmit. Например, можно регистрировать нажатие ⌘/Ctrl + Enter в обработчике событий onKeyDown:

// app/entry.tsx
'use client'

export function Entry() {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
(e.ctrlKey || e.metaKey) &&
(e.key === 'Enter' || e.key === 'NumpadEnter')
) {
e.preventDefault()
e.currentTarget.form?.requestSubmit()
}
}

return (
<div>
<textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
</div>
)
}

Это запустит отправку ближайшей формы, которая вызовет серверную операцию.

Другие элементы

Серверные операции могут вызываться не только элементами формы, но также обработчиками событий и хуком useEffect.

Обработчики событий

Серверные операции могут вызываться из обработчиков событий, например, onClick. Пример увеличения количества лайков:

// app/like-button.tsx
'use client'

import { incrementLike } from './actions'
import { useState } from 'react'

export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes)

return (
<>
<p>Общее количество лайков: {likes}</p>
<button
onClick={async () => {
const updatedLikes = await incrementLike()
setLikes(updatedLikes)
}}
>
Лайк
</button>
</>
)
}

Для улучшения пользовательского опыта рекомендуется использовать другие React API, такие как useOptimistic и useTransition для обновления UI до завершения выполнения операции на сервере или для отображения состояния ожидания.

Мы также можем добавить обработчики событий к элементам формы, например, для сохранения черновика при возникновении события onChange:

// app/ui/edit-post.tsx
'use client'

import { publishPost, saveDraft } from './actions'

export default function EditPost() {
return (
<form action={publishPost}>
<textarea
name="content"
onChange={async (e) => {
await saveDraft(e.target.value)
}}
/>
<button type="submit">Опубликовать</button>
</form>
)
}

В подобных случаях, когда за короткое время может возникнуть несколько событий, рекомендуется задерживать (debounce) вызов серверных операций.

useEffect

Для вызова серверных операций можно использовать хук useEffect при монтировании компонента или изменении зависимостей. Это полезно для мутаций, которые зависят от глобальных событий или должны запускаться автоматически. Например, onKeyDown для "горячих" клавиш, Intersection Observer API для бесконечной прокрутки или обновление счетчика показов страницы при монтировании компонента.

// app/view-count.tsx
'use client'

import { incrementViews } from './actions'
import { useState, useEffect } from 'react'

export default function ViewCount({ initialViews }: { initialViews: number }) {
const [views, setViews] = useState(initialViews)

useEffect(() => {
const updateViews = async () => {
const updatedViews = await incrementViews()
setViews(updatedViews)
}

updateViews()
}, [])

return <p>Общее количество показов: {views}</p>
}

Обработка ошибок

Возникающая ошибка перехватывается ближайшим предохранителем error.js или компонентом Suspense на клиенте. Для возврата ошибок для их обработки, например, отображения в UI рекомендуется использовать конструкцию try/catch.

Пример обработки ошибки создания новой задачи путем возврата сообщения:

// app/actions.ts
'use server'

export async function createTodo(prevState: any, formData: FormData) {
try {
// Мутируем данные
} catch (e) {
throw new Error('Провал создания задачи')
}
}

Ревалидация данных

Кеш Next.js можно ревалидировать внутри серверной операции с помощью функции revalidatePath:

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost() {
try {
// ...
} catch (error) {
// ...
}

revalidatePath('/posts')
}

Для инвалидации тегированных данных, хранящихся в кеше, можно использовать функцию revalidateTag:

// app/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function createPost() {
try {
// ...
} catch (error) {
// ...
}

revalidateTag('posts')
}

Перенаправление

Пользователя можно перенаправить на другой роут после завершения серверной операции с помощью функции redirect. Эта функция должна вызываться за пределами блока try/catch:

// app/actions.ts
'use server'

import { redirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'

export async function createPost(id: string) {
try {
// ...
} catch (error) {
// ...
}

revalidateTag('posts') // обновляем кешированные посты
redirect(`/post/${id}`) // перенаправляем пользователя на страницу нового поста
}

Куки

С помощью методов экземпляра, возвращаемого функцией cookies, можно получать, устанавливать и удалять куки в серверных операциях:

'use server'

import { cookies } from 'next/headers'

export async function exampleAction() {
const cookieHandler = cookies()
// Получаем куки
const value = cookieHandler.get('name')?.value

// Устанавливаем куки
cookieHandler.set('name', 'Harry')

// Удаляем куки
cookieHandler.delete('name')
}

Безопасность

Аутентификация и авторизация

Серверные операции должны расцениваться как конечные точки API, доступные публично. Это означает, что для их выполнения пользователь должен быть авторизован.

// app/actions.ts
'use server'

import { auth } from './lib'

export function addItem() {
const { user } = auth()

if (!user) {
throw new Error('Для выполнения этой операции необходима авторизация')
}

// ...
}

Замыкания и шифрование

Определение серверной операции в компоненте создает замыкание, когда операция имеет доступ к области видимости внешней функции. В следующем примере операция action имеет доступ к переменной publishVersion:

// app/page.tsx
export default function Page() {
const publishVersion = await getLatestVersion();

async function publish(formData: FormData) {
"use server";

if (publishVersion !== await getLatestVersion()) {
throw new Error('С момента последней публикации изменилась версия');
}
// ...
}

return <button action={publish}>Опубликовать</button>;
}

Замыкания полезны, когда нужно захватить снимок данных (например, publishVersion) во время рендеринга для того, чтобы использовать их в будущем, при вызове операции.

Однако для обеспечения такой возможности захваченные переменные отправляются клиенту и обратно на сервер при вызове операции. Для предотвращения отправки клиенту конфиденциальных данных Next.js автоматически шифрует переменные в замыкании. При каждой сборке приложения для каждой операции генерируется закрытый ключ. Это означает, что операции могут вызываться только для определенной сборки.

Паттерны и лучшие практики

Существует несколько рекомендуемых паттернов и лучших практик получения данных в React и Next.js.

Получение данных на сервере

Там, где это возможно, рекомендуется получать данные на сервере с помощью серверных компонентов. Это позволяет:

  • иметь прямой доступ к источникам ресурсов на сервере (например, БД)
  • делает приложение более безопасным, предотвращая попадание на клиент конфиденциальной информации, такой как токены доступа и ключи API
  • получать данные и рендерить их в одном окружении. Это уменьшает как количество коммуникаций между клиентом и сервером, так и объем работы в основном потоке на клиенте
  • выполнять несколько запросов данных одновременно вместо отправки нескольких индивидуальных запросов на клиенте
  • уменьшает количество клиент-серверных водопадов (waterfalls)
  • в зависимости от региона, получение данных может происходить ближе к источнику данных, что уменьшает задержку и улучшает производительность

Затем данные могут мутироваться или обновляться с помощью серверных операций.

Запрос данных по-необходимости

Если нам нужны одинаковые данные (например, текущий пользователь) в нескольких компонентах, нам не нужно получать эти данные глобально или передавать пропы между компонентами. Вместо этого, мы можем использовать функции fetch или cache в компоненте, которому нужны данные, и не беспокоиться о снижении производительности или отправки нескольких запросов на получение одних и тех же данных.

Это возможно благодаря автоматической мемоизации fetch.

Потоковая передача данных

Потоковая передача и компонент Suspense позволяют прогрессивно рендерить и инкрементально передавать части UI клиенту.

Серверные компоненты и вложенные макеты позволяют незамедлительно рендерить части страницы, которые не требуют данных, и отображать состояние загрузки для частей страницы, для рендеринга которых нужны данные, которые еще не получены. Это означает, что пользователю не нужно ждать загрузки всей страницы до начала взаимодействия с ней.


Параллельное и последовательное получение данных

Данные в компонентах можно получать двумя способами: параллельно и последовательно.


  • Последовательное получение данных означает, что запросы зависят друг от друга и создают водопады. Этот паттерн необходим, когда вызов одного fetch зависит от результатов другого или для вызова следующего fetch требуется соблюдение определенного условия с целью экономии ресурсов. Однако этот паттерн может быть ненамеренным и может приводить к более длительному времени ожидания
  • параллельное получение данных означает, что запросы выполняются одновременно. Это уменьшает количество водопадов и уменьшает общее время ожидания загрузки страницы

Последовательное получение данных

Если у нас имеются вложенные компоненты, и каждый компонент запрашивает собственные данные, тогда получения данных происходит последовательно, если запросы отличаются (одинаковые данные доставляются из кеша).

Например, компонент Playlist начнет запрашивать данные только после того, как компонент Artist закончит это делать, поскольку Playlist зависит от пропа artistID:

// app/artist/[username]/page.tsx
async function Playlists({ artistID }: { artistID: string }) {
// Ждем плейлисты
const playlists = await getArtistPlaylists(artistID)

return (
<ul>
{playlists.map((playlist) => (
<li key={playlist.id}>{playlist.name}</li>
))}
</ul>
)
}

export default async function Page({
params: { username },
}: {
params: { username: string }
}) {
// Ждем музыканта
const artist = await getArtist(username)

return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<div>Загрузка...</div>}>
<Playlists artistID={artist.id} />
</Suspense>
</>
)
}

В подобных случаях можно использовать loading.js (для сегментов роута) или Suspense (для вложенных компонентов) для отображения состояния загрузки во время стриминга результата.

Это предотвращает блокировку всего роута запросом данных, и пользователь может взаимодействовать с готовыми частями страницы.

Параллельное получение данных

Для параллельного получения данных можно определить запросы за пределами компонента и вызвать их в компоненте. Это уменьшает общее время ожидания ответов, но пользователь не увидит результат рендеринга до разрешения всех промисов.

В следующем примере функции getArtist и getArtistAlbums определяются за пределами компонента Page. Затем они вызываются в компоненте с помощью метода Promise.all.

// app/artist/[username]/page.tsx
import Albums from './albums'

async function getArtist(username: string) {
const res = await fetch(`https://api.example.com/artist/${username}`)
return res.json()
}

async function getArtistAlbums(username: string) {
const res = await fetch(`https://api.example.com/artist/${username}/albums`)
return res.json()
}

export default async function Page({
params: { username },
}: {
params: { username: string }
}) {
// Инициализируем запросы
const artistData = getArtist(username)
const albumsData = getArtistAlbums(username)

// Ждем разрешения промисов
const [artist, albums] = await Promise.all([artistData, albumsData])

return (
<>
<h1>{artist.name}</h1>
<Albums list={albums}></Albums>
</>
)
}

Для улучшения UX можно добавить компоненты Suspense для разделения работы по рендерингу и максимально быстрого отображения готовых частей страницы.

Предварительная загрузка данных

Другим способом предотвращения водопадов является предварительное получение данных. Для дальнейшей оптимизации параллельного получения данных можно создать функцию preload. Это избавляет от необходимости передавать промисы как пропы. Функция preload может называться как угодно, поскольку это паттерн, а не API.

// components/Item.tsx
import { getItem } from '@/utils/get-item'

export const preload = (id: string) => {
// `void` оценивает переданное выражение и возвращает `undefined`
// https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/void
void getItem(id)
}
export default async function Item({ id }: { id: string }) {
const result = await getItem(id)
// ...
}
// app/item/[id]/page.tsx
import Item, { preload, checkIsAvailable } from '@/components/Item'

export default async function Page({
params: { id },
}: {
params: { id: string }
}) {
// Начинаем загружать данные
preload(id)
// Выполняем другую асинхронную работу
const isAvailable = await checkIsAvailable()

return isAvailable ? <Item id={id} /> : null
}

cache, server-only и паттерн предварительного получения данных

Мы можем скомбинировать функцию cache, паттерн preload и пакет server-only для создания утилиты получения данных, которую можно использовать во всем приложении:

// utils/get-item.ts
import { cache } from 'react'
import 'server-only'

export const preload = (id: string) => {
void getItem(id)
}

export const getItem = cache(async (id: string) => {
// ...
})

Такой подход обеспечивает незамедлительное получение данных, кеширование ответов и обеспечение того, что запрос данных выполняется на сервере.

Экспорты utils/get-item.js могут использоваться в макетах, страницах и других компонентах.

Предотвращение попадания конфиденциальных данных на клиент

Для предотвращения передачи объекта или его отдельных полей на клиент рекомендуется использовать экспериментальные функции taintObjectReference и taintUniqueValue, соответственно.

Для включения этой возможности необходимо установить настройку experimental.taint в значение true в файле next.config.js:

module.exports = {
experimental: {
taint: true,
},
}

Пример защиты объекта и его поля от использования на клиенте:

// app/utils.ts
import { queryDataFromDB } from './api'
import {
experimental_taintObjectReference,
experimental_taintUniqueValue,
} from 'react'

export async function getUserData() {
const data = await queryDataFromDB()
experimental_taintObjectReference(
'Не передавайте объект пользователя на клиент',
data
)
experimental_taintUniqueValue(
'Не передавайте номер телефона пользователя на клиент',
data,
data.phoneNumber
)
return data
}
// app/page.tsx
import { getUserData } from './data'

export async function Page() {
const userData = getUserData()
return (
<ClientComponent
user={userData} // это вызовет ошибку из-за `taintObjectReference`
phoneNumber={userData.phoneNumber} // это вызовет ошибку из-за `taintUniqueValue`
/>
)
}

Рендеринг

Рендеринг - это процесс превращения кода в UI. React и Next.js позволяют создавать гибридные веб-приложения, где части кода могут рендериться на сервере или клиенте.

Основы

Основными концепциями рендеринга являются следующие:

  • среда выполнения кода: сервер и клиент
  • жизненный цикл "запрос-ответ", который начинается с посещения страницы или взаимодействия пользователя с приложением
  • граница сети, которая разделяет код клиента и сервера

Среда выполнения

Существует две среды, в которых могут рендериться веб-приложения: клиент и сервер.


  • клиент - это браузер на устройстве пользователя, который отправляет запрос на сервер для получения кода приложения. Затем он превращает ответ от сервера в UI
  • сервер - это компьютер в центре данных, который хранит код приложения, получает запросы от клиента и отправляем ему соответствующие ответы

Исторически разработчики должны были использовать разные языки (JavaScript, PHP и др.) и фреймворки для написания кода сервера и клиента. С React разработчики могут использовать для этого один язык (JS) и один фреймворк (Next.js и др.). Эта гибкость позволяет легко писать код для обоих сред выполнения без переключения между контекстами.

Однако у каждой среды имеются свои возможности и ограничения. Поэтому код, который мы пишем для сервера и клиента не всегда похож между собой. Существуют некоторые операции (например, получение данных, управление состоянием), которые лучше выполнять в определенной среде.

Понимание этих различий - ключ к эффективному использованию React и Next.js.

Жизненный цикл "запрос-ответ"

Коротко говоря, все сайты следуют одинаковому жизненному циклу "запрос-ответ":

  1. Действие пользователя. Пользователь взаимодействует с приложением. Это может быть клик по ссылке, отправка формы, ручной ввод URL в строку для поиска браузера и др.
  2. Запрос HTTP. Клиент отправляет на сервер запрос HTTP, содержащий необходимую информацию о запрошенном ресурсе, используемом методе (GET, POST и т.п.) и др.
  3. Сервер. Сервер обрабатывает запрос и отвечает соответствующим ресурсом. Этот процесс может состоять из нескольких этапов, например, роутинг, получение данных и др.
  4. Ответ HTTP. После обработки запроса, сервер отправляет клиенту ответ HTTP. Этот ответ содержит статус-код (сообщающий клиенту об успехе или провале запроса) и запрашиваемые ресурсы (HTML, CSS, JS, статика и др.).
  5. Клиент. Клиент разбирает (parse) ресурсы для рендеринга UI.
  6. Действие пользователя. После рендеринга UI, пользователь может начать с ним взаимодействовать, и процесс начинается сначала.

Основной задачей при разработке гибридного приложение является принятие решения о том, как разделить работу жизненного цикла и куда поместить границу сети.

Граница сети

В веб-разработке граница сети - это концептуальная линия, разделяющая разные среды выполнения кода. Например, клиент и сервер или сервер и хранилище данных.

В React граница сети может определяться по-разному.

За сценой работа делиться на две части: клиентский граф модулей и серверный граф модулей. Серверный граф содержит все компоненты, которые рендерятся на сервере, клиентский граф - все компоненты, которые рендерятся на клиенте.

О графе модулей можно думать как о визуальном представлении того, как файлы приложения зависят друг от друга.

Для определения границы используется директива use client. Существует также директива use server, сообщающая React, что вычисления следует выполнять на сервере.

Разработка гибридного приложения

Поток выполнения кода полезно считать однонаправленным. Поток выполнения двигается от сервера к клиенту.

Если клиенту нужен доступ к серверу, он отправляет новый запрос, а не повторно использует старый запрос. Это облегчает понимание того, где рендерить компоненты и куда поместить границу сети.

На практике такая модель заставляет разработчиков сначала думать о том, что они хотят выполнять на сервере перед отправкой результата клиенту.

Серверные компоненты

Серверные компоненты позволяют писать UI, который рендерится и, опционально, кешируется на сервере. В Next.js работа по рендерингу еще больше разделяется по сегментам роута для обеспечения стриминга и частичного рендеринга. Существует три основные стратегии серверного рендеринга:

  • статический рендеринг
  • динамический рендеринг
  • потоковая передача данных (стриминг)

Преимущества серверного рендеринга

Среди преимуществ серверного рендеринга можно отметить следующее:

  • получение данных. Серверные компоненты позволяют переместить получение данных на сервер, ближе к источнику данных. Это может улучшить производительность за счет уменьшения времени получения данных, необходимых для рендеринга, а также количества запросов, которые нужно выполнить клиенту
  • безопасность. Серверные компоненты позволяют хранить конфиденциальные данные, такие как токены доступа и ключи API, и логику работы с ними на сервере, без риска их попадания на клиент
  • кеширование. Серверные компоненты позволяют кешировать результаты рендеринга и повторно использовать их при последующих запросах и для других пользователей
  • размер сборки. Серверные компоненты позволяют хранить большие зависимости на сервере. Это выгодно, прежде всего, людям с медленным соединением и слабыми устройствами, поскольку клиенту не нужно загружать, разбирать и выполнять код серверных компонентов
  • начальная загрузка страницы и First Contentful Paint (FCP). При рендеринге на сервере пользователь получает HTML незамедлительно, ему не нужно ждать загрузки, разбора и выполнения JS для рендеринга страницы
  • поисковая оптимизация. Отрендеренный HTML может использоваться ботами поисковиков для индексации страниц и ботами социальных сетей для генерации превью страниц
  • стриминг. Серверные компоненты позволяют разделить работу по рендерингу на части и отправлять их клиенту по готовности. Это позволяет пользователю видеть части страницы раньше, чем если ждать рендеринга всей страницы на сервере

Использование серверных компонентов

По умолчанию компоненты Next.js являются серверными. Это позволяет автоматически реализовывать серверный рендеринг без дополнительной настройки. Серверный компонент легко сделать клиентским, о чем мы поговорим в следующем разделе.

Рендеринг серверных компонентов

На сервере Next.js использует API React для оркестрации рендеринга. Работа по рендерингу делится на части: по сегментам роута и компонентам Suspense.

Каждая часть рендерится в два этапа:

  1. React рендерит серверные компоненты в специальный формат данных, который называется полезной нагрузкой серверного компонента React (React Server Component Payload, RSC Payload).
  2. Next.js использует полезную нагрузку RSC и инструкции JS клиентских компонентов (Client Component JavaScript Instructions) для рендеринга HTML роута на сервере.

Затем на клиенте:

  1. HTML используется для моментального отображения неинтерактивного превью роута - только при первоначальной загрузке страницы.
  2. Полезная нагрузка RSC используется для сравнения деревьев клиентских и серверных компонентов и обновления DOM (Document Object Model - объектная модель документа).
  3. Инструкции JS используются для гидратации клиентских компонентов, что делает приложение интерактивным.

Стратегии серверного рендеринга

Существует три разновидности серверного рендеринга: статический, динамический и стриминг.

Статический рендеринг (по умолчанию)

При статическом рендеринге роуты рендерятся во время сборки или в фоновом режиме после ревалидации данных. Результат кешируется и может помещаться в CDN (Content Delivery Network - сеть доставки контента). Это оптимизация позволяет распределять результат рендеринга между пользователями и запросами.

Статический рендеринг полезен, когда роут содержит данные, не привязанные к пользователю и известные во время сборки, например, пост блога или страница товара.

Динамический рендеринг

При динамическом рендеринге роуты рендерятся для каждого пользователя во время запроса.

Динамический рендеринг полезен, когда роут содержит данные, которые связаны с пользователем или известны только во время запроса, такие как куки или параметры поиска URL.

Переключение на динамический рендеринг

В процессе рендеринга при обнаружении динамической функции или некешируемого запроса Next.js переключается на динамический рендеринг всего роута.

Динамические функцииДанныеРоут
НетКешСтатический
ДаКешДинамический
НетНе кешДинамический
ДаНе кешДинамический

Для того, чтобы роут был полностью статическим, все данные должны находиться в кеше. При этом, в роуте не должны использоваться динамические функции (см. ниже).

Как разработчику нам не нужно выбирать между статическим и динамическим рендерингом, поскольку Next.js автоматически выбирает лучшую стратегию рендеринга для каждого роута на основе возможностей и API, которые он использует. Мы выбираем? когда кешировать или ревалидировать определенные данные, а также можем передавать UI по частям.

Динамические функции

Динамические функции используют информацию, которая известна только во время запроса, такую как пользовательские куки, заголовки текущего запроса и параметры поиска URL. Такими функциями являются:

  • cookies и headers. Использование этих функция в серверном компоненте делает роут динамическим
  • searchParams. Использование этого пропа на странице делает роут динамическим

Стриминг


Стриминг позволяет прогрессивно рендерить UI на сервере. Работа по рендерингу делится на части, и UI отправляется клиенту по готовности. Это позволяет пользователю видеть части страницы моментально до окончания рендеринга всего контента.


Стриминг по умолчанию встроен в роутер приложения. Он помогает улучшить как начальную загрузку страницы, так и UI, который зависит от медленного получения данных, которые могут заблокировать весь роут. Например, отзывы на странице товара.

Стриминг сегментов роута обеспечивается файлами loading.js, а стриминг компонентов - Suspense.

Клиентские компоненты

Клиентские компоненты позволяют писать интерактивный UI, который рендерится на клиенте во время запроса. В Next.js рендеринг на клиенте является опциональным: мы должны явно указать, что компонент является клиентским.

Преимущества рендеринга на клиенте

Существует несколько преимуществ клиентского рендеринга:

  • интерактивность. Клиентские компоненты могут использовать состояние, эффекты и обработчики событий: они могут предоставить мгновенную обратную связь пользователю и обновление UI
  • браузерные API. Клиентские компоненты имеют доступ к браузерным API, таким как геолокация или localStorage, позволяя создавать более функциональный UI

Использование клиентских компонентов в Next.js

Для того, чтобы сделать компонент клиентским, достаточно добавить директиву use client в начало соответствующего файла, перед импортами.

use client используется для определения границы между серверными и клиентскими модулями. Это означает, что все модули, импортируемые в клиентский компонент, и все его дочерние компоненты считаются частью клиентской сборки.

// app/counter.tsx
'use client'

import { useState } from 'react'

export default function Counter() {
const [count, setCount] = useState(0)

return (
<div>
<p>Вы кликнули {count} раз</p>
<button onClick={() => setCount(count + 1)}>Нажми на меня</button>
</div>
)
}

На диаграмме ниже показано, что использование обработчика onClick и хука useState во вложенном компоненте (toggle.js) вызовет ошибку при отсутствии директивы use client. Это связано с тем, что по умолчанию компоненты рендерятся на сервере, где эти API отсутствуют. Директива use client сообщает React о том, что компонент и его потомки должны рендерится на клиенте, где эти API доступны.


Рендеринг клиентских компонентов

Клиентские компоненты рендерятся по-разному в зависимости от того, происходит ли полная загрузка страницы (при начальной загрузке или обновлении браузера) или последующая навигация.

Полная загрузка страницы

Для оптимизации начальной загрузки страницы Next.js использует API React для рендеринга статического превью HTML как для серверных, так и для клиентских компонентов. Это означает, что при посещении приложения пользователь мгновенно видит контент страницы, а не ждет, пока клиент загрузит, разберет и выполнит JS.

На сервере:

  1. React рендерит серверные компоненты в специальный формат данных, который называется полезной нагрузкой серверного компонента React (React Server Component Payload, RSC Payload).
  2. Next.js использует полезную нагрузку RSC и инструкции JS клиентских компонентов (Client Component JavaScript Instructions) для рендеринга HTML роута на сервере.

Затем на клиенте:

  1. HTML используется для моментального отображения неинтерактивного превью роута - только при первоначальной загрузке страницы.
  2. Полезная нагрузка RSC используется для сравнения деревьев клиентских и серверных компонентов и обновления DOM.
  3. Инструкции JS используются для гидратации клиентских компонентов, что делает приложение интерактивным.

Последующие навигации

При последующих навигациях клиентские компоненты полностью рендерятся на клиенте, без рендеринга HTML на сервере.

Это означает загрузку и разбор сборки JS клиентских компонентов. После этого React использует полезную нагрузку RSC для сравнения деревьев клиентских и серверных компонентов и обновления DOM.

Возвращение в серверную среду

Иногда после определения границы use client, возникает потребность вернуться в серверную среду. Например, для уменьшения размера сборки для клиента, получения данных на сервере или использования серверных API.

Замечательная новость состоит в том, что клиентские и серверные компоненты, а также серверные операции можно чередовать с помощью паттернов композиции.

Паттерны композиции

При разработке приложения нужно решить, какие его части будут рендериться на сервере, а какие - на клиенте.

Случаи использования серверных и клиентских компонентов

ЗадачаСерверные компонентыКлиентские компоненты
Получение данных
Прямой доступ к серверным ресурсам
Сокрытие конфиденциальной информации
Хранение больших зависимостей
Добавление интерактивности и обработчиков событий
Использование состояния и методов жизненного цикла компонента
Использование браузерных API
Использование кастомных хуков, которые зависят от состояния, эффектов или браузерных API
Использование классовых компонентов

Паттерны серверных компонентов

Распределение данных между компонентами

При получении данных на сервере, может возникнуть необходимость распределения данных между несколькими компонентами. Например, у нас могут быть макет и страница, которые используют одинаковые данные.

Вместо использования контекста (который не доступен на сервере) или передачи данных как пропов, можно использовать функции fetch или cache для получение данных в компоненте, которому они нужны, и не беспокоиться о дублировании запросов. Это обусловлено тем, что Next.js автоматически мемоизирует запросы данных. cache используется, когда fetch не доступна.

Предотвращение попадания серверного кода на клиент

Поскольку модули JS могут использоваться как в серверных, так и в клиентских компонентах, может возникнуть ситуация, когда код, который должен выполняться только на сервере, оказывается на клиенте.

Рассмотрим следующую функцию получения данных:

// lib/data.ts
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})

return res.json()
}

На первый взгляд может показаться, что эта функция будет работать как на сервере, так и на клиенте. Однако, она содержит переменную среды окружения API_KEY, которая доступна только на сервере.

Поскольку у API_KEY нет префикса NEXT_PUBLIC_, эта переменная считается закрытой. Такие переменные на клиенте заменяются пустыми строками.

В итоге, если функция getData будет импортирована и использована на клиенте, она будет работать не так, как ожидается.

Для предотвращения подобных ситуаций рекомендуется использовать пакет server-only, который выбрасывает исключение по время сборки при обнаружении импорта серверного кода на клиент.

npm install server-only
// lib/data.js
import 'server-only'

export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})

return res.json()
}

Для клиентского кода (например, кода который обращается в объекту window) можно использовать аналогичный пакет - client-only.

Использование сторонних пакетов и провайдеров

Поскольку серверные компоненты являются новыми, еще не все сторонние пакеты и провайдеры добавили директиву use client в компоненты, которые используют такие клиентские фичи, как useState, useEffect или createContext.

Такие компоненты будут отлично работать в клиентских компонентах, но не будут работать в серверных компонентах.

Предположим, что мы установили гипотетический пакет acme-carousel, который предоставляет компонент Carousel. Этот компонент использует useState и не содержит директивы use client.

Если мы используем Carousel в клиентском компоненте, то все будет хорошо:

// app/gallery.tsx
'use client'

import { useState } from 'react'
import { Carousel } from 'acme-carousel'

export default function Gallery() {
let [isOpen, setIsOpen] = useState(false)

return (
<div>
<button onClick={() => setIsOpen(true)}>Показать изображения</button>

{/* Работает, поскольку `Carousel` используется в клиентском компоненте */}
{isOpen && <Carousel />}
</div>
)
}

Но если мы попробуем использовать Carousel в серверном компоненте, то получим ошибку:

// app/page.tsx
import { Carousel } from 'acme-carousel'

export default function Page() {
return (
<div>
{/* Ошибка: `useState` не может использоваться в серверных компонентах */}
<Carousel />
</div>
)
}

Это связано с тем, что Next.js не знает о том, что Carousel - это клиентский компонент.

Для решения этой задачи достаточно обернуть сторонний компонент в клиентский компонент:

// app/carousel.tsx
'use client'

import { Carousel } from 'acme-carousel'

export default Carousel

После этого Carousel можно использовать в серверных компонентах:

// app/page.tsx
import Carousel from './carousel'

export default function Page() {
return (
<div>
{/* Работает, поскольку `Carousel` - это клиентский компонент */}
<Carousel />
</div>
)
}

Большинство сторонних пакетов будут использоваться в клиентских компонентах, поэтому такие обертки требуются редко. Другое дело - провайдеры, которые полагаются на состояние и контекст, и обычно используются на верхнем уровне приложения.

Использование провайдеров контекста

Провайдеры контекста обычно размещаются рядом с корнем приложения для распределения глобального состояния, такого как текущая тема. Поскольку контекст React не поддерживается в серверных компонентах, попытка его создания в корне приложения завершается ошибкой:

// app/layout.tsx
import { createContext } from 'react'

// `createContext` не поддерживается в серверных компонентах
export const ThemeContext = createContext({})

export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
</body>
</html>
)
}

Для того, чтобы решить эту задачу, провайдер следует завернуть в клиентский компонент:

// app/theme-provider.tsx
'use client'

import { createContext } from 'react'

export const ThemeContext = createContext({})

export default function ThemeProvider({
children,
}: {
children: React.ReactNode
}) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

После этого провайдер можно использовать в серверных компонентах:

// app/layout.tsx
import ThemeProvider from './theme-provider'

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}

Клиентские компоненты

Перемещение клиентских компонентов ниже по дереву

Для уменьшения сборки JS для клиента рекомендуется помещать клиентские компоненты максимально глубоко в дереве компонентов.

Например, у нас может быть макет со статическими элементами (логотип, ссылки и др.) и интерактивной панелью для поиска, которая использует состояние.

Вместо того, чтобы делать весь макет клиентским компонентом, перемещаем интерактивную логику в клиентский компонент (например, SearchBar) и оставляем макет серверным компонентом.

// app/layout.tsx
// `SearchBar` - это клиентский компонент
import SearchBar from './searchbar'
// `Logo` - серверный компонент
import Logo from './logo'

// `Layout` - серверный компонент по умолчанию
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<nav>
<Logo />
<SearchBar />
</nav>
<main>{children}</main>
</>
)
}

Передача пропов от серверных к клиентским компонентам (сериализация)

Пропы, передаваемые от серверного компонента клиентскому, должны быть сериализуемыми.

Если клиентский компонент требует данных, которые не сериализуются, можно получать их на клиенте с помощью сторонних библиотек или на сервере с помощью обработчиков роута.

Чередование серверных и клиентских компонентов

При чередовании клиентских и серверных компонентов, может быть полезным визуализировать UI как дерево компонентов. Начиная с корневого макета, который является серверным компонентом, мы можем рендерить определенные поддеревья компонентов на клиенте с помощью директивы use client.

В этих поддеревьх мы по-прежнему можем использовать серверные компоненты и вызывать серверные операции, но существуют некоторые ограничения:

  • в течение жизненного цикла "запрос-ответ" код перемещается с сервера на клиент. Для доступа к ресурсам на сервере из клиента нужно отправить новый запрос
  • при отправке на сервер нового запроса сначала рендерятся все серверные компоненты, включая те, которые вложены внутрь клиентских компонентов. Результат рендеринга (полезная нагрузка RSC) будет содержать ссылки на локации клиентских компонентов. Затем, на клиенте, React использует полезную нагрузку RSC для согласования серверных и клиентских компонентов
  • поскольку клиентские компоненты рендерятся после серверных, серверные компоненты не могут импортироваться в клиентские. Однако серверные компоненты могут передаваться клиентским как props

Неподдерживаемый паттерн: импорт серверных компонентов в клиентские

// app/client-component.tsx
'use client'

// Серверный компонент не может импортироваться в клиентский
import ServerComponent from './Server-Component'

export default function ClientComponent({
children,
}: {
children: React.ReactNode
}) {
const [count, setCount] = useState(0)

return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>

<ServerComponent />
</>
)
}

Поддерживаемый паттерн: передача серверного компонента клиентскому как пропа

Для создания слота для серверного компонента в клиентском обычно используется проп children:

// app/client-component.tsx
'use client'

import { useState } from 'react'

export default function ClientComponent({
children,
}: {
children: React.ReactNode
}) {
const [count, setCount] = useState(0)

return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
)
}

ClientComponent не знает, что children предназначен для серверного компонента. Единственная ответственность ClientComponent - определение локации children.

В родительском серверном компоненте мы можем импортировать ClientComponent и ServerComponent, и сделать последнего потомком первого:

// app/page.tsx
import ClientComponent from './client-component'
import ServerComponent from './server-component'

// Страницы являются серверными компонентами по умолчанию
export default function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}

Такой подход позволяет ClientComponent и ServerComponent рендериться независимо. В данном случае ServerComponent рендерится на сервере до рендеринга ClientComponent на клиенте.

Граничная и Node.js среды выполнения

В контексте Next.js среда выполнения (runtime) означает набор библиотек, API и общей функциональности, доступной коду во время выполнения.

На сервере существует две среды, в которых может выполняться код приложения:

  • среда Node.js (по умолчанию) предоставляет доступ ко всем API Node.js и совместимым с ним пакетов
  • граничная (edge) среда, основанная на Web API

Отличия сред

Существует много факторов, которые необходимо учитывать при выборе среды выполнения.

  • | Node.js среда | Бессерверная (serverless) среда | Граничная среда ---|---|---|--- Холодный запуск | / | Обычный | Быстрый Потоковая передача данных | Да | Да | Да Ввод-вывод | Весь | Весь | fetch Масштабируемость | / | Высокая | Самая высокая Задержка | Обычная | Низкая | Самая низкая Пакета NPM | Все | Все | Небольшой набор Статический рендеринг | Да | Да | Нет Динамический рендеринг | Да | Да | Да Ревалидация данных с помощью fetch | Да | Да | Да

Граничная среда

Граничная среда выполнения представляет собой небольшой набор API Node.js.

Она идеально подходит для доставки динамического, персонализированного контента с низкой задержкой с помощью небольших, простых функций. Скорость граничной среды обусловлена минимальным использованием ресурсов, но во многих случаях этого может оказаться недостаточно.

Например, код, выполняемый в граничной среде в Vercel, должен иметь размер от 1 до 4 Мб. Этот размер включает импортируемые пакеты, шрифты и файлы, и зависит от инфрастуктуры развертывания. Кроме того, граничная среда поддерживает не все API Node.js, поэтому некоторые пакеты npm могут не работать (Module not found: Can't resolve 'fs' и похожие ошибки).

Среда Node.js

Это среда выполнения предоставляет доступ ко всем API Node.js. В ней работают все пакеты, совместимые с Node.js. Но она не такая быстрая, как граничная.

Деплой приложения Next.js на сервере Node.js требует управления, масштабирования и настройки инфраструктуры. Вместо этого, для развертывания приложения можно использовать бессерверную платформу, такую как Vercel.

Бессерверный Node.js

Бессерверная среда отлично подходит, когда требуется масштабируемое решение, которое может выполнять более сложные вычисления, чем граничная среда. Максимальный размер бессерверных функций в Vercel составляет 50 Мб, включая импортируемые пакеты, шрифты и файлы.

Бессерверная среда немного медленнее граничной, особенно при холодном старте.

Примеры

Настройка сегмента роута

Среду выполнения для определенного сегмента роута можно определить путем создания и экспорта переменной runtime. Значением этой переменной может быть "nodejs" и "edge".

// app/page.tsx
export const runtime = 'edge' // по умолчанию 'nodejs'

runtime можно определять на уровне макета, что автоматически определит среду выполнения для всех его роутов.

Дефолтной средой выполнения является Node.js, для нее явно определять runtime не нужно.

Кеширование

Обзор

Механизмы кеширования, применяемые в Next.js, и их цели:

МеханизмЧтоГдеЦельДлительность
Мемоизация запросовЗначения, возвращаемые функциямиСерверПовторное использование данных в компонентахЖизненный цикл запроса
Кеш данныхДанныеСерверХранение данных между запросами пользователя и деплоямиПостоянно (доступна ревалидация)
Кеш всего роутаHTML и полезная нагрузка RSCСерверУменьшение стоимости рендеринга и улучшение производительностиПостоянно (доступна ревалидация)
Кеш роутераПолезная нагрузка RSCКлиентУменьшение количества запросов при навигацииСессия пользователя или в течение определенного времени

По умолчанию Next.js кеширует все, что можно. Это означает статический рендеринг роутов и кеширование запросов данных. На следующей диаграмме представлено дефолтное поведение кеширования: когда роут статически рендерится во время сборки и при первом посещении статического роута.


Логика кеширования меняется в зависимости от того, статически или динамически рендерится роут, кешируются или не кешируются данные, является запрос частью первого посещения или последующей навигации. Логику кеширования можно настраивать для отдельных роутов и запросов данных.

Мемоизация запросов

Next.js расширяет Fetch API для автоматической мемоизации запросов с одинаковыми URL и настройками. Это означает, что мы можем вызывать один и тот же fetch в нескольких местах дерева компонентов, а он будет выполнен только один раз.


Если нам нужны одинаковые данные в макете, на странице и в нескольких компонентах, нам не нужно запрашивать их в общем родительском компоненте и передавать дочерним компонентам в виде пропов. Вместо этого, мы получаем данные в компонентах, не заботясь о снижении производительности и дублировании запросов.

// app/example.tsx
async function getItem() {
// Функция `fetch` автоматически мемоизируется, а ее результат кешируется
const res = await fetch('https://.../item/1')
return res.json()
}

// Функция вызывается дважды, но выполнятся только один раз
const item = await getItem() // MISS (данные в кеше отсутствуют)

// Второй вызов может выполняться в любом месте роута
const item = await getItem() // HIT (данные в кеше имеются)

Логика мемоизации запроса


  • При рендеринге роута выполняется запрос, результат которого отсутствует в памяти (кеше)
  • запрос выполняется, данные запрашиваются из внешнего источника, и результат сохраняется в памяти
  • последующие вызовы функции в том же цикле рендеринга возвращают результаты из кеша, запрос не выполняется
  • после рендеринга роута и завершения цикла рендеринга, память сбрасывается и мемоизированные запросы удаляются

Продолжительность

Кеш существует в течение жизненного цикла серверного запроса до завершения рендеринга дерева компонентов.

Ревалидация

Поскольку мемоизация не распределяется между запросами и применяется только при рендеринге, необходимость в ее ревалидации отсутствует.

Отключение

Для отключения мемоизации fetch можно передать в запрос signal экземпляра AbortController:

const { signal } = new AbortController()
fetch(url, { signal })

Кеш данных

Кеш данных (data cache) хранит результаты запросов данных между серверными запросами и деплоями. Это возможно благодаря расширению fetch, которое позволяет каждому запросу определять собственную логику кеширования.

По умолчанию запросы данных, использующие fetch, кешируются. Настройки cache и next.revalidate позволяют настраивать логику кеширования.

Логика кеширования данных


  • При первом вызове fetch в процессе рендеринга, кешированный ответ проверяется в кеше данных
  • при обнаружении кешированного ответа он незамедлительно возвращается и мемоизируется
  • при отсутствии кешированного ответа, запрос выполняется, и результат записывается в кеш данных и мемоизируется
  • для не кешируемых (например, { cache: 'no-store' }) запросов результат всегда запрашивается из источника данных и мемоизируется
  • независимо от кешируемости данных, запрос всегда мемоизируется для предотвращения дублирования запросов одинаковых данных в одном цикле рендеринга

Продолжительность

Кеш данных сохраняется между запросами и деплоями до ревалидации или отключения.

Ревалидация

Кешированные данные могут ревалидироваться двумя способами:

  • ревалидация на основе времени - данные ревалидируются по истечении определенного времени при выполнении нового запроса. Этот способ подходит для данных, которые меняются редко, и актуальность которых не является критичной для работы приложения
  • ревалидация по запросу - ревалидация данных на основе события (например, после отправки формы). Для групповой ревалидации данных могут использоваться теги и пути. Этот способ подходит для данных, актуальность которых является критичной для работы приложения

Ревалидация на основе времени

Для ревалидации данных через определенный период времени можно использовать настройку next.revalidate в fetch для установки времени жизни кеша ресурса (в секундах):

// Ревалидировать хотя бы раз в час
fetch('https://...', { next: { revalidate: 3600 } })

В качестве альтернативы можно использовать конфигурацию сегмента роута для настройки всех fetch сегмента или для случаев, когда нельзя использовать fetch.

Логика ревалидации на основе времени


  • При первом вызове запроса с revalidate, данные запрашиваются из внешнего источника и записываются в кеш данных
  • результат аналогичного запроса, вызываемого в течение определенного времени (например, 60 секунд), доставляется из кеша
  • по истечении определенного времени очередной запрос возвращает кешированные данные (устаревшие)
    • Next.js запускает ревалидацию данных в фоновом режиме
    • после успешного получения данных, кеш данных обновляется
    • если фоновая ревалидация провалилась, продолжают использоваться старые данные

Ревалидация по запросу

Данные могут ревалидироваться по запросу по пути (revalidatePath) и по тегу кеша (revalidateTag).

Логика ревалидации по запросу


  • При первом вызове запроса с revalidate, данные запрашиваются из внешнего источника и записываются в кеш данных
  • при запуске ревалидации по запросу, соответствующие записи удаляются из кеша
    • это отличается от ревалидации на основе времени, когда устаревшие данные сохраняются в кеше до получения новых
  • при выполнении следующего запроса, данные в кеше отсутствуют, поэтому они запрашиваются из внешнего источника и записываются в кеш данных

Отключение

Кеширование отдельного запроса можно отключить путем установки настройки cache в значение no-store:

fetch(`https://...`, { cache: 'no-store' })

Кеширование всех запросов определенного сегмента роута, включая сторонние библиотеки, можно отключить с помощью следующей настройки:

export const dynamic = 'force-dynamic'

Кеш всего роута

Next.js автоматически рендерит и кеширует роуты во время сборки. Эта оптимизация позволяет обслуживать кешированный роут вместо его рендеринга на сервере при каждом запросе, что приводит к более быстрой загрузке страницы.

Для понимания работы кеша всего роута (full route cache), полезно рассмотреть, как React выполняет рендеринг, и как Next.js кеширует его результат.

1. React выполняет рендеринг на сервере

На сервере Next.js использует API React для оркестрации рендеринга. Работа по рендерингу делится на части: по сегментам роута и компонентам Suspense.

Каждая часть рендерится в два этапа:

  1. React рендерит серверные компоненты в специальный формат данных, который называется полезной нагрузкой RSC.
  2. Next.js использует полезную нагрузку RSC и инструкции JS клиентского компонента для рендеринга HTML роута на сервере.

Это означает, что нам не нужно ждать завершения рендеринга всего роута перед началом кеширования или отправкой ответа. Вместо этого, части UI могут передаваться клиенту с помощью потока по мере их готовности.

2. Next.js выполняет кеширование на сервере (кеш всего роута)


Дефолтным поведением Next.js является кеширование результата рендеринга (полезной нагрузки RSC и HTML) роута на сервере. Это применяется к статическим роутам во время сборки или в процессе ревалидации.

3. React гидратирует и согласовывает компоненты на клиенте

Во время запроса на клиенте:

  1. HTML используется для моментального отображения неинтерактивного начального превью клиентских и серверных компонентов.
  2. Полезная нагрузка RSC используется для согласования компонентов и обновления DOM.
  3. Инструкции JS используются для гидратации клиентских компонентов и добавления интерактивности в приложение.

4. Next.js кеширует данные на клиенте (клиентский кеш)

Полезная нагрузка RSC хранится на стороне клиента в кеша роутера (router cache) - отдельном кеше в памяти, разделенном по сегментам роута. Этот кеш используется для улучшения опыта навигации путем хранения ранее посещенных роутов и предварительного запроса будущих роутов.

5. Последующие навигации

При последующих навигациях, а также при предварительных запросах Next.js проверяет наличие полезной нагрузки RSC в кеше роутера. Если нагрузка в кеше имеется, запрос не выполняется, если отсутствует - выполняется запрос на сервер, и результат записывается в кеш.

Статический и динамический рендеринг

Кешируется ли роут во время сборки, зависит от того, статический он или динамический. Статические роуты кешируются по умолчанию, динамические рендерятся во время запроса и не кешируются.


Продолжительность

По умолчанию кеш всего роута хранится на постоянной основе. Это означает, что результат рендеринга кешируется между запросами пользователя.

Инвалидация

Существует два способа инвалидировать кеш всего роута:

  • ревалидация данных - ревалидация кеша данных приводит к инвалидации кеша всего роута, повторному рендерингу компонентов на сервере и кешированию нового результата рендеринга
  • повторный деплой - в отличие от кеша данных, которых сохраняется между деплоями, кеш всего роута очищается при новых развертываниях

Отключение

Отключить кеширование всего роута можно следующим образом:

  • путем использования динамических функций - это отключает кеш всего роута и приводит к динамическому рендерингу во время запроса. Кеш данных по-прежнему может использоваться
  • путем использования настроек сегмента роута dynamic = 'force-dynamic' или revalidate = 0 - это отключает кеш всего роута и кеш данных. Это означает, что компоненты будут рендериться, а данные запрашиваться на сервере при каждом запросе. Кеш роутера будет по-прежнему применяться, поскольку это клиентский кеш
  • отключение кеша данных - если роут содержит fetch, который не кешируется, кеш всего роута отключается. Этот fetch будет выполняться при каждом запросе. Другие fetch будут кешироваться. Это позволяет использовать кешированные и не кешированные данные одновременно

Кеш роутера

Next.js использует кеш на стороне клиента, который хранит полезную нагрузку RSC, разделенную на сегменты роута, на протяжении сессии пользователя. Он называется кешем роутера.

Логика работы кеша роутера


Когда пользователь перемещается между роутами, Next.js кеширует посещенные сегменты роута и предварительно запрашивает роуты, которые пользователь посетит с большой долей вероятности (индикатором этого является нахождение компонента Link в области просмотра).

Это улучшает опыт навигации пользователя:

  • мгновенная навигация "вперед-назад" за счет кеширования посещенных роутов и быстрая навигация к новым роутам благодаря предварительному получению данных и частичному рендерингу
  • навигации не влекут полную перезагрузку страницы - состояние React и браузера сохраняется

Продолжительность

Кеш хранится во временной памяти браузера. То, как долго он хранится, определяется двумя факторами:

  • сессия - кеш сохраняется между навигациями. Однако при перезагрузке страницы он очищается
  • автоматическая инвалидации - кеш отдельных сегментов роута автоматически инвалидируется по истечении определенного периода времени. Этот период зависит от того, статическим является роут или динамическим:
    • для динамических роутов он составляет 30 секунд
    • для статических - 5 минут

Хотя перезагрузка страницы влечет очистку всех кешированных сегментов роута, началом периода автоматической инвалидации определенного сегмента является время его создания или время последнего доступа к нему.

Добавление prefetch={true} или вызов router.prefetch для динамического роута включают его кеширование на 5 минут.

Инвалидация

Существует два способа инвалидировать кеш роутера:

  • в серверной операции:
    • ревалидация данных по запросу по пути (revalidatePath) и по тегу кеша (revalidateTag)
    • использование метода cookies.set или cookies.delete инвалидируют кеш роутера для предотвращения устаревания роутов, использующих куки (это актуально, например, для аутентификации)
  • вызов метода router.refresh инвалидирует кеш роутера и отправляет новый запрос на сервер для текущего роута

Отключение

Кеш роутера отключить нельзя. Однако его можно инвалидировать путем вызова router.refresh, revalidatePath или revalidateTag. Это очистит кеш и отправит новый запрос, обеспечив отображение актуальных данных.

Также можно отключить предварительное получение данных путем передачи prefetch={false} компоненту Link. Однако сегменты роута все равно будут храниться в течение 30 секунд. Посещенные роуты будут кешироваться.

Взаимодействие частей кеша между собой

При настройке механизмов кеширования, важно понимать, как отдельные части кеша взаимодействуют между собой.

Кеш данных и кеш всего роута

  • ревалидация или отключение кеша данных влечет инвалидацию кеша всего роута, поскольку результат рендеринга зависит от данных
  • инвалидация или отключение кеша всего роута не влияет на кеш данных. Мы можем динамически рендерить роут, который использует как кешированные, так и не кешированные данные. Это полезно, когда большая часть страницы использует кешированные данные, но имеется несколько компонентов, которым требуется получать данные при каждом запросе

Кеш данных и кеш роутера

  • ревалидация кеша данных в обработчике роута не влечет инвалидации кеша роутера, поскольку обработчик роутера не привязан к конкретному роуту. Это означает, что кеш роутера будет обслуживать предыдущую полезную нагрузку RSC до жесткой перезагрузки или истечения периода инвалидации
  • для мгновенной инвалидации кеша данных и кеша роутера можно использовать функцию revalidatePath и revalidateTag в серверной операции

API

APIКеш роутераКеш всего роутаКеш данныхКеш React
<Link prefetch>Кеширование---
router.prefetchКеширование---
router.refreshРевалидация---
fetch--КешированиеКеширование
fetch options.cache--Кеширование или отключение-
fetch options.next.revalidate-РевалидацияРевалидация-
fetch options.next.tags-КешированиеКеширование-
revalidateTagРевалидация (серверная операция)РевалидацияРевалидация-
revalidatePathРевалидация (серверная операция)РевалидацияРевалидация-
const revalidate-Ревалидация или отключениеРевалидация или отключение-
const dynamic-Кеширование или отключениеКеширование или отключение-
cookiesРевалидация (серверная операция)Отключение--
headers, searchParams-Отключение--
generateStaticParams-Кеширование--
React.cache---Кеширование

Link

По умолчанию компонент Link автоматически предварительно запрашивает роуты из кеша всего роута и добавляет полезную нагрузку RSC в кеш роутера.

Для отключения предварительного запроса можно установить проп prefetch в значение false. Но это не отключает кеширование, сегмент роута будет кешироваться на клиенте при посещении роута пользователем.

router.prefetch

Настройка prefetch хука useRouter может использоваться для ручного предварительного запроса роута. Это добавляет полезную нагрузку RSC в кеш роутера.

router.refresh

Настройка refresh хука useRouter может использоваться для ручной перезагрузки роута. Это полностью очищает кеш роутера и выполняет новый запрос на сервер для текущего роута. refresh не влияет на кеш данных или кеш всего роута.

Результат рендеринга согласовывается на клиенте при сохранении состояния React и браузера.

fetch

Данные, возвращаемые fetch, автоматически кешируются в кеше данных.

fetch с настройкой cache

Отключить кеширование отдельного fetch можно путем установки настройки cache в значение no-store:

fetch(`https://...`, { cache: 'no-store' })

Поскольку результат рендеринга зависит от данных, это также отключает кеш всего роута для роута, в котором используется этот fetch.

fetch с настройкой next.revalidate

Настройка next.revalidate используется для определения периода ревалидации (в секундах) отдельного fetch. Ревалидация касается кеша данных, что, в свою очередь, влияет на кеш всего роута. Запрашиваются свежие данные, компоненты повторно рендерятся на сервере.

// Ревалидировать хотя бы раз в час
fetch(`https://...`, { next: { revalidate: 3600 } })

fetch с настройкой next.tags и revalidateTag

Для детального кеширования и ревалидации данных Next.js предоставляет теги кеша.

  1. При использовании fetch можно пометить кешируемые сущности одним или несколькими тегами.
  2. Затем для удаления сущностей из кеша вызывается функция revalidateTag, которой передаются эти теги.

Пример установки тегов кеша:

fetch(`https://...`, { next: { tags: ['a', 'b', 'c'] } })

Пример очистки кеша:

revalidateTag('a')

revalidateTag может использоваться в двух местах:

  1. Обработчик роута - для ревалидации данных в ответ на стороннее событие (например, веб-хук). Это не влечет инвалидации кеша роутера, поскольку обработчик роута не привязан к конкретному роуту.
  2. Серверная операция - для ревалидации данных после действия пользователя (например, отправки формы). Это влечет инвалидацию кеша роутера для соответствующего роута.

revalidatePath

Функция revalidatePath позволяет вручную ревалидировать данные и повторно отрендерить сегменты роута по определенному пути за одну операцию. Вызов revalidatePath ревалидирует кеш данных, что, в свою очередь, инвалидирует кеш всего роута.

revalidatePath('/')

revalidatePath может использоваться в двух местах:

  1. Обработчик роута - для ревалидации данных в ответ на стороннее событие (например, веб-хук). Это не влечет инвалидации кеша роутера, поскольку обработчик роута не привязан к конкретному роуту.
  2. Серверная операция - для ревалидации данных после действия пользователя (например, отправки формы).

Динамические функции

Динамические функции, вроде cookies и headers, а также проп страниц searchParams зависят от входящего запроса. Их использование отключает кеш всего роута, другими словами, роут будет рендериться динамически.

cookies

Использование метода cookies.set или cookies.delete в серверной операции инвалидирует кеш роутера для предотвращения использования роутами устаревших куки.

Настройки сегмента роута

Конфигурация сегмента роута может применяться для перезаписи настроек по умолчанию или когда в отсутствие возможности использовать fetch.

Следующие настройки отключают кеш данных и кеш всего роута:

  • const dynamic = 'force-dynamic'
  • const revalidate = 0

generateStaticParams

Для динамических сегментов (например, app/blog/[slug]/page.js) пути, предоставляемые функцией generateStaticParams, записываются в кеш всего роута во время сборки. Во время запроса Next.js также кеширует пути, которые не были известны во время сборки, при их посещении в первый раз.

Кеширование во время запроса можно отключить с помощью настройки сегмента роута export const dynamicParams = false. В этом случае будут обслуживаться только пути, предоставленные generateStaticParams.

React.cache

Функция cache из React позволяет мемоизировать значение, возвращаемое функцией, позволяя вызывать функцию несколько раз при ее однократном выполнении.

Поскольку запросы fetch мемоизируются автоматически, их не нужно оборачивать в cache. Однако cache может использоваться для ручной мемоизации запросов данных в случаях, когда fetch недоступен. Такими случаями может быть использование клиентов БД, CMS или GraphQL.

// utils/get-item.ts
import { cache } from 'react'
import db from '@/lib/db'

export const getItem = cache(async (id: string) => {
const item = await db.item.findUnique({ id })

return item
})

Стилизация

Next.js поддерживает несколько способов стилизации приложения, включая:

  • глобальный CSS - способ простой в использовании и хорошо знакомый разработчикам, которые привыкли иметь дело с обычным CSS, но может приводить к увеличению размера сборки и сложностям в управлении стилями при росте приложения
  • модули CSS - позволяют создавать классы CSS с локальной областью видимости, что предотвращает конфликты названий классов и улучшает поддерживаемость стилей
  • Tailwind CSS - фреймворк CSS, основанный на классах-утилитах
  • Sass - популярный препроцессор CSS, расширяющий CSS фичами, вроде переменных, вложенности и миксинов
  • CSS в JS - позволяет внедрять CSS в компоненты JS, что позволяет писать динамические стили с ограниченной областью видимости

Модули CSS

Next.js имеет встроенную поддержку модулей CSS - файлов с расширением .module.css.

Модули CSS - это стили с локальной областью видимости, которая обеспечивается уникальными названиями классов. Это позволяет использовать одинаковые названия классов в разных файлах, не беспокоясь о возможных коллизиях. Такое поведение делает модули CSS идеальным способом определения стилей компонентов.

Пример

Модули CSS могут импортироваться в любой файл, находящийся в директории app:

// app/dashboard/layout.tsx
import styles from './styles.module.css'

export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return <section className={styles.dashboard}>{children}</section>
}
/* app/dashboard/styles.module.css */
.dashboard {
padding: 24px;
}

Модули CSS являются опциональной возможностью и включены только для файлов с расширением .module.css. Также поддерживаются обычные таблицы стилей, подключаемые с помощью элемента link, а также глобальные стили.

В продакшне все файлы модулей CSS автоматически конкатенируются во множество минифицированных файлов .css. Эти файлы представляют "горячие" пути выполнения, обеспечивая загрузку минимально необходимого CSS.

Глобальные стили

Глобальные стили могут импортироваться в любой макет, страницу или компонент в директории app.

Предположим, что у нас есть такая таблица стилей app/global.css:

body {
padding: 20px 20px 60px;
max-width: 680px;
margin: 0 auto;
}

Для применения этих стилей ко всем роутам приложения импортируем эту таблицу в корневой макет приложения (app/layout.js):

// Эти стили буду применяться ко всем роутам приложения
import './global.css'

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

Внешние таблицы стилей

Таблицы стилей сторонних пакетов могут импортироваться в любое место в директории app:

// app/layout.tsx
import 'bootstrap/dist/css/bootstrap.css'

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="container">{children}</body>
</html>
)
}

Дополнительные возможности

Next.js предоставляет некоторые дополнительные возможности для улучшения опыта стилизации:

  • при локальном запуске с помощью next dev локальные таблицы стилей (глобальные или модули CSS) используют быструю перезагрузку для отражения изменений после их сохранения
  • при сборке с помощью next build стили собираются в несколько минифицированных файлов .css для уменьшения количества сетевых запросов на получение стилей

Tailwind CSS

Tailwind CSS - это фреймворк CSS, основанный на классах-утилитах, который прекрасно работает с Next.js.

Установка

npm i -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Настройка

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',

// При использовании директории `src`
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}

Импорт стилей

/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
// app/layout.tsx
import type { Metadata } from 'next'

// Эти стили будут применяться к каждому роуту приложения
import './globals.css'

export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

Использование классов

// app/page.tsx
export default function Page() {
return <h1 className="text-3xl font-bold underline">Привет, Next.js!</h1>
}

Использование с Turbopack

Начиная с Next.js 13.1, Tailwind CSS и PostCSS поддерживаются с Turbopack.

CSS в JS

В настоящее время библиотеки CSS в JS, для которых требуется JS во время выполнения, не поддерживаются в серверных компонентах.

Следующие библиотеки поддерживаются в клиентских компонентах в директории app (в алфавитном порядке):

  • chakra-ui
  • kuma-ui
  • @mui/material
  • pandacss
  • styled-jsx
  • styled-components
  • stylex
  • tamagui
  • tss-react
  • vanilla-extract

Для стилизации серверных компонентов рекомендуется использовать модули CSS или другие решения, генерирующие файлы CSS, такие как PostCSS или Tailwind CSS.

Настройка CSS в JS в app

Настройка CSS в JS состоит из 3 этапов:

  1. Создание реестра стилей для сбора всего CSS при рендеринге.
  2. Использование нового хука useServerInsertedHTML для внедрения стилей перед контентом, который может их использовать.
  3. Создание клиентского компонента, который оборачивает приложение в реестр стилей во время начального рендеринга на стороне сервера.

styled-jsx

Создаем новый реестр:

// app/registry.tsx
'use client'

import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { StyleRegistry, createStyleRegistry } from 'styled-jsx'

export default function StyledJsxRegistry({
children,
}: {
children: React.ReactNode
}) {
// Создаем таблицу стилей один раз с ленивым начальным состоянием
// https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const [jsxStyleRegistry] = useState(() => createStyleRegistry())

useServerInsertedHTML(() => {
const styles = jsxStyleRegistry.styles()
jsxStyleRegistry.flush()
return <>{styles}</>
})

return <StyleRegistry registry={jsxStyleRegistry}>{children}</StyleRegistry>
}

Оборачиваем корневой макет в реестр:

// app/layout.tsx
import StyledJsxRegistry from './registry'

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<StyledJsxRegistry>{children}</StyledJsxRegistry>
</body>
</html>
)
}

styled-components

Включаем styled-components в файле next.config.js:

module.exports = {
compiler: {
styledComponents: true,
},
}

Используем API styled-components для создания компонента глобального реестра для сбора всех стилей, генерируемых во время рендеринга, и функцию для возврата этих стилей. Затем используем хук useServerInsertedHTML для внедрения стилей в элемент head в корневом макете.

// lib/registry.tsx
'use client'

import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'

export default function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode
}) {
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())

useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement()
styledComponentsStyleSheet.instance.clearTag()
return <>{styles}</>
})

if (typeof window !== 'undefined') return <>{children}</>

return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
)
}

Оборачиваем children корневого макета в реестр стилей:

// app/layout.tsx
import StyledComponentsRegistry from './lib/registry'

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<StyledComponentsRegistry>{children}</StyledComponentsRegistry>
</body>
</html>
)
}

Sass

Next.js имеет встроенную поддержку Sass после установки соответствующего пакета. Поддерживаются не только файлы с расширением .scss и .sass, но также модули CSS (.module.scss и .module.sass).

Устанавливаем sass:

npm i -D sass

Настройки Sass

Для конфигурации компилятора Sass можно воспользоваться настройкой sassOptions в файле next.config.js:

const path = require('path')

module.exports = {
sassOptions: {
includePaths: [path.join(__dirname, 'styles')],
},
}

Переменные Sass

Next.js поддерживает переменные Sass, экспортируемые из файлов модулей CSS.

Пример использования переменной primaryColor:

/* app/variables.module.scss */
$primary-color: #64ff00;

:export {
primaryColor: $primary-color;
}
// app/page.js
import variables from './variables.module.scss'

export default function Page() {
return <h1 style={{ color: variables.primaryColor }}>Привет, Next.js!</h1>
}

Оптимизации

Next.js предоставляет различные оптимизации для улучшения производительности и показателей Core Web Vitals приложения.

Встроенные компоненты

Встроенные компоненты абстрагируют сложность реализации популярных оптимизаций UI:

  • Image - разработан на основе нативного элемента img. Он оптимизирует изображения для производительности путем ленивой загрузки и автоматического изменения размеров на основе размеров устройства
  • Link - разработан на основе нативного элемента a. Он предварительно запрашивает данные для страниц в фоновом режиме для более быстрых и плавных переходов
  • Script - разработан на основе нативного элемента script. Он предназначен дя загрузки и выполнения сторонних скриптов

Метаданные

Метаданные помогают поисковым движкам лучше понимать контент приложения (что приводит к лучшему SEO) и позволяют кастомизировать, как контент представлен в социальных сетях, создавая более вовлекающий и согласованный UX на разных платформах.

Metadata API позволяет модифицировать элемент head страницы. Метаданные можно настраивать двумя способами:

  • метаданные на основе конфига - экспорт статического объекта metadata или динамической функции generateMetadata в файле layout.js или page.js
  • метаданные на основе файла - статические или динамически генерируемые файлы в сегменте роута

Кроме того, конструктор imageResponse позволяет создавать изображения Open Graph с помощью JSX и CSS.

Статические ресурсы

Для обслуживания статических файлов (изображения, шрифты и др.) предназначена директория public. Файлы, находящиеся в ней, могут кешироваться провайдерами CDN для эффективной доставки.

Аналитика и мониторинг

Next.js хорошо интегрируется с популярными инструментами аналитики и мониторинга приложения.

Изображения

Согласно Web Almanac изображения составляют существенную часть веса страниц веб-сайта и могут оказывать сильное влияние на LCP.

Компонент Image, предоставляемый Next.js, расширяет нативный элемент img возможностями по автоматической оптимизации изображений:

  • оптимизация размера - автоматическое использование правильного размера изображения для каждого устройства, в современном формате, вроде WebP и AVIF
  • визуальная стабильность - автоматическое предотвращение сдвига макета при загрузке изображения
  • ускорение загрузки страницы - изображения загружаются только при попадании в область видимости с помощью нативной ленивой загрузки с опциональным размытием
  • гибкость ресурса - изменение размеров изображения по запросу, даже для изображений, хранящихся на удаленных серверах

Использование

import Image from 'next/image'

Источник изображения указывается в пропе src.

Локальные изображения

Для использования локального изображения сначала необходимо его импортировать.

Next.js автоматически определяет width и height изображения на основе импортированного файла. Эти значения используются для предотвращения совокупного сдвига макета (Cumulative Layout Shift, CLS) при загрузке изображения.

// app/page.js
import Image from 'next/image'
import profilePic from './me.png'

export default function Page() {
return (
<Image
src={profilePic}
alt="Изображение автора"
// width={500} вычисляется автоматически
// height={500} вычисляется автоматически
// blurDataURL="data:..." вычисляется автоматически
// placeholder="blur" // опциональное размытие на время загрузки
/>
)
}

Удаленные изображения

Значением пропа src удаленных изображений должна быть строка URL.

Поскольку Next.js не имеет доступа к удаленным файлам в процессе сборки, пропы width, height и blurDataURL должны указываться вручную.

Атрибуты width и height используются для определения правильного соотношения сторон изображения для предотвращения сдвига макета после загрузки изображения. width и height не определяют размер самого изображения.

// app/page.js
import Image from 'next/image'

export default function Page() {
return (
<Image
src="https://s3.amazonaws.com/my-bucket/profile.png"
alt="Изображение автора"
width={500}
height={500}
/>
)
}

Для безопасного разрешения оптимизации изображений необходимо определить список паттернов URL в файле next.config.js. Паттерны должны быть максимально точными для предотвращения вредного использования. Пример конфигурации, разрешающей загрузку изображений только из определенного "бакета" AWS S3:

module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 's3.amazonaws.com',
port: '',
pathname: '/my-bucket/**',
},
],
},
}

Домены

Иногда может возникнуть потребность оптимизировать удаленное изображение при сохранении встроенного API оптимизации изображений Next.js. Для этого нужно оставить loader в качестве дефолтной настройки и указать абсолютный URL для пропа src.

Загрузчики

Обратите внимание, что в одном из предыдущих примеров мы указали относительный путь /me.png для локального изображения. Это возможно благодаря загрузчикам (loaders).

Загрузчик - это функция, генерирующая URL для изображения. Она модифицирует указанный src и генерирует несколько URL для запроса изображений разного размера. Эти URL используются для автоматической генерации srcset, чтобы пользователи получали изображение нужного размера.

Дефолтный загрузчик использует встроенный API оптимизации изображений, который оптимизирует любые изображения и затем доставляет их с веб-сервера Next.js. Для доставки изображений прямо из CDN или сервера изображений можно написать собственный загрузчик. Это потребует нескольких строк JS-кода.

Загрузчик конкретного изображения может быть указан с помощью пропа loader, а на уровне приложения это можно сделать с помощью настройки loaderFile.

Приоритет

Изображениям, которые являются элементами Largest Contentful Paint (LCP), следует устанавливать проп priority. Это позволяет Next.js приоритизировать загрузку таких изображений, что приводит к существенному улучшению LCP.

Элемент LCP - это, как правило, самое большое изображение или блок текста, находящийся в области просмотра. При запуске next dev, мы увидим предупреждение в консоли, если элементом LCP является Image без пропа priority.

// app/page.js
import Image from 'next/image'
import profilePic from '../public/me.png'

export default function Page() {
return <Image src={profilePic} alt="Изображение автора" priority />
}

Размеры изображения

Сдвиг макета происходит, когда после загрузки изображение двигает другие элементы на странице. Это проблема производительности так сильно раздражает пользователей, что имеет собственный показатель Core Web Vitals - Cumulative Layout Shift (CLS). Одним из способов предотвращения сдвига макета является резервирование на странице достаточного места для изображения.

Поскольку компонент Image спроектирован для достижения лучшей производительности, он не может использоваться способами, которые могут привести к сдвигу макета. Размеры изображения должны быть определены одним из трех способов:

  1. Автоматически с помощью статического импорта.
  2. Явно через пропы width и height.
  3. Неявно с помощью пропа fill, который заставляет изображение расширяться для заполнения родительского элемента.

Стилизация

Стилизация компонента Image похожа на стилизацию элемента img, за исключением следующего:

  • для стилизации следует использовать className или style, а не styled-jsx
    • рекомендуется использовать проп className. Это может быть импортированный модуль CSS, глобальная таблица стилей и т.п.
    • также можно использовать проп style
    • styled-jsx использовать нельзя, поскольку область таких стилей ограничена текущим элементом (если не установлен атрибут global)
  • при использовании fill, родительский элемент должен иметь position: relative
    • это необходимо для корректного рендеринга изображения в этом режиме
  • при использовании fill, родительский элемент должен иметь display: block

Примеры

Отзывчивое изображение


import Image from 'next/image'
import mountains from '../public/mountains.jpg'

export default function Responsive() {
return (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Image
alt="Mountains"
// Импорт изображения
// автоматически устанавливает `width` и `height`
src={mountains}
sizes="100vw"
// Изображение занимает всю ширину
style={{
width: '100%',
height: 'auto',
}}
/>
</div>
)
}

Заполнение контейнера


import Image from 'next/image'
import mountains from '../public/mountains.jpg'

export default function Fill() {
return (
<div
style={{
display: 'grid',
gridGap: '8px',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, auto))',
}}
>
<div style={{ position: 'relative', height: '400px' }}>
<Image
alt="Mountains"
src={mountains}
fill
sizes="(min-width: 808px) 50vw, 100vw"
style={{
objectFit: 'cover', // cover, contain, none
}}
/>
</div>
{/* Другие изображения в сетке... */}
</div>
)
}

Фоновое изображение


import Image from 'next/image'
import mountains from '../public/mountains.jpg'

export default function Background() {
return (
<Image
alt="Mountains"
src={mountains}
placeholder="blur"
quality={100}
fill
sizes="100vw"
style={{
objectFit: 'cover',
}}
/>
)
}

Компонент Image.

Видео

Использование video и iframe

Видео может добавляться на страницу с помощью HTML-тега video для локальных видео-файлов и iframe для видео со сторонних платформ.

video

Тег video позволяет добавлять локальный видео-контент и предоставляет полный контроль над воспроизведением и внешним видом плеера:

export function Video() {
return (
<video width="320" height="240" controls preload="none">
// из-за source ломается хабровская верстка, пришлось закомментить
// <source src="/path/to/video.mp4" type="video/mp4" />
<track
src="/path/to/captions.vtt"
kind="subtitles"
srcLang="en"
label="English"
/>
Ваш браузер не поддерживает тег video.
</video>
)
}

Распространенные атрибуты video

АтрибутОписаниеПример значения
srcОпределяет источник видео-файла<video src="/path/to/video.mp4" />
widthУстанавливает ширину видео-плеера<video width="320" />
heightУстанавливает высоту видео-плеера<video height="240" />
controlsОпределяет отображение дефолтных кнопок управления воспроизведением<video controls />
autoPlayЗапускает воспроизведение после загрузки страницы<video autoPlay />
loopЗацикливает воспроизведение<video loop />
mutedОтключает аудио<video muted />
preloadОпределяет, как видео предварительно загружается. Возможные значения: none, metadata, auto<video preload="none" />
playsInlineВключает встроенное воспроизведение на устройствах iOS, часто требуется для работы autoPlay в Safari<video playsInline />

Лучшие практики работы с видео

  • Субтитры или подписи - включите субтитры или подписи для людей с нарушениями слуха. Для определения источников подписей используется тег track
  • доступные кнопки управления - стандартные кнопки позволяют управлять воспроизведением с помощью клавиатуры и совместимы с устройствами чтения с экрана. Для продвинутых случаев можно использовать сторонние библиотеки вроде react-player или video.js

iframe

Тег iframe позволяет добавлять видео со сторонних платформ, таких как YouTube или Vimeo:

export default function Page() {
return (
<iframe
src="https://www.youtube.com/watch?v=gfU1iZnjRZM"
frameborder="0"
allowfullscreen
/>
)
}

Распространенные атрибуты iframe

АтрибутОписаниеПример значения
srcURL внедряемой страницы<iframe src="https://example.com" />
widthУстанавливает ширину iframe<iframe width="500" />
heightУстанавливает высоту iframe<iframe height="300" />
frameborderОпределяет отображение границы<iframe frameborder="0" />
allowfullscreenОпределяет возможность отображения контента в полноэкранном режиме<iframe allowfullscreen />
sandboxВключает дополнительный набор ограничений для контента<iframe sandbox />
loadingОпределяет способ загрузки контента (например, ленивую загрузку)<iframe loading="lazy" />
titleПозволяет добавлять описание контента для доступности<iframe title="Описание" />

Выбор метода добавления видео

Существует 2 способа добавить видео в приложение Next.js:

  • локальные видео-файлы - для полного контроля функциональности и внешнего вида плеера следует использовать тег video со ссылкой на локальный файл
  • видео хостинг-сервисы (YouTube, Vimeo и др.) - для добавления видео, размещенного на сторонних платформах, следует использовать тег iframe

Добавление внешнего видео

Для добавления внешнего видео можно использовать Next.js для получения информации о видео и компонент Suspense для предоставления резервного контента во время загрузки видео.

  1. Создание серверного компонента для добавления видео.

Этот компонент запрашивает данные видео и рендерит iframe:

// app/ui/video-component.jsx
export default async function VideoComponent() {
const src = await getVideoSrc()

return <iframe src={src} frameborder="0" allowfullscreen />
}
  1. Стриминг видео с помощью Suspense.
// app/page.jsx
import { Suspense } from 'react'
import VideoComponent from '../ui/VideoComponent.jsx'

export default function Page() {
return (
<section>
<Suspense fallback={<p>Загрузка...</p>}>
<VideoComponent />
</Suspense>
{/* Другой контент страницы */}
</section>
)
}

Такой подход приводит к лучшему UX, поскольку он предотвращает блокировку страницы - пользователь может взаимодействовать с ней во время загрузки видео.

В качестве более информативного резервного контента можно использовать скелет:

// app/page.jsx
import { Suspense } from 'react'
import VideoComponent from '../ui/VideoComponent.jsx'
import VideoSkeleton from '../ui/VideoSkeleton.jsx'

export default function Page() {
return (
<section>
<Suspense fallback={<VideoSkeleton />}>
<VideoComponent />
</Suspense>
{/* Другой контент страницы */}
</section>
)
}

Локальные видео

Локальные видео могут быть предпочтительными по следующим основаниям:

  • полный контроль и независимость - локальные файлы предоставляют полный контроль над воспроизведением видео и внешним видом плеера, отсутствуют ограничения сторонних платформам
  • возможность кастомизации под конкретные нужды
  • производительность и масштабируемость
  • низкая стоимость хранения и легкость интеграции

Использование Vercel Blob в качестве видео-хостинга

Vercel Blob предлагает эффективный способ хостинга видео, предоставляя масштабируемое облачное хранилище, которое прекрасно интегрируется с Next.js.

  1. Загрузка видео на Vercel Blob.

На панели управления Vercel перейдите на вкладку "Storage" и выберите Vercel Blob. В правом верхнем углу найдите и нажмите кнопку "Upload". Выберите файл для загрузки. После загрузки видео появится в таблице.

Видео можно загружать с помощью серверных операций. Vercel также поддерживает загрузку на клиенте, которая может быть предпочтительной в некоторых случаях.

  1. Отображение видео в приложении.
import { Suspense } from 'react'
import { list } from '@vercel/blob'

export default function Page() {
return (
<Suspense fallback={<p>Загрузка...</p>}>
<VideoComponent fileName="my-video.mp4" />
</Suspense>
)
}

async function VideoComponent({ fileName }) {
const { blobs } = await list({
prefix: fileName,
limit: 1,
})
const { url } = blobs[0]

return (
<video controls preload="none" aria-label="Видео-плеер">
// из-за source ломается хабровская верстка, пришлось закомментить
// <source src={url} type="video/mp4" />
Ваш браузер не поддерживает тег video.
</video>
)
}

Добавление субтитров

Субтитры к видео можно добавить с помощью элемента track. Субтитры также можно загрузить на Vercel Blob.

async function VideoComponent({ fileName }) {
const { blobs } = await list({
prefix: fileName,
limit: 2
});
const { url } = blobs[0];
const { url: captionsUrl } = blobs[1];

return (
<video controls preload="none" aria-label="Видео-плеер">
// из-за source ломается хабровская верстка, пришлось закомментить
// <source src={url} type="video/mp4" />
<track
src={captionsUrl}
kind="subtitles"
srcLang="en"
label="English"
>
Ваш браузер не поддерживает тег video.
</video>
);
};

Дополнительные источники

Подробнее узнать об оптимизации и лучших практиках работы с видео можно в следующих источниках:

Компонент next-video

  • Предоставляет компонент Video для Next.js, совместимый с разными хостинг-сервисами, включая Vercel Blob, S3, Backblaze и Mux
  • имеет отличную документацию

Интеграция с Cloudinary

Шрифты

next/font автоматически оптимизирует шрифты (включая локальные) и удаляет внешние сетевые запросы для улучшения приватности и производительности.

next/font включает встроенный автоматический хостинг любого файла шрифта. Это означает, что мы можем оптимально загружать веб-шрифты с нулевым сдвигом макета благодаря CSS-свойству size-adjust, которое используется под капотом.

Google Fonts

next/font/google автоматически хостит любой шрифт Google. Шрифты включаются в сборку вместе с другими статическими ресурсами и обслуживаются из того же домена, что и приложение. Для лучшей производительности и гибкости рекомендуется использовать вариативные шрифты.

// app/layout.tsx
import { Inter } from 'next/font/google'

// При загрузке вариативного шрифта, вес определять не нужно
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}

Если нельзя использовать вариативный шрифт, вес шрифта нужно указывать явно:

// app/layout.tsx
import { Roboto } from 'next/font/google'

const roboto = Roboto({
weight: '400',
subsets: ['latin'],
display: 'swap',
})

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={roboto.className}>
<body>{children}</body>
</html>
)
}

С помощью массива можно определить несколько весов и/или стилей:

const roboto = Roboto({
weight: ['400', '700'],
style: ['normal', 'italic'],
subsets: ['latin'],
display: 'swap',
})

Определение набора шрифтов

Определение наборов (subsets) шрифтов уменьшает размер файла и улучшает производительность. Наборы предварительно загружаемых шрифтов определяются в объекте, передаваемом функции:

const inter = Inter({ subsets: ['latin'] })

Использование нескольких шрифтов

Существует два подхода к импорту и использованию нескольких шрифтов в приложении.

Первый подход заключается в создании утилиты, экспортирующей шрифты, их импорте и применении className по-необходимости. Это загружает шрифт только при его рендеринге.

// app/fonts.ts
import { Inter, Roboto_Mono } from 'next/font/google'

export const inter = Inter({
subsets: ['latin'],
display: 'swap',
})

export const roboto_mono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
})
// app/layout.tsx
import { inter } from './fonts'

export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>
<div>{children}</div>
</body>
</html>
)
}
// app/page.tsx
import { roboto_mono } from './fonts'

export default function Page() {
return (
<>
<h1 className={roboto_mono.className}>Моя страница</h1>
</>
)
}

В этом примере Inter применяется глобально, а Roboto Mono импортируется и применяется локально.

В качестве альтернативы можно создать переменную CSS и использовать подходящее решение CSS:

// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
import styles from './global.css'

const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
})

const roboto_mono = Roboto_Mono({
subsets: ['latin'],
variable: '--font-roboto-mono',
display: 'swap',
})

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${roboto_mono.variable}`}>
<body>
<h1>Мое приложение</h1>
<div>{children}</div>
</body>
</html>
)
}
/* app/global.css */
html {
font-family: var(--font-inter);
}

h1 {
font-family: var(--font-roboto-mono);
}

В этом примере Inter применяется глобально, а к тегам h1 применяется Roboto Mono.

Локальные шрифты

next/font/local позволяет загружать локальные шрифты. Для лучшей производительности и гибкости рекомендуется использовать вариативные шрифты.

import localFont from 'next/font/local'

// Файлы шрифтов могут размещаться в директории `app`
const myFont = localFont({
src: './my-font.woff2',
display: 'swap',
})

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={myFont.className}>
<body>{children}</body>
</html>
)
}

Для использования нескольких файлов для одного семейства шрифтов можно использовать массив в качестве значения пропа src:

const roboto = localFont({
src: [
{
path: './Roboto-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './Roboto-Italic.woff2',
weight: '400',
style: 'italic',
},
{
path: './Roboto-Bold.woff2',
weight: '700',
style: 'normal',
},
{
path: './Roboto-BoldItalic.woff2',
weight: '700',
style: 'italic',
},
],
})

Вместе с Tailwind CSS

next/font может быть использован вместе с Tailwind CSS через переменные CSS.

// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'

const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})

const roboto_mono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
})

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${roboto_mono.variable}`}>
<body>{children}</body>
</html>
)
}
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
'./app/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)'],
mono: ['var(--font-roboto-mono)'],
},
},
},
plugins: [],
}

После этого для применения к элементам шрифтов можно использовать классы-утилиты font-sans и font-mono.

Предварительная загрузка

Вызов функции шрифта на странице не делает шрифт глобально доступным или предварительно загруженным для всех роутов. Предварительная загрузка шрифта зависит от типа файла, в котором он используется:

  • для уникальной страницы шрифт предварительно загружается для уникального роута этой страницы
  • для макета шрифт предварительно загружается для всех роутов, обернутых этим макетом
  • для корневого макета шрифт предварительно загружается для всех роутов

Повторное использование шрифтов

При каждом вызове функции шрифта создается экземпляр шрифта, который сохраняется в приложении. Во избежание создания нескольких экземпляров одного шрифта рекомендуется делать следующее:

  • вызывать функцию шрифта в общем файле
  • экспортировать ее как константу
  • импортировать константу в каждый файл, в котором используется шрифт

API шрифтов.

Метаданные

Next.js предоставляет Metadata API для определения метаданных приложения (например, тегов meta и link внутри элемента head) для улучшения SEO.

Существует два способа определения метаданных:

  • на основе конфига - экспорт статического объекта metadata или динамической функции generateMetadata в файле layout.js или page.js
  • на основе файлов - статические или динамические специальные файлы в сегментах роута

Статические метаданные

Для определения статических метаданных достаточно экспортировать объект metadata из файла layout.js или page.js:

import type { Metadata } from 'next'

export const metadata: Metadata = {
title: '...',
description: '...',
}

export default function Page() {}

Динамические метаданные

Функция generateMetadata предназначена для генерации метаданных, которые требуют динамических значений:

// app/products/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'

type Props = {
params: { id: string }
searchParams: { [key: string]: string | string[] | undefined }
}

export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
// Читаем параметры роута
const id = params.id

// Получаем данные
const product = await (await fetch(`https://.../${id}`)).json()

// Опциональный доступ и расширение (вместо замены) родительских метаданных
const previousImages = (await parent).openGraph?.images || []

return {
title: product.title,
openGraph: {
images: ['/some-specific-page-image.jpg', ...previousImages],
},
}
}

export default function Page({ params, searchParams }: Props) {}

Настройки объекта metadata и функции generateMetadata.

Метаданные на основе файлов

Для метаданных доступны следующие специальные файлы:

  • favicon.ico, apple-icon.jpg и icon.jpg
  • opengraph-image.jpg и twitter-image.jpg
  • robots.txt
  • sitemap.xml

Эти файлы могут быть статическими или генерироваться динамически.

Поведение

Метаданные на основе файлов имеют более высокий приоритет и перезаписывают метаданные на основе конфига.

Дефолтные поля

Существует два дефолтных тега meta, которые добавляются в каждый роут:

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

Порядок

Метаданные оцениваются по порядку, начиная от корневого сегмента и заканчивая сегментом, ближайшим к финальной странице. Например:

  1. app/layout.tsx (корневой макет)
  2. app/blog/layout.tsx (вложенный макет блога)
  3. app/blog/[slug]/page.tsx (страница блога)

Объединение

Объекты метаданных, экспортируемые из нескольких сегментов, поверхностно объединяются для формирования финальных метаданных роута. Дублирующиеся ключи заменяются на основе порядка оценивания метаданных.

Перезапись полей

// app/layout.js
export const metadata = {
title: 'Acme',
openGraph: {
title: 'Acme',
description: 'Acme - это...',
},
}
// app/blog/page.js
export const metadata = {
title: 'Блог',
openGraph: {
title: 'Блог',
},
}

// Результат:
// <title>Блог</title>
// <meta property="og:title" content="Блог" />

Для того, чтобы сделать некоторые вложенные поля общими для нескольких сегментов, а другие перезаписать, можно вынести их в отдельную переменную:

// app/shared-metadata.js
export const openGraphImage = { images: ['http://...'] }
// app/page.js
import { openGraphImage } from './shared-metadata'

export const metadata = {
openGraph: {
...openGraphImage,
title: 'Главная',
},
}
// app/about/page.js
import { openGraphImage } from '../shared-metadata'

export const metadata = {
openGraph: {
...openGraphImage,
title: 'Контакты',
},
}

В этом примере изображение OG распределяется между app/layout.js и app/about/page.js, а title отличаются.

Наследование полей

// app/layout.js
export const metadata = {
title: 'Acme',
openGraph: {
title: 'Acme',
description: 'Acme - это...',
},
}
// app/about/page.js
export const metadata = {
title: 'Контакты',
}

// Результат:
// <title>Контакты</title>
// <meta property="og:title" content="Acme" />
// <meta property="og:description" content="Acme - это..." />

Файлы метаданных.

Динамическая генерация изображений

Конструктор ImageResponse позволяет генерировать динамические изображения с помощью JSX и CSS. Это полезно для создания изображений для социальных сетей, таких как изображения OG, карточки Twitter и др.

ImageResponse использует граничную среду выполнения, и Next.js автоматически добавляет правильные заголовки для кеширования изображений, что улучшает производительность и уменьшает количество повторных вычислений.

// app/about/route.js
import { ImageResponse } from 'next/og'

export const runtime = 'edge'

export async function GET() {
return new ImageResponse(
(
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
textAlign: 'center',
alignItems: 'center',
justifyContent: 'center',
}}
>
Всем привет!
</div>
),
{
width: 1200,
height: 600,
}
)
}

Конструктор ImageResponse.

JSON-LD

JSON-LD - это формат структурированных данных, который может быть использован поисковыми движками для анализа контента страницы. Например, мы можем использовать его для описания человека, события, организации, фильма, книги, рецепта и многих других вещей.

Для JSON-LD рекомендуется рендерить тег script в компонентах layout.js или page.js.

// app/products/[id]/page.tsx
export default async function Page({ params }) {
const product = await getProduct(params.id)

const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.image,
description: product.description,
}

return (
<section>
{/* Добавляем JSON-LD на страницу */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* ... */}
</section>
)
}

Для валидации и тестирования структурированных данных можно воспользоваться Rich Results Test для Google или общим Schema Markup Validator.

Типизировать JSON-LD (TypeScript) можно с помощью пакета schema-dts:

import { Product, WithContext } from 'schema-dts'

const jsonLd: WithContext<Product> = {
'@context': 'https://schema.org',
'@type': 'Product',
name: 'Стикер Next.js',
image: 'https://nextjs.org/imgs/sticker.png',
description: 'Динамический по цене статического.',
}

Скрипты

Скрипты макетов

next/script позволяет загружать сторонние скрипты:

// app/dashboard/layout.tsx
import Script from 'next/script'

export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<section>{children}</section>
<Script src="https://example.com/script.js" />
</>
)
}

Сторонний скрипт будет загружен при доступе пользователя к роуту директории (например, dashboard/page.js) или к любому вложенному роуту (например, dashboard/settings/page.js). Скрипт загружается только один раз, даже если пользователь перемещается между несколькими роутами в одном макете.

Скрипты всего приложения

// app/layout.tsx
import Script from 'next/script'

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
<Script src="https://example.com/script.js" />
</html>
)
}

Скрипт будет загружен и выполнен при доступе к любому роуту приложения.

Стратегия

Проп strategy используется для определения стратегии загрузки стороннего скрипта:

  • beforeInteractive - скрипт загружается до любого другого кода и гидратации
  • afterInteractive (по умолчанию) - скрипт загружается после частичной гидратации
  • lazyOnload - скрипт загружается во время простоя (idle) браузера
  • worker (экспериментальная стратегия) - скрипт загружается в веб-воркере

Встроенные скрипты

Компонент Script позволяет писать встроенные скрипты. Код JS помещается в фигурные скобки:

<Script id="show-banner">
{`document.getElementById('banner').classList.remove('hidden')`}
</Script>

Вместо фигурных скобок можно использовать проп dangerouslySetInnerHTML:

<Script
id="show-banner"
dangerouslySetInnerHTML={{
__html: `document.getElementById('banner').classList.remove('hidden')`,
}}
/>

Выполнение дополнительного кода

Для выполнения дополнительного кода могут использоваться обработчики следующих событий:

  • onLoad - возникает после загрузки скрипта
  • onReady - возникает после загрузки скрипта и при каждом монтировании компонента
  • onError - возникает при провале загрузки скрипта

Обратите внимание, что эти обработчики работают только в клиентских компонентах.

'use client'

import Script from 'next/script'

export default function Page() {
return (
<>
<Script
src="https://example.com/script.js"
onLoad={() => {
console.log('Скрипт загружен')
}}
/>
</>
)
}

Дополнительные атрибуты

Существует множество атрибутов DOM, которые могут быть присвоены элементу script, но не используются компонентом Script, например, nonce или кастомные дата-атрибуты. Эти атрибуты передаются финальному элементу script, помещаемому в HTML.

import Script from 'next/script'

export default function Page() {
return (
<>
<Script
src="https://example.com/script.js"
id="example-script"
nonce="XUENAJFW"
data-test="script"
/>
</>
)
}

Компонент Script.

Анализ сборки

@next/bundle-analyzer - это плагин Next.js, который генерирует визуальный отчет о размере каждого модуля приложения и его зависимостей. Это информация может использоваться для удаления больших зависимостей, разделения кода, загрузки кода по-необходимости, что уменьшает количество данных, передаваемых клиенту.

Установка

Устанавливаем плагин:

npm i @next/bundle-analyzer
# или
yarn add @next/bundle-analyzer
# или
pnpm add @next/bundle-analyzer

Добавляем настройки анализатора сборки в файл next.config.js:

const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})

/** @type {import('next').NextConfig} */
const nextConfig = {}

module.exports = withBundleAnalyzer(nextConfig)

Анализ сборки

Запускаем команду для анализа сборки:

ANALYZE=true npm run build
# или
ANALYZE=true yarn build
# или
ANALYZE=true pnpm build

Выполнение этой команды приводит к открытию трех новых вкладок браузера. Рекомендуется регулярно анализировать сборку во время разработки и перед деплоем приложения.

Ленивая загрузка

Ленивая загрузка позволяет ускорить начальную загрузку приложения путем уменьшения количества JS, необходимого для рендеринга роута.

Она позволяет отложить загрузку клиентских компонентов и сторонних библиотек до тех пор, пока они не понадобятся. Например, можно отложить загрузку кода модального окна до того, как пользователь нажмет кнопку для его открытия.

Существует два способа ленивой загрузки в Next.js:

  1. Динамический импорт с помощью next/dynamic.
  2. Функция React.lazy и компонент Suspense.

next/dynamic

next/dynamic - это сочетание React.lazy и Suspense.

Примеры

Импорт клиентских компонентов

'use client'

import { useState } from 'react'
import dynamic from 'next/dynamic'

// Клиентские компоненты
const ComponentA = dynamic(() => import('../components/A'))
const ComponentB = dynamic(() => import('../components/B'))
const ComponentC = dynamic(() => import('../components/C'), { ssr: false })

export default function ClientComponentExample() {
const [showMore, setShowMore] = useState(false)

return (
<div>
{/* Загружается сразу, но отдельной клиентской сборкой */}
<ComponentA />

{/* Загружается по запросу, при удовлетворении условия */}
{showMore && <ComponentB />}
<button onClick={() => setShowMore(!showMore)}>Переключить</button>

{/* Загружается только на стороне клиента */}
<ComponentC />
</div>
)
}

Пропуск SSR

При использовании React.lazy и Suspense клиентские компоненты предварительно рендерятся на сервере по умолчанию (SSR).

Для отключения SSR клиентского компонента нужно установить настройку ssr в значение false:

const ComponentC = dynamic(() => import('../components/C'), { ssr: false })

Импорт серверных компонентов

При динамическом импорте серверного компонента, лениво загружаются только его дочерние клиентские компоненты, сам серверный компонент загружается сразу:

import dynamic from 'next/dynamic'

// Серверный компонент
const ServerComponent = dynamic(() => import('../components/ServerComponent'))

export default function ServerComponentExample() {
return (
<div>
<ServerComponent />
</div>
)
}

Загрузка внешних библиотек

Внешние библиотеки могут загружаться по запросу с помощью функции import. В следующем примере модуль fuse.js загружается, когда пользователь начинает вводить символы в поле для поиска:

'use client'

import { useState } from 'react'

const names = ['Игорь', 'Вера', 'Олег', 'Елена']

export default function Page() {
const [results, setResults] = useState()

return (
<div>
<input
type="text"
placeholder="Поиск..."
onChange={async (e) => {
const { value } = e.currentTarget
// Динамическая загрузка `fuse.js`
const Fuse = (await import('fuse.js')).default
const fuse = new Fuse(names)

setResults(fuse.search(value))
}}
/>
<pre>Результаты: {JSON.stringify(results, null, 2)}</pre>
</div>
)
}

Индикатор загрузки

import dynamic from 'next/dynamic'

const WithCustomLoading = dynamic(
() => import('../components/WithCustomLoading'),
{
loading: () => <p>Загрузка...</p>,
}
)

export default function Page() {
return (
<div>
{/* Индикатор будет отображаться до загрузки компонента `WithCustomLoading` */}
<WithCustomLoading />
</div>
)
}

Импорт именованного экспорта

// components/hello.js
'use client'

export function Hello() {
return <p>Привет!</p>
}
// app/page.js
import dynamic from 'next/dynamic'

const ClientComponent = dynamic(() =>
import('../components/hello').then((mod) => mod.Hello)
)

Аналитика

Next.js предоставляет метрики производительности приложения из коробки. Хук useReportWebVitals позволяет управлять отчетами вручную. В качестве альтернативы Vercel предоставляет специальный сервис.

Ручное управление отчетами

// app/_components/web-vitals.js
'use client'

import { useReportWebVitals } from 'next/web-vitals'

export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric)
})
}
// app/layout.js
import { WebVitals } from './_components/web-vitals'

export default function Layout({ children }) {
return (
<html>
<body>
<WebVitals />
{children}
</body>
</html>
)
}

Хук useReportWebVitals.

Web Vitals

Web Vitals - это набор полезных метрик, направленных на улучшение UX:

Результаты этих метрик можно обработать с помощью свойства name:

'use client'

import { useReportWebVitals } from 'next/web-vitals'

export function WebVitals() {
useReportWebVitals((metric) => {
switch (metric.name) {
case 'FCP': {
// Обрабатываем результаты FCP
}
case 'LCP': {
// Обрабатываем результаты FCP
}
// ...
}
})
}

Отправка результатов во внешние системы

useReportWebVitals((metric) => {
const body = JSON.stringify(metric)
const url = 'https://example.com/analytics'

// Используем `navigator.sendBeacon` или `fetch`
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body)
} else {
fetch(url, { body, method: 'POST', keepalive: true })
}
})
useReportWebVitals((metric) => {
// Используем `window.gtag` при инициализации Google Analytics, как показано в этом примере:
// https://github.com/vercel/next.js/blob/canary/examples/with-google-analytics/pages/_app.js
window.gtag('event', metric.name, {
value: Math.round(
metric.name === 'CLS' ? metric.value * 1000 : metric.value
), // значения должны быть целыми числами
event_label: metric.id, // `id` является уникальным для текущей загрузки страницы
non_interaction: true, // позволяет избежать влияния отказов на показатель
})
})

Статические ресурсы

Next.js умеет обслуживать статические файлы, такие как изображения, находящиеся в директории public в корне проекта. На файлы внутри public можно ссылаться, начиная с базового URL (/).

Например, путь к файлу public/avatars/me.png будет выглядеть как /avatars/me.png:

import Image from 'next/image'

export function Avatar({ id, alt }) {
return <Image src={`/avatars/${id}.png`} alt={alt} width="64" height="64" />
}

export function AvatarOfMe() {
return <Avatar id="me" alt="Мой портрет" />
}

Кеширование

Next.js не может безопасно кешировать файлы из директории public, поскольку они могут измениться. Дефолтными заголовками кеширования являются следующие:

Cache-Control: public, max-age=0

Файлы метаданных

Для статических файлов метаданных, таких как robots.txt, favicon.ico и др. должны использоваться специальные файлы в директории app.

Сторонние библиотеки

@next/third-parties - это библиотека, которая предоставляет коллекцию компонентов и утилит, улучшающих производительность и опыт разработчика по работе с популярными сторонними библиотеками в приложении Next.js.

Сторонние интеграции, предоставляемые @next/third-parties, оптимизированы для повышения производительности и облегчения использования.

Начало работы

Устанавливаем библиотеку:

npm install @next/third-parties@latest next@latest

@next/third-parties - это экспериментальная библиотека, которая находится в стадии активной разработки.

Решения от Google

Все поддерживаемые библиотеки от Google импортируются из @next/third-parties/google:

Кроме этого, @next/third-parties предоставляет компонент YouTubeEmbed для внедрения контента с YouTube.

Настройка

TypeScript

Новые проекты

CLI create-next-app по умолчанию создает проект с поддержкой TS:

npx create-next-app@latest

Существующие проекты

Меняем расширения файлов JS на .ts/.tsx. Запускаем next dev и next build для автоматической установки необходимых зависимостей и добавляем файл tsconfig.json с рекомендуемыми настройками.

Если у нас есть старый файл jsconfig.json, копируем настройку path из него в файл tsconfig.json и удаляем jsconfig.json.

Плагин TS

Next.js предоставляет кастомный плагин TS и контроллер типов, которые VSCode и другие редакторы кода могут использовать для продвинутой проверки типов и автозавершений.

Для включения плагина необходимо сделать следующее:

  1. Открываем командую панель (Ctrl/⌘ + Shift + P).
  2. Ищем "TypeScript: Select TypeScript Version".
  3. Выбираем "Use Workspace Version".

Возможности плагина

  • Предупреждения о невалидных значениях, передаваемых в настройки сегмента роута
  • отображение доступных настроек и контекстной документации
  • обеспечение правильного использования директивы use client
  • обеспечение использования клиентских хуков (таких как useState) только в клиентских компонентах

Статическая типизация ссылок

Next.js может статически типизировать ссылки для предотвращения опечаток и других ошибок при использовании компонента Link, что улучшает типобезопасность при навигации между страницами.

Включить эту возможность можно с помощью файла next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
typedRoutes: true,
},
}

module.exports = nextConfig

Next.js генерирует определение ссылок в .next/types, содержащее информацию обо всех существующих роутах приложения, которая может использоваться TS для определения невалидных ссылок.

import type { Route } from 'next';
import Link from 'next/link'

// Ошибки TS отсутствуют, если `href` - валидный роут
<Link href="/about" />
<Link href="/blog/nextjs" />
<Link href={`/blog/${slug}`} />
<Link href={('/blog' + slug) as Route} />

// Ошибка TS, если `href` - невалидный роут
<Link href="/aboot" />

Для того, чтобы принимать href в кастомном компоненте, оборачивающем Link, необходимо использовать дженерик:

import type { Route } from 'next'
import Link from 'next/link'

function Card<T extends string>({ href }: { href: Route<T> | URL }) {
return (
<Link href={href}>
<div>Моя карточка</div>
</Link>
)
}

Сквозная безопасность типов

Роутер приложения Next.js имеет продвинутую безопасность типов:

  1. Отсутствие сериализации данных между функцией получения данных и страницей. Мы можем получать данные (fetch) прямо в компонентах, макетах и страницах на сервере. Эти данные не нужно сериализовывать (конвертировать в строку) для передачи клиенту (для потребления React). Поскольку компоненты директории app являются серверными по умолчанию, мы можем использовать Date, Map, Set и другие типы данных как есть.
  2. Более простой поток данных между компонентами. Замена компонента _app корневым макетом облегчает визуализацию потока данных между страницами и компонентами.

Мы можем типизировать данные из ответа обычным способом:

async function getData() {
const res = await fetch('https://api.example.com/...')
// Возвращаемое значение не сериализуется
// Можно возвращать `Date`, `Map`, `Set` и др.
return res.json()
}

export default async function Page() {
const name = await getData()

return // ...
}

Передача данных между серверными и клиентскими компонентами

При передаче данных между серверным и клиентским компонентами через пропы, данные сериализуются (конвертируются в строку) для использования в браузере. Однако специальные типы для них не нужны. Они типизируются точно также, как любые другие пропы.

Синонимы путей и baseUrl

Next.js автоматически поддерживает настройки paths и baseUrl из файла tsconfig.json.

Проверка типов в next.config.js

// @ts-check

/**
* @type {import('next').NextConfig}
**/
const nextConfig = {
/* настройки */
}

module.exports = nextConfig

Инкрементальная проверка типов

Начиная с v10.2.1, Next.js поддерживает инкрементальную проверку типов при ее включении в tsconfig.json, что может ускорить проверку типов в больших приложениях.

Определения кастомных типов

Для определения кастомных типов нельзя использовать файл next-env.d.ts, поскольку он генерируется автоматически, и наши изменения будут перезаписаны. Вместо этого, нужно создать новый файл, например, new-types.d.ts и сослаться на него в tsconfig.json:

{
"compilerOptions": {
"skipLibCheck": true
// ...
},
"include": [
"new-types.d.ts",
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": ["node_modules"]
}

Переменные окружения

Next.js предоставляет встроенную поддержку переменных окружения, что позволяет делать следующее:

  • использовать файл .env.local для загрузки таких переменных
  • добавлять такие переменные в сборку для клиента с помощью префикса NEXT_PUBLIC_

Загрузка переменных

Next.js загружает переменные из .env.local в process.env:

# .env.local
DB_HOST=localhost
DB_USER=myuser
DB_PASS=mypassword
// app/api/route.js
export async function GET() {
const db = await myDB.connect({
host: process.env.DB_HOST,
username: process.env.DB_USER,
password: process.env.DB_PASS,
})
// ...
}

Ссылка на другие переменные

На другие переменные можно ссылаться с помощью префикса $ + название переменной, например:

TWITTER_USER=nextjs
TWITTER_URL=https://twitter.com/$TWITTER_USER

process.env.TWITTER_URL будет иметь значение https://twitter.com/nextjs.

Добавление переменных в сборку для клиента

Обычные переменные доступны только на сервере. Для того, чтобы сделать переменную доступной в браузере, необходимо добавить префикс NEXT_PUBLIC_ к ее названию, например:

NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk

Все process.env.NEXT_PUBLIC_ANALYTICS_ID в клиентском коде будут заменены на abcdefghijk при сборке приложения.

Обратите внимание, что переменные должны быть статическими.

// Это не будет работать
const varName = 'NEXT_PUBLIC_ANALYTICS_ID'
setupAnalyticsService(process.env[varName])

// Это тоже не будет работать
const env = process.env
setupAnalyticsService(env.NEXT_PUBLIC_ANALYTICS_ID)

Дефолтные переменные

В большинстве случаев для переменных нужен только файл .env.local. Однако Next.js также позволяет устанавливать дефолтные переменные в файлах .env (все окружения), .env.development (рабочее окружение) и .env.production (производственное окружение).

.env.local перезаписывает дефолтные наборы.

Порядок загрузки переменных

Порядок загрузки переменных следующий:

  1. process.env
  2. .env.$(NODE_ENV).local
  3. .env.local (не проверяется, когда NODE_ENV имеет значение test)
  4. .env.$(NODE_ENV)
  5. .env

Например, если мы определили переменную NODE_ENV в .env.development.local и .env, будет использовано значение из .env.development.local.

Абсолютные импорты и синонимы путей модулей

Next.js имеет встроенную поддержку настроек paths и baseUrl файлов tsconfig.json и jsconfig.json.

Эти настройки позволяют привязывать пути директорий к абсолютным путям, что облегчает импорт модулей, например:

// До
import { Button } from '../../../components/button'

// После
import { Button } from '@/components/button'

Абсолютные импорты

Настройка baseUrl позволяет импортировать модули прямо из корня проекта.

Пример:

// tsconfig.json или jsconfig.json
{
"compilerOptions": {
"baseUrl": "."
}
}
// components/button.tsx
export default function Button() {
return <button>Нажми на меня</button>
}
// app/page.tsx
import Button from 'components/button'

export default function HomePage() {
return (
<>
<h1>Всем привет!</h1>
<Button />
</>
)
}

Синонимы модулей

Настройка paths является дополнительной к настройке baseUrl и позволяет определять синонимы путей модулей.

Например, следующая конфигурация привязывает @/components/* к components/*:

// tsconfig.json или jsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/components/*": ["components/*"]
}
}
}
// components/button.tsx
export default function Button() {
return <button>Нажми на меня</button>
}
// app/page.tsx
import Button from '@/components/button'

export default function HomePage() {
return (
<>
<h1>Всем привет!</h1>
<Button />
</>
)
}

Каждая запись в paths является относительной к baseUrl, например:

// tsconfig.json или jsconfig.json
{
"compilerOptions": {
"baseUrl": "src/",
"paths": {
"@/styles/*": ["styles/*"],
"@/components/*": ["components/*"]
}
}
}
// app/page.js
import Button from '@/components/button'
import '@/styles/styles.css'
import Helper from 'utils/helper'

export default function HomePage() {
return (
<Helper>
<h1>Всем привет!</h1>
<Button />
</Helper>
)
}

Markdown и MDX

Markdown - это легковесный язык разметки для форматирования текста. Он позволяет писать обычный текст и конвертировать его в валидный HTML.

Мы пишем:

Я **люблю** использовать [Next.js](https://nextjs.org/)

И получаем:

<p>Я <strong>люблю</strong> использовать <a href="https://nextjs.org/">Next.js</a></p>

MDX - это расширение MD, которое позволяет писать JSX прямо в MD-файлах. Это позволяет добавлять интерактивность и компоненты React в контент.

Next.js поддерживает как локальные, так и удаленные файлы MD, запрашиваемые на сервере динамически. Плагин Next.js конвертирует MD и компоненты React в HTML, включая поддержку серверных компонентов.

@next/mdx

Пакет @next/mdx используется для обработки MD и MDX. Он извлекает данные из локальных файлов, позволяя создавать страницы с расширением .mdx прямо в директории app.

Начало работы

Устанавливаем зависимости, необходимые для рендеринга MDX:

npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

Создаем файл mdx-components.tsx в корне приложения:

import type { MDXComponents } from 'mdx/types'

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
}
}

Редактируем файл next.config.js:

const withMDX = require('@next/mdx')()

/** @type {import('next').NextConfig} */
const nextConfig = {
// Добавляем расширение `mdx`
pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
// ...
}

// Объединяем настройки MDX с настройками Next.js
module.exports = withMDX(nextConfig)

Создаем страницу MDX:

  your-project
├── app
│ └── my-mdx-page
│ └── page.mdx
└── package.json

Теперь мы можем использовать MD и импортировать компоненты React прямо на страницу MDX:

import { MyComponent } from 'my-components'

# Добро пожаловать на мою страницу MDX!

Это **жирный** и _курсивный_ текст.

Это список:

- Один
- Два
- Три

Взгляните на мой компонент React:

<MyComponent />

Удаленный MDX

Если наш MD "живет" в другом месте, мы можем запрашивать его динамически на сервере. Для этого случая отлично подойдет популярный пакет next-mdx-remote:

// app/my-mdx-page-remote/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'

export default async function RemoteMdxPage() {
// MD может храниться в локальном файле, БД, CMS, где угодно
const res = await fetch('https://...')
const markdown = await res.text()
return <MDXRemote source={markdown} />
}

Макеты

Создать макет для страниц MDX также просто, как для обычных страниц:

// app/my-mdx-page/layout.tsx
export default function MdxLayout({ children }: { children: React.ReactNode }) {
return <div style={{ color: 'blue' }}>{children}</div>
}

Плагины Remark и Rehype

Опционально для преобразования MDX можно использовать плагины remark и rehype. Например, можно использовать remark-gfm для поддержки GitHub Flavored Markdown. Поскольку remark и rehype являются ESM, для конфигурации Next.js должен использоваться файл next.config.mjs:

import remarkGfm from 'remark-gfm'
import createMDX from '@next/mdx'

/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
// ...
}

const withMDX = createMDX({
// Добавляем плагины MD
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [],
},
})

// Объединяем настройки MDX с настройками Next.js
export default withMDX(nextConfig)

Frontmatter

Frontmatter - это похожий на YAML формат ключей и значений, который может использоваться для хранения данных о странице. @next/mdx не поддерживает frontmatter по умолчанию. Существует большое количество решений для добавления frontmatter в MDX:

Для доступа к метаданным страницы с помощью @next/mdx можно экспортировать объект metadata из файла .mdx:

export const metadata = {
author: 'Игорь Агапов',
}

# Моя страница MDX

Кастомные элементы

Кастомные элементы можно добавить с помощью файла mdx-components.tsx:

import type { MDXComponents } from 'mdx/types'
import Image, { ImageProps } from 'next/image'

export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
// Кастомизируем встроенные компоненты
h1: ({ children }) => <h1 style={{ fontSize: '100px' }}>{children}</h1>,
img: (props) => (
<Image
sizes="100vw"
style={{ width: '100%', height: 'auto' }}
{...(props as ImageProps)}
/>
),
...components,
}
}

Директория src

Next.js позволяет хранить код приложения в директории src в корне проекта. Это позволяет отделить код приложения от его настроек. В этом случае директория app должна находиться в директории src.


Обратите внимание:

  • директория public, файлы package.json, next.config.js, tsconfig.json и env.* должны находиться в корне проекта
  • файл middleware.ts должен находиться в директории src
  • при использовании TailwindCSS, в раздел content файла tailwind.config.js необходимо добавить префикс src/
  • при использовании путей TS для импорта, таких как @/*, в раздел paths файла tsconfig.json необходимо добавить префикс src/

Content Security Policy

Content Security Policy является важной частью защиты приложения Next.js от различных угроз безопасности, таких как межсайтовый скриптинг (XSS), кликджекинг и другие атаки с внедрением кода.

С помощью CSP разработчики могут определять разрешенные источники контента, скриптов, таблиц стилей, изображений, шрифтов, медиа (аудио, видео), iframe и др.

Nonce

Nonce - это уникальная произвольная строка символов разового использования. Она используется в сочетании с CSP для выполнения встроенных скриптов и стилей.

Хотя CSP предназначен для блокировки вредных скриптов, существуют ситуации, когда нужно выполнить встроенный скрипт. В этом случае nonce позволяет убедиться в безопасности скрипта.

Создание nonce

Для добавления заголовков и генерации nonce можно использовать посредника.

Nonce должна генерироваться при каждом посещении страницы. Это означает, что для добавления nonce следует использовать динамический рендеринг.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'

export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')

const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
block-all-mixed-content;
upgrade-insecure-requests;
`

// Заменяем символы новой строки и пробелы
const contentSecurityPolicyHeaderValue = cspHeader
.replace(/\s{2,}/g, ' ')
.trim()

const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)

requestHeaders.set(
'Content-Security-Policy',
contentSecurityPolicyHeaderValue
)

const response = NextResponse.next({
request: {
headers: requestHeaders,
},
})
response.headers.set(
'Content-Security-Policy',
contentSecurityPolicyHeaderValue
)

return response
}

По умолчанию посредник запускается для всех запросов. Пути для запуска посредника фильтруются с помощью matcher.

export const config = {
matcher: [
/*
* Совпадает со всеми запросами, за исключением тех, которые начинаются с:
* - api (роуты API)
* - _next/static (статические файлы)
* - _next/image (файлы оптимизированных изображений)
* - favicon.ico
*/
{
source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
}

Чтение nonce

Прочитать nonce в серверном компоненте можно с помощью функции headers:

import { headers } from 'next/headers'
import Script from 'next/script'

export default function Page() {
const nonce = headers().get('x-nonce')

return (
<Script
src="https://www.googletagmanager.com/gtag/js"
strategy="afterInteractive"
nonce={nonce}
/>
)
}

Без nonce

Если нам не нужны nonce, то заголовок CSP можно установить в файле next.config.js:

const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
block-all-mixed-content;
upgrade-insecure-requests;
`

module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: cspHeader.replace(/\n/g, '').trim(),
},
],
},
]
},
}

Аутентификация

Реализация аутентификации в Next.js предполагает понимание трех концепций:

  • аутентификация - это процесс идентификации пользователя. Он требует предоставление пользователем данных, подтверждающих его личность, например, имени пользователя и пароля
  • управление сессией - отслеживание состояния пользователя (например, его авторизованности) между запросами
  • авторизация - процесс определения разрешений пользователя (того, к чему пользователь имеет доступ)

Аутентификация

Аутентификация подтверждает личность пользователя. Это происходит, когда пользователь входит в систему с помощью логина и пароля или через сервис, вроде Google.

Стратегии аутентификации

Современные веб-приложения используют несколько стратегий аутентификации:

  1. OAuth/OpenID Connect (OIDC) - предоставление третьим лицам доступа к приложению без передачи им учетных данных пользователя. Хорошо подходит для социальных сетей и Single Sign-On (SSO). Добавляет в приложение слой идентификации с помощью OpenID Connect.
  2. Учетные данные (email + пароль). Стандартный способ для веб-приложений. Легко реализовать, но требует дополнительных мер защиты от атак вроде фишинга.
  3. Токены. Магические ссылки в email или одноразовый код в SMS. Повышенная безопасность. Ограничением является зависимость от email или телефона пользователя.
  4. Passkey/Webauthn. Криптографические ключи, уникальные для каждого сайта. Повышенная безопасность, но сложно реализовать.

Реализация аутентификации

  1. Пользователь отправляет свои учетные данные с помощью формы.
  2. Форма вызывает серверную операцию.
  3. После успешной верификации процесс завершается, что означает успешную аутентификацию.
  4. Если верификация проваливается, пользователь видит сообщение об ошибке.

Форма аутентификации:

// app/login/page.tsx
import { authenticate } from '@/app/lib/actions'

export default function Page() {
return (
<form action={authenticate}>
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Пароль" required />
<button type="submit">Войти</button>
</form>
)
}

Форма состоит из двух полей для ввода email и пароля. При отправке она вызывает серверную операцию authenticate:

// app/lib/actions.ts
'use server'

import { signIn } from '@/auth'

export async function authenticate(_currentState: unknown, formData: FormData) {
try {
await signIn('credentials', formData)
} catch (error) {
if (error.type) {
switch (error.type) {
case 'CredentialsSignin':
return 'Неправильные учетные данные.'
default:
return 'Что-то пошло не так.'
}
}
throw error
}
}

В форме аутентификации мы можем использовать хук useFormState для вызова серверной операции и обработки ошибок, а также хук useFormStatus для обработки состояния загрузки формы:

// app/login/page.tsx
'use client'

import { authenticate } from '@/app/lib/actions'
import { useFormState, useFormStatus } from 'react-dom'

export default function Page() {
const [errorMessage, dispatch] = useFormState(authenticate, undefined)

return (
<form action={dispatch}>
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Пароль" required />
<p>{errorMessage || ""}</p>
<LoginButton />
</form>
)
}

function LoginButton() {
const { pending } = useFormStatus()

return (
<button disabled={pending} type="submit">
Войти
</button>
)
}

Авторизация

После аутентификации нужно проверить, какие роуты пользователь может посещать, какие серверные операции может совершать и какие обработчики роута вызывать.

Защита роутов с помощью посредника

Посредник позволяет управлять доступом к различным частям приложения.

Использовать посредника для авторизации можно следующим образом:

  1. Настройка посредника:
    • создаем файл middleware.ts|js в корне проекта
    • определяем логику авторизации, например, проверяем токены аутентификации
  2. Определяем защищенные роуты:
    • не все роуты должны быть закрытыми. Для определения открытых роутов используется настройка matcher
  3. Определяем логику посредника:
    • проверяем роли пользователя и разрешения для доступа к роутам
  4. Обработка отказа в доступе:
    • перенаправляем неавторизованного пользователя на страницу авторизации или ошибки

Пример посредника:

import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
const currentUser = request.cookies.get('currentUser')?.value

if (currentUser && !request.nextUrl.pathname.startsWith('/dashboard')) {
return Response.redirect(new URL('/dashboard', request.url))
}

if (!currentUser && !request.nextUrl.pathname.startsWith('/login')) {
return Response.redirect(new URL('/login', request.url))
}
}

export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}

В этом примере используется функция Response.redirect для ранних перенаправлений в конвейере обработки запроса.

В серверных компонентах, обработчиках роута и серверных операциях для перенаправлений может использоваться функция redirect. Это полезно для навигации на основе роли или в чувствительных к контексту сценариях.

import { redirect } from 'next/navigation'

export default function Page() {
// Логика определения необходимости перенаправления
const accessDenied = true
if (accessDenied) {
redirect('/login')
}

// ...
}

Посредник полезен для начальной валидации, но для полной защиты данных его недостаточно.

Дополнительные проверки должны выполняться в следующих местах:

Защита серверных операций

В следующем примере мы проверяем роль пользователя перед выполнением операции:

// app/lib/actions.ts
'use server'

// ...

export async function serverAction() {
const session = await getSession()
const userRole = session?.user?.role

// Только администратор может выполнять эту операцию
if (userRole !== 'admin') {
throw new Error('Неавторизованный доступ: пользователь не является администратором.')
}

// ...
}

Защита обработчиков роута

// app/api/route.ts
export async function GET() {
const session = await getSession()

// Если пользователь не аутентифицирован
if (!session) {
return new Response(null, { status: 401 })
}

// Если пользователь не является администратором
if (session.user.role !== 'admin') {
return new Response(null, { status: 403 })
}

// ...
}

Авторизация с помощью серверных компонентов

Распространенной практикой является условный рендеринг UI на основе роли пользователя. Такой подход улучшает UX и безопасность, поскольку пользователь имеет доступ только к авторизованному контенту.

// app/dashboard/page.tsx
export default function Dashboard() {
const session = await getSession()
const userRole = session?.user?.role

if (userRole === 'admin') {
return <AdminDashboard /> // компонент для администраторов
} else if (userRole === 'user') {
return <UserDashboard /> // компонент для обычных пользователей
} else {
return <AccessDenied /> // компонент для неавторизованных пользователей
}
}

Лучшие практики

  • безопасное управление сессией - приоритизация безопасности данных сессии позволяет предотвратить неавторизованный доступ и утечку конфиденциальных данных. Используйте шифрование и безопасные хранилища
  • динамичное управление ролями - используйте гибкую систему пользовательских ролей для легкого применения изменений в разрешениях и ролях пользователя
  • безопасность всех аспектов работы с приложением

Управление сессией

Управление сессией включает в себя отслеживание и управление взаимодействием пользователя с приложением в течение определенного времени, сохранение состояния аутентификации пользователя между разными частями приложения.

Это избавляет от необходимости в повторной авторизации, что улучшает как безопасность, так и UX. Существует два основных метода для управления сессией: на основе куки и на основе БД.

Сессии на основе куки

При таком подходе данные пользователя хранятся в браузерных куки. Они прикрепляются к запросам на сервер в необходимых случаях.

Однако такой подход требует осторожного шифрования чувствительных данных, поскольку куки подвержены рискам на стороне клиента. Шифрование данных сессии в куки является гарантией защиты информации о пользователе от неавторизованного доступа, даже если куки будет украдена, данные внутри нее останутся нечитаемыми.

Кроме того, куки имеют очень ограниченный размер (порядка 4 Кб, зависит от браузера). Однако техники вроде чанкования (разделения на части) данных для куки могут решить эту проблему.

Установка куки на сервере:

// app/actions.ts
'use server'

import { cookies } from 'next/headers'

export async function handleLogin(sessionData) {
const encryptedSessionData = encrypt(sessionData) // шифруем данные сессии

cookies().set('session', encryptedSessionData, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // одна неделя
path: '/',
})

// ...
}

Получение доступа к данным сессии, хранящимся в куки, в серверном компоненте:

// app/page.tsx
import { cookies } from 'next/headers'

export async function getSessionData(req) {
const encryptedSessionData = cookies().get('session')?.value

return encryptedSessionData ? JSON.parse(decrypt(encryptedSessionData)) : null
}

Сессии, хранящиеся в БД

При таком подходе данные сессии хранятся на сервере, а браузер пользователя получает только ID сессии. Этот подход является более безопасным, поскольку чувствительные данные хранятся на сервере, т.е. не подвержены рискам на стороне клиента. Сессии на основе БД также являются более масштабируемыми: в БД можно хранить гораздо больше данных, чем в куки.

Однако у этого подхода есть и свои недостатки. Обращение к БД при каждом взаимодействии пользователя негативно влияет на производительность приложения. Эту проблему может решить кешированием данных сессии. При таком подходе производительность приложения полностью зависит от производительности и доступности БД.

Создание сессии на сервере:

import db from './lib/db'

export async function createSession(user) {
const sessionId = generateSessionId() // генерируем уникальный ID сессии

await db.insertSession({ sessionId, userId: user.id, createdAt: new Date() })

return sessionId
}

Извлечение сессии в посреднике или серверной операции:

import { cookies } from 'next/headers'
import db from './lib/db'

export async function getSession() {
const sessionId = cookies().get('sessionId')?.value

return sessionId ? await db.findSession(sessionId) : null
}

Готовые решения

Реализация полноценного с точки зрения безопасности, опыта разработки и удобства пользователей механизма аутентификации/авторизации - задача далеко не из простых, поэтому лучше пользоваться готовыми решениями:

Производственный чеклист

Подытожим оптимизации и паттерны для реализации лучшего UX, производительности и безопасности приложения.

Автоматические оптимизации

Оптимизации Next.js включены по умолчанию:

  • серверные компоненты. Серверные компоненты используются по умолчанию. Они запускаются на сервере, поэтому не требуют JS для рендеринга на клиенте. Поэтому они не влияют на размер сборки JS для клиента. Для добавления интерактивности используются клиентские компоненты
  • разделение кода. Код серверных компонентов автоматически делится по сегментам роута. Для клиентских компонентов и сторонних библиотек можно применять ленивую загрузку
  • предварительное получение данных. Когда ссылка на новый роут попадает в область просмотра, данные для этого роута автоматически запрашиваются в фоновом режиме. Это делает переход на новый роут почти мгновенным. Для определенных роутов предварительное получение данных можно отключать
  • статический рендеринг. Next.js статически рендерит серверные и клиентские компоненты на сервере во время сборки и кеширует результат рендеринга для улучшения производительности приложения. Для определенных роутов можно включать динамический рендеринг
  • кеширование. Next.js кеширует запросы данных, результаты рендеринга серверных и клиентских компонентов, статические ресурсы и др. для уменьшения количества запросов на сервер и в БД. Кеширование можно отключать

Оптимизации времени разработки

При разработке приложения рекомендуется использовать следующие возможности для лучшей производительности и UX:

Роутинг и рендеринг

  • используйте макеты для распределения UI между страницами и включения частичного рендеринга при навигации
  • используйте компонент Link для клиентских навигаций и предварительного получения данных роутов
  • обрабатывайте все возможные ошибки в продакшне путем создания кастомных страниц ошибок
  • следуйте рекомендуемому паттерну композиции серверных и клиентских компонентов, проверяйте правильность размещения директивы use client во избежание лишнего увеличения сборки для клиента
  • помните о том, что использование динамических функций вроде cookies и headers делает весь роут динамическим. Использование этих функций должны быть обоснованным. Оборачивайте соответствующие компоненты в Suspense

Получение данных

  • старайтесь запрашивать данные в серверных компонентах
  • используйте обработчики роута для доступа к серверным ресурсам из клиентских компонентов. Не вызывайте обработчики роута из серверных компонентов во избежание лишних запросов к серверу
  • используйте UI загрузки и компонент Suspense для прогрессивной (потоковой) передачи данных от сервера к клиенту во избежание блокировки всего роута
  • по-возможности запрашивайте данные параллельно, рассмотрите возможность предварительной загрузки данных
  • старайтесь кешировать все запросы данных
  • используйте директорию public для хранения статических ресурсов

UI и доступность

  • используйте серверные операции для обработки отправки форм, их серверной валидации и обработки ошибок
  • оптимизируйте шрифты с помощью модуля шрифтов
  • оптимизируйте изображения с помощью компонента Image
  • оптимизируйте сторонние скрипты с помощью компонента Script
  • используйте встроенный плагин eslint-plugin-jsx-a11y для раннего обнаружения проблем доступности

Безопасность

  • защищайте конфиденциальные данные от использования на клиенте путем искажения объектов данных или определенных значений
  • проверяйте право пользователя на совершение серверной операции
  • убедитесь, что файлы .env.* добавлены в файл .gitignore и только открытые переменные окружения имеют префикс NEXT_PUBLIC_
  • рассмотрите возможность добавления в приложение Content Security Policy

Метаданные и SEO

  • используйте Metadata API для поисковой оптимизации приложения
  • создавайте изображения OG для социальных сетей
  • добавьте файлы sitemap.xml и robots.txt для помощи ботам поисковых систем в понимании и индексировании страниц приложения

Безопасность типов

  • используйте TS и плагин TS для лучшей типобезопасности приложения

Оптимизации перед продакшном

Core Web Vitals

  • запустите Lighthouse в режиме "Инкогнито" для лучшего понимания UX и определения областей для улучшения
  • используйте хук useReportWebVitals для отправки Core Web Vitals в инструменты анализа и мониторинга

Анализ сборки

  • используйте плагин @next/bundle-analyzer для анализа размера сборки JS
  • используйте инструменты, позволяющие определить влияние новых зависимостей на приложение: