Как стать автором
Обновить
1983.16
Timeweb Cloud
То самое облако

Руководство по Next.js. 3/3

Уровень сложностиСредний
Время на прочтение45 мин
Количество просмотров3.3K


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 возможностями по автоматической оптимизации изображений:


  • оптимизация размера — автоматическое использование правильного размера изображения для каждого устройства, в современном формате, вроде 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


Атрибут Описание Пример значения
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 со ссылкой на локальный файл
  • видео хостинг-сервисы (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
  • используйте инструменты, позволяющие определить влияние новых зависимостей на приложение:

Это конец третьей части и руководства, в целом.


Happy coding!




Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Теги:
Хабы:
+27
Комментарии0

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud