Руководство по Next.js. 3/3
- суббота, 4 мая 2024 г. в 00:00:10
Hello world!
Представляю вашему вниманию третью и заключительную часть обновленного руководства по Next.js.
На мой взгляд, Next.js — это лучший на сегодняшний день инструмент для разработки веб-приложений.
Предполагается, что вы хорошо знаете JavaScript и React, а также хотя бы поверхностно знакомы с Node.js.
Обратите внимание: руководство актуально для Next.js версии 14.
При подготовке руководства я опирался в основном на официальную документацию, но в "отсебятине" мог и приврать (или просто очепятаться) 😁 При обнаружении подобного не стесняйтесь писать в личку 😉
Парочка полезных ссылок:
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
возможностями по автоматической оптимизации изображений:
Использование
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
спроектирован для достижения лучшей производительности, он не может использоваться способами, которые могут привести к сдвигу макета. Размеры изображения должны быть определены одним из трех способов:
width
и height
.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',
}}
/>
)
}
Использование 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
iframe
Тег iframe
позволяет добавлять видео со сторонних платформ, таких как YouTube или Vimeo:
export default function Page() {
return (
<iframe
src="https://www.youtube.com/watch?v=gfU1iZnjRZM"
frameborder="0"
allowfullscreen
/>
)
}
Распространенные атрибуты iframe
Атрибут | Описание | Пример значения |
---|---|---|
src |
URL внедряемой страницы | <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
со ссылкой на локальный файлiframe
Добавление внешнего видео
Для добавления внешнего видео можно использовать Next.js для получения информации о видео и компонент Suspense
для предоставления резервного контента во время загрузки видео.
Этот компонент запрашивает данные видео и рендерит iframe:
// app/ui/video-component.jsx
export default async function VideoComponent() {
const src = await getVideoSrc()
return <iframe src={src} frameborder="0" allowfullscreen />
}
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.
На панели управления Vercel перейдите на вкладку "Storage" и выберите Vercel Blob. В правом верхнем углу найдите и нажмите кнопку "Upload". Выберите файл для загрузки. После загрузки видео появится в таблице.
Видео можно загружать с помощью серверных операций. Vercel также поддерживает загрузку на клиенте, которая может быть предпочтительной в некоторых случаях.
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
.
Предварительная загрузка
Вызов функции шрифта на странице не делает шрифт глобально доступным или предварительно загруженным для всех роутов. Предварительная загрузка шрифта зависит от типа файла, в котором он используется:
Повторное использование шрифтов
При каждом вызове функции шрифта создается экземпляр шрифта, который сохраняется в приложении. Во избежание создания нескольких экземпляров одного шрифта рекомендуется делать следующее:
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
.
Метаданные на основе файлов
Для метаданных доступны следующие специальные файлы:
Эти файлы могут быть статическими или генерироваться динамически.
Поведение
Метаданные на основе файлов имеют более высокий приоритет и перезаписывают метаданные на основе конфига.
Дефолтные поля
Существует два дефолтных тега meta
, которые добавляются в каждый роут:
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
Порядок
Метаданные оцениваются по порядку, начиная от корневого сегмента и заканчивая сегментом, ближайшим к финальной странице. Например:
app/layout.tsx
(корневой макет)app/blog/layout.tsx
(вложенный макет блога)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,
}
)
}
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"
/>
</>
)
}
@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:
next/dynamic
.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>
)
}
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.
Новые проекты
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 и другие редакторы кода могут использовать для продвинутой проверки типов и автозавершений.
Для включения плагина необходимо сделать следующее:
Ctrl/⌘
+ Shift
+ P
).Возможности плагина
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 имеет продвинутую безопасность типов:
fetch
) прямо в компонентах, макетах и страницах на сервере. Эти данные не нужно сериализовывать (конвертировать в строку) для передачи клиенту (для потребления React). Поскольку компоненты директории app
являются серверными по умолчанию, мы можем использовать Date
, Map
, Set
и другие типы данных как есть._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
перезаписывает дефолтные наборы.
Порядок загрузки переменных
Порядок загрузки переменных следующий:
process.env
.env.$(NODE_ENV).local
.env.local
(не проверяется, когда NODE_ENV
имеет значение test
).env.$(NODE_ENV)
.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 — это легковесный язык разметки для форматирования текста. Он позволяет писать обычный текст и конвертировать его в валидный 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
content
файла tailwind.config.js
необходимо добавить префикс src/
@/*
, в раздел paths
файла tsconfig.json
необходимо добавить префикс src/
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.
Стратегии аутентификации
Современные веб-приложения используют несколько стратегий аутентификации:
Реализация аутентификации
Форма аутентификации:
// 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>
)
}
Авторизация
После аутентификации нужно проверить, какие роуты пользователь может посещать, какие серверные операции может совершать и какие обработчики роута вызывать.
Защита роутов с помощью посредника
Посредник позволяет управлять доступом к различным частям приложения.
Использовать посредника для авторизации можно следующим образом:
middleware.ts|js
в корне проектаmatcher
Пример посредника:
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 включены по умолчанию:
Оптимизации времени разработки
При разработке приложения рекомендуется использовать следующие возможности для лучшей производительности и UX:
Роутинг и рендеринг
Link
для клиентских навигаций и предварительного получения данных роутовuse client
во избежание лишнего увеличения сборки для клиентаcookies
и headers
делает весь роут динамическим. Использование этих функций должны быть обоснованным. Оборачивайте соответствующие компоненты в Suspense
Получение данных
Suspense
для прогрессивной (потоковой) передачи данных от сервера к клиенту во избежание блокировки всего роутаpublic
для хранения статических ресурсовUI и доступность
Image
Script
eslint-plugin-jsx-a11y
для раннего обнаружения проблем доступностиБезопасность
.env.*
добавлены в файл .gitignore
и только открытые переменные окружения имеют префикс NEXT_PUBLIC_
Метаданные и SEO
Metadata API
для поисковой оптимизации приложенияsitemap.xml
и robots.txt
для помощи ботам поисковых систем в понимании и индексировании страниц приложенияБезопасность типов
Оптимизации перед продакшном
Core Web Vitals
useReportWebVitals
для отправки Core Web Vitals в инструменты анализа и мониторингаАнализ сборки
@next/bundle-analyzer
для анализа размера сборки JSЭто конец третьей части и руководства, в целом.
Happy coding!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩