Привет, друзья!


Хочу поделиться с вами заметками о Next.js (надеюсь, кому-нибудь пригодится).


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


Как известно, основным недостатком SPA являются проблемы с индексацией страниц таких приложений поисковыми роботами, что негативно влияет на SEO.


Впрочем, по моим личным наблюдениям, в последнее время ситуация стала меняться к лучшему, по крайней мере, страницы моего небольшого SPA-PWA-приложения нормально индексируются.


Кроме того, существуют специальные инструменты, такие как react-snap, позволяющие превратить React-SPA в многостраничник путем предварительного рендеринга приложения в статическую разметку. Метаинформацию же можно встраивать в head с помощью таких утилит, как react-helmet. Однако Next.js существенно упрощает процесс разработки многостраничных и гибридных приложений (последнего невозможно добиться с помощью того же react-snap). Он также предоставляет множество других интересных возможностей.



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


Заметки состоят из 2 частей. Это часть номер раз.


Итерация вторая.


Ремарка: ссылки ниже могут оказаться нерабочими из-за особенностей парсинга MD хабровским редактором.


Содержание



Начало работы


Для создания проекта рекомендуется использовать create-next-app:


yarn create next-app app-name
# typescript
yarn create next-app app-name --typescript

Ручная установка:


  • устанавливаем зависимости

yarn add next react react-dom

  • обновляем package.json

"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint"
}

Запуск сервера для разработки:


yarn dev

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


Страницы (Pages)


Страница — это компонент React, который экспортируется из файла с расширением .js, .jsx, .ts или .tsx, находящегося в директории pages. Каждая страница ассоциируется с маршрутом (роутом) по названию. Например, страница pages/about.js будет доступна по адресу /about. Обратите внимание, что страница должна экспортироваться по умолчанию (export default):


export default function About() {
  return <div>О нас</div>
}

Маршрут для страницы pages/posts/[id].js будет динамическим, т.е. такая страница будет доступна по адресам posts/1, posts/2 и т.д.


По умолчанию все страницы рендерятся предварительно (pre-rendering). Это приводит к лучшей производительности и SEO. Каждая страница ассоциируется с минимальным количеством JS. При загрузке страницы запускается JS-код, который делает ее интерактивной (данный процесс называется гидратацией — hydration).


Существует 2 формы предварительного рендеринга: генерация статической разметки (static generation, SSG, рекомендуемый подход) и рендеринг на стороне сервера (server-side rendering, SSR). Первая форма предусматривает генерацию HTML во время сборки и его повторное использование при каждом запросе. Вторая — генерацию разметки при каждом запросе. Генерация статической разметки является рекомендуемым подходом по причинам производительности.


Кроме этого можно использовать рендеринг на стороне клиента (client-side rendering), когда определенные части страницы рендерятся клиентским JS.


SSG


Генерироваться могут как страницы с данными, так и страницы без данных.


Без данных


export default function About() {
  return <div>О нас</div>
}

С данными


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


  1. Контент страницы зависит от внешних данных: используется getStaticProps
  2. Пути (paths) страницы зависят от внешних данных: используется getStaticPaths (как правило, совместно с getStaticProps)

Контент страницы зависит от внешних данных


Предположим, что страница блога получает список постов от CMS:


// TODO: запрос `posts`

export default function Blog({ posts }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

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


export default function Blog({ posts }) {
  // ...
}

export async function getStaticProps() {
  const posts = await (await fetch('https://example.com/posts'))?.json()

  // обратите внимание на сигнатуру
  return {
    props: {
      posts
    }
  }
}

Пути страницы зависят от внешних данных


Для обработки предварительного рендеринга статической страницы, пути которой зависят от внешних данных, из динамической страницы (например, pages/posts/[id].js) должна экспортироваться асинхронная функция getStaticPaths. Данная функция вызывается во время сборки и позволяет определить пути для пререндеринга:


export default function Post({ post }) {
  // ...
}

export async function getStaticPaths() {
  const posts = await (await fetch('https://example.com/posts'))?.json()

  // обратите внимание на структуру возвращаемого массива
  const paths = posts.map((post) => ({
    params: { id: post.id }
  }))

  // `fallback: false` означает, что для ошибки 404 используется другой маршрут
  return {
    paths,
    fallback: false
  }
}

На странице pages/posts/[id].js также должна экспортироваться функция getStaticProps для получения данных поста с указанным id:


export default function Post({ post }) {
  // ...
}

export async function getStaticPaths() {
  // ...
}

export async function getStaticProps({ params }) {
  const post = await (await fetch(`https://example.com/posts/${params.id}`)).json()

  return {
    props: {
      post
    }
  }
}

SSR


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


function Page({ data }) {
  // ...
}

export async function getServerSideProps() {
  const data = await (await fetch('https://example.com/data'))?.json()

  return {
    props: {
      data
    }
  }
}

Получение данных (Data Fetching)


Существует 3 функции для получения данных, необходимых для предварительного рендеринга:


  • getStaticProps (SSG): получение данных во время сборки
  • getStaticPaths (SSG): определение динамических роутов для предварительного рендеринга страниц на основе данных
  • getServerSideProps (SSR): получение данных при каждом запросе

getStaticProps


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


export async function getStaticProps(context) {
  return {
    props: {}
  }
}

context — это объект со следующими свойствами:


  • params — параметры роута для страниц с динамической маршрутизацией. Например, если названием страницы является [id].js, params будет иметь вид { id: ... }
  • preview — имеет значение true, если страница находится в режиме предварительного просмотра
  • previewData — набор данных, установленный с помощью setPreviewData
  • locale — текущая локаль (если включено)
  • locales — поддерживаемые локали (если включено)
  • defaultLocale — дефолтная локаль (если включено)

getStaticProps возвращает объект со следующими свойствами:


  • props — опциональный объект с пропами для страницы
  • revalidate — опциональное количество секунд, по истечении которых происходит повторная генерация страницы. По умолчанию имеет значение false — повторная генерация выполняется только при следующей сборке
  • notFound — опциональное логическое значение, позволяющее вернуть статус 404 и соответствующую страницу, например:

export async function getStaticProps(context) {
  const res = await fetch('/data')
  const data = await res.json()

  if (!data) {
    return {
      notFound: true
    }
  }

  return {
    props: {
      data
    }
  }
}

Обратите внимание: notFound не требуется в режиме fallback: false, поскольку в этом режиме предварительно рендерятся только пути, возвращаемые getStaticPaths.


Также обратите внимание, что notFound: true означает возврат 404 даже в случае, если предыдущая страница была успешно сгенерирована. Это рассчитано на поддержку случаев удаления пользовательского контента.


  • redirect — опциональный объект, позволяющий выполнять перенаправления на внутренние и внешние ресурсы, который должен иметь форму { destination: string, permanent: boolean }:

export async function getStaticProps(context) {
  const res = await fetch('/data')
  const data = await res.json()

  if (!data) {
    return {
      redirect: {
        destination: '/',
        permanent: false
      }
    }
  }

  return {
    props: {
      data
    }
  }
}

Замечание 1: в настоящее время перенаправления во время сборки не разрешаются. Такие перенаправления должны быть добавлены в next.config.js.


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


Замечание 3: fetch() в getStaticProps следует использовать только при получении ресурсов из внешних источников.


Случаи использования


  • данные для рендеринга доступны во время сборки и не зависят от запроса пользователя
  • данные приходят из безголовой (headless) CMS
  • данные могут быть кешированы в открытом виде (не предназначены для конкретного пользователя)
  • страница должна быть предварительно отрендерена (для SEO) и при этом должна быть очень быстрой — getStaticProps генерирует HTML и JSON файлы, которые могут быть кешированы с помощью CDN

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


import { GetStaticProps } from 'next'

export const getStaticProps: GetStaticProps = async (context) => {}

Для получения предполагаемых типов для пропов следует использовать InferGetStaticPropsType<typeof getStaticProps>:


import { InferGetStaticPropsType } from 'next'

type Post = {
  author: string
  content: string
}

export const getStaticProps = async () => {
  const res = await fetch('/posts')
  const posts: Post[] = await res.json()

  return {
    props: {
      posts
    }
  }
}

export default function Blog({ posts }: InferGetStaticPropsType<typeof getStaticProps>) {
  // посты будут иметь тип `Post[]`
}

Инкрементальная статическая регенерация


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


Пример:


const Blog = ({ posts }) => (
  <ul>
    {posts.map((post) => (
      <li>{post.title}</li>
    ))}
  </ul>
)

// Данная функция вызывается во время сборки на сервере.
// Она может вызываться повторно как бессерверная функция
// при включенной инвалидации и поступлении нового запроса
export async function getStaticProps() {
  const res = await fetch('/posts')
  const posts = await res.json()

  return {
    props: {
      posts
    },
    // `Next.js` попытается регенерировать страницу:
    // - при поступлении нового запроса
    // - как минимум, один раз каждые 10 секунд
    revalidate: 10 // в секундах
  }
}

// Данная функция вызывается во время сборки на сервере.
// Она может вызываться повторно как бессерверная функция
// если путь не был сгенерирован предварительно
export async function getStaticPaths() {
  const res = await fetch('/posts')
  const posts = await res.json()

  // Получаем пути для предварительного рендеринга на основе постов
  const paths = posts.map((post) => ({
    params: { id: post.id }
  }))

  // Только эти пути будут предварительно отрендерены во время сборки
  // `{ fallback: 'blocking' }` будет рендерить страницы на сервере
  // при отсутствии соответствующего пути
  return { paths, fallback: 'blocking' }
}

export default Blog

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


  • Ответ на любой запрос к такой странице до истечения 10 секунд также мгновенно возвращается из кеша
  • По истечении 10 секунд следующий запрос также получает в ответ кешированную версию страницы
  • После этого в фоновом режиме запускается регенерация страницы
  • После успешной регенерации кеш инвалидируется и отображается новая страница. При провале регенерации старая страница остается неизменной

Чтение файлов


Для получения абсолютного пути к текущей рабочей директории следует использовать process.cwd():


import { promises as fs } from 'fs'
import { join } from 'path'

const Blog = ({ posts }) => (
  <ul>
    {posts.map((post) => (
      <li key={post.id}>
        <h3>{post.filename}</h3>
        <p>{post.content}</p>
      </li>
    ))}
  </ul>
)

// Данная функция вызывается на сервере, так что
// в ней можно напрямую обращаться к БД
export async function getStaticProps() {
  const postsDir = join(process.cwd(), 'posts')
  const filenames = await fs.readdir(postsDir)

  const posts = filenames.map(async (filename) => {
    const filePath = join(postsDir, filename)
    const fileContent = await fs.readFile(filePath, 'utf-8')

    // Обычно, здесь выполняется преобразование контента,
    // например, разбор `markdown` в `HTML`

    return {
      filename,
      content: fileContent
    }
  })

  return {
    props: {
      posts: await Promise.all(posts)
    }
  }
}
export default Blog

Технические подробности


  • Поскольку getStaticProps запускается во время сборки, она не может использовать данные из запроса, такие как параметры строки запроса (query params) или HTTP-заголовки (headers)
  • getStaticProps запускается только на сервере, поэтому ее нельзя использовать для обращения к внутренним роутам
  • при использовании getStaticProps генерируется не только HTML, но и файл в формате JSON. Данный файл содержит результаты выполнения getStaticProps и используется механизмом маршрутизации на стороне клиента для передачи пропов компонентам
  • getStaticProps может использовать только в компоненте-странице. Это объясняется тем, что все данные, необходимые для рендеринга страницы, должны быть доступными
  • в режиме для разработки getStaticProps вызывается при каждом запросе
  • режим предварительного просмотра (preview mode) используется для рендеринга страницы при каждом запросе

getStaticPaths


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


export async function getStaticPaths() {
  return {
    paths: [
      params: {}
    ],
    fallback: true | false | 'blocking'
  }
}

Ключ paths


paths определяет, какие пути будут предварительно отрендерены. Например, если у нас имеется страница с динамической маршрутизацией, которая называется pages/posts/[id].js, и экспортируемая на этой странице getStaticPaths возвращает такой paths:


return {
  paths: [
    { params: { id: '1' } },
    { params: { id: '2' } },
  ]
}

Тогда будут статически сгенерированы страницы posts/1 и posts/2 на основе компонента pages/posts/[id].js.


Обратите внимание, что название каждого params должно совпадать с параметрами, используемыми на странице:


  • если названием страницы является pages/posts/[postId]/[commentId], тогда params должен содержать postId и commentId
  • если на странице используется перехватчик роутов, например, pages/[...slug], params должен содержать slug в виде массива. Например, если такой массив будет выглядеть как ['foo', 'bar'], то будет сгенерирована страница /foo/bar
  • если на странице используется опциональный перехватчик роутов, применение null, [], undefined или false, приведет к рендерингу роута верхнего уровня. Например, при применении slug: false к pages/[[...slug]], будет сгенерирована страница /

Ключ fallback


  • если fallback имеет значение false, отсутствующий путь будет разрешаться страницей 404
  • если fallback имеет значение true, поведение getStaticProps будет таким:
    • пути из getStaticPaths будут сгенерированы во время сборки с помощью getStaticProps
    • отсутствующий путь не будет разрешаться страницей 404. Вместо этого в ответ на запрос будет возвращена резервная страница
    • в фоновом режиме выполняется генерация запрошенного HTML и JSON. Это включает в себя вызов getStaticProps
    • браузер получает JSON для сгенерированного пути. Этот JSON используется для автоматического рендеринга страницы с обязательными пропами. Со стороны пользователя это выглядит как переключение между резервной и полной страницами
    • новый путь добавляется в список предварительно отрендеренных страниц

Обратите внимание: fallback: true не поддерживается при использовании next export.


Резервные страницы


В резервной версии страницы:


  • пропы страницы будут пустыми
  • определить, что рендерится резервная страница, можно с помощью роутера: router.isFallback будет иметь значение true

// pages/posts/[id].js
import { useRouter } from 'next/router'

function Post({ post }) {
  const router = useRouter()

  // Если страница еще не сгенерирована, будет отображаться это
  // До тех пор, пока `getStaticProps` не закончит свою работу
  if (router.isFallback) {
    return <div>Загрузка...</div>
  }

  // рендеринг поста
}

export async function getStaticPaths() {
  return {
    paths: [
      { params: { id: '1' } },
      { params: { id: '2' } }
    ],
    fallback: true
  }
}

export async function getStaticProps({ params }) {
  const res = await fetch(`/posts/${params.id}`)
  const post = await res.json()

  return {
    props: {
      post
    },
    revalidate: 1
  }
}

export default Post

В каких случаях может быть полезен fallback: true?


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


Вместо этого мы генерируем небольшой набор статических страниц и используем fallback: true для остальных. При запросе отсутствующей страницы пользователь какое-то время будет наблюдать индикатор загрузки (пока getStaticProps делает свое дело), затем увидит саму страницу. И после этого новая страница будет возвращаться в ответ на каждый запрос.


Обратите внимание: fallback: true не обновляет сгенерированные страницы. Для этого используется инкрементальная статическая регенерация.


Если fallback имеет значение blocking, отсутствующий путь также не будет разрешаться страницей 404, но и перехода между резервной и нормальной страницами не будет. Вместо этого запрашиваемая страница будет сгенерирована на сервере и отправлена браузеру, а пользователь после некоторого ожидания сразу увидит готовую страницу


Случаи использования getStaticPaths


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


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


import { GetStaticPaths } from 'next'

export const getStaticPaths: GetStaticPaths = async () => {}

Технические подробности


  • getStaticPaths должна использоваться совместно с getStaticProps. Она не может использоваться вместе с getServerSideProps
  • getStaticPaths запускается только на сервере во время сборки
  • getStaticPaths может экспортироваться только в компоненте-странице
  • в режиме для разработки getStaticPaths запускается при каждом запросе

getServerSideProps


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


export async function getServerSideProps(context) {
  return {
    props: {}
  }
}

context — это объект со следующими свойствами:


  • params: см. getStaticProps
  • req: объект HTTP IncomingMessage (входящее сообщение, запрос)
  • res: объект HTTP-ответа
  • query: объектное представление строки запроса
  • preview: см. getStaticProps
  • previewData: см. getStaticProps
  • resolveUrl: нормализованная версия запрашиваемого URL, из которой удален префикс _next/data и включены значения оригинальной строки запроса
  • locale: см. getStaticProps
  • locales: см. getStaticProps
  • defaultLocale: см. getStaticProps

getServerSideProps должна возвращать объект с такими полями:


  • props — см. getStaticProps
  • notFound — см. getStaticProps

export async function getServerSideProps(context) {
  const res = await fetch('/data')
  const data = await res.json()

  if (!data) {
    return {
      notFound: true
    }
  }

  return {
    props: {}
  }
}

  • redirect — см. getStaticProps

export async function getServerSideProps(context) {
  const res = await fetch('/data')
  const data = await res.json()

  if (!data) {
    return {
      redirect: {
        destination: '/',
        permanent: false
      }
    }
  }

  return {
    props: {}
  }
}

Для getServerSideProps характерны те же особенности и ограничения, что и для getStaticProps.


Случаи использования getServerSideProps


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


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


import { GetServerSideProps } from 'next'

export const getServerSideProps: GetServerSideProps = async () => {}

Для получения предполагаемых типов для пропов следует использовать InferGetServerSidePropsType<typeof getServerSideProps>:


import { InferGetServerSidePropsType } from 'next'

type Data = {}

export async function getServerSideProps() {
  const res = await fetch('/data')
  const data = await res.json()

  return {
    props: {
      data
    }
  }
}

function Page({ data }: InferGetServerSidePropsType<typeof getServerSideProps>) {
  // ...
}

export default Page

Технические подробности


  • getServerSideProps запускается только на сервере
  • getServerSideProps может экспортироваться только в компоненте-странице

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


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


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


import useSWR from 'swr'

const fetcher = (url) => fetch(url).then((res) => res.json())

function Profile() {
  const { data, error } = useSWR('/api/user', fetcher)

  if (error) return <div>При загрузке данных возникла ошибка</div>
  if (!data) return <div>Загрузка...</div>

  return <div>Привет, {data.name}!</div>
}

Встроенная поддержка CSS


Импорт глобальных стилей


Для добавления глобальных стилей соответствующую таблицу следует импортировать в файл pages/_app.js (обратите внимание на нижнее подчеркивание):


// pages/_app.js
import './style.css'

// Данный экспорт по умолчанию является обязательным
export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />
}

Такие стили будут применяться ко всем страницам и компонентам в приложении. Обратите внимание: во избежание конфликтов глобальные стили могут импортироваться только в pages/_app.js.


При сборке приложения все стили объединяются в один минифицированный CSS-файл.


__Импорт стилей из директории node_modules__


Стили могут импортироваться из node_modules.


Пример импорта глобальных стилей:


// pages/_app.js
import 'bootstrap/dist/css/bootstrap.min.css'

export default function App({ Component, pageProps }) {
  return <Component {...pageProps} />
}

Пример импорта стилей для стороннего компонента:


// components/Dialog.js
import { useState } from 'react'
import { Dialog } from '@reach/dialog'
import VisuallyHidden from '@reach/visually-hidden'
import '@reach/dialog/styles.css'

export function MyDialog(props) {
  const [show, setShow] = useState(false)
  const open = () => setShow(true)
  const close = () => setShow(false)

  return (
    <div>
      <button onClick={open} className='btn-open'>Открыть</button>
      <Dialog>
        <button onClick={close} className='btn-close'>
          <VisuallyHidden>Закрыть</VisuallyHidden>
          <span>X</span>
        </button>
        <p>Привет!</p>
      </Dialog>
    </div>
  )
}

Добавление стилей на уровне компонента


Next.js из коробки поддерживает CSS-модули. CSS-модули должны иметь название [name].module.css. Они создают локальную область видимости для соответствующих стилей, что позволяет использовать одинаковые названия классов без риска возникновения коллизий. CSS-модуль импортируется как объект (обычно, именуемый styles), ключами которого являются названия соответствующих классов.


Пример использования CSS-модулей:


/* components/Button/Button.module.css */
.danger {
  background-color: red;
  color: white;
}

// components/Button/Button.js
import styles from './Button.module.css'

export const Button = () => (
  <button className={styles.danger}>
    Удалить
  </button>
)

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


Поддержка SASS


Next.js поддерживает файлы с расширением .scss и .sass. SASS также может использоваться на уровне компонентов (.module.scss и .module.sass). Для компиляции SASS в CSS необходимо установить sass:


yarn add sass

Поведение компилятора SASS может быть кастомизировано в файле next.config.js, например:


const path = require('path')

module.exports = {
  sassOptions: {
    includePaths: [path.join(__dirname, 'styles')]
  }
}

CSS-в-JS


В Next.js можно использовать любое решение CSS-в-JS. Простейшим примером является использование встроенных стилей:


export const Hi = ({ name }) => <p style={{ color: 'green' }}>Привет, {name}!</p>

Next.js также имеет встроенную поддержку styled-jsx:


export const Bye = ({ name }) => (
  <div>
    <p>Пока, {name}. Скоро увидимся!</p>
    <style jsx>{`
      div {
        background-color: #3c3c3c;
      }
      p {
        color: #f0f0f0;
      }
      @media (max-width: 768px) {
        div {
          backround-color: #f0f0f0;
        }
        p {
          color: #3c3c3c;
        }
      }
    `}</style>
    <style global jsx>{`
      body {
        margin: 0;
        min-height: 100vh;
        display: grid;
        place-items: center;
      }
    `}</style>
  </div>
)

Макеты (Layouts)


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


// components/layout.js
import Navbar from './navbar'
import Footer from './footer'

export default function Layout({ children }) {
  return (
    <>
      <Navbar />
      <main>{children}</main>
      <Footer />
    </>
  )
}

Примеры


Единственный макет


Если в приложении используется только один макет, мы можем создать кастомное приложение (custom app) и обернуть приложение в макет. Поскольку компонент-макет будет переиспользоваться при изменении страниц, его состояние (например, значения инпутов) будет сохраняться:


// pages/_app.js
import Layout from '../components/layout'

export default function App({ Component, pageProps }) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
}

Макеты на уровне страниц


Свойство getLayout страницы позволяет возвращать компонент для макета. Это позволяет определять макеты на уровне страниц. Возвращаемая функция позволяет конструировать вложенные макеты:


// pages/index.js
import Layout from '../components/layout'
import Nested from '../components/nested'

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

Page.getLayout = (page) => (
  <Layout>
    <Nested>{page}</Nested>
  </Layout>
)

// pages/_app.js
export default function App({ Component, pageProps }) {
  // использовать макет, определенный на уровне страницы, при наличии такового
  const getLayout = Component.getLayout || ((page) => page)

  return getLayout(<Component {...pageProps} />)
}

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


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


При использовании TypeScript сначала создается новый тип для страницы, включающей getLayout. Затем следует создать новый тип для AppProps, который перезаписывает свойство Component для обеспечения возможности использования созданного ранее типа:


// pages/index.tsx
import type { ReactElement } from 'react'
import Layout from '../components/layout'
import Nested from '../components/nested'

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

Page.getLayout = (page: ReactElement) => (
  <Layout>
    <Nested>{page}</Nested>
  </Layout>
)

// pages/_app.tsx
import type { ReactElement, ReactNode } from 'react'
import type { NextPage } from 'next'
import type { AppProps } from 'next/app'

type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactElement) => ReactNode
}

type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout
}

export default function App({ Component, pageProps }: AppPropsWithLayout) {
  const getLayout = Component.getLayout ?? ((page) => page)

  return getLayout(<Component  {...pageProps} />)
}

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


Данные в макете можно получать на стороне клиента с помощью useEffect или утилит вроде SWR. Поскольку макет — это не страница, в нем в настоящее время нельзя использовать getStaticProps или getServerSideProps:


import useSWR from 'swr'
import Navbar from './navbar'
import Footer from './footer'

export default function Layout({ children }) {
  const { data, error } = useSWR('/data', fetcher)

  if (error) return <div>Ошибка</div>
  if (!data) return <div>Загрузка...</div>

  return (
    <>
      <Navbar />
      <main>{children}</main>
      <Footer />
    </>
  )
}

Компонент Image и оптимизация изображений


Компонент Image, импортируемый из next/image, является расширением HTML-тега img, предназначенным для современного веба. Он включает несколько встроенных оптимизаций, позволяющих добиться хороших показателей Core Web Vitals. Эти оптимизации включают в себя следующее:


  • улучшение производительности
  • обеспечение визуальной стабильности
  • ускорение загрузки страницы
  • обеспечение гибкости (масштабируемости) изображений

Пример использования локального изображения


import Image from 'next/image'
import imgSrc from '../public/some-image.png'

export default function Home() {
  return (
    <>
      <h1>Главная страница</h1>
      <Image
        src={imgSrc}
        alt=""
        role="presentation"
      />
    </h1>
  )
}

Пример использования удаленного изображения


Обратите внимание на необходимость установки ширины и высоты изображения:


import Image from 'next/image'

export default function Home() {
  return (
    <>
      <h1>Главная страница</h1>
      <Image
        src="/some-image.png"
        alt=""
        role="presentation"
        width={500}
        height={500}
      />
    </h1>
  )
}

Определение размеров изображения


Image ожидает получение ширины и высоты изображения:


  • в случае статического импорта (локальное изображение) ширина и высота вычисляются автоматически
  • ширина и высота могут указываться с помощью соответствующих пропов
  • если размеры изображения неизвестны, можно использовать проп layout со значением fill

Существует 3 способа решения проблемы неизвестных размеров изображения:


  • использование режима макетирования fill: этот режим позволяет управлять размерами изображения с помощью родительского элемента. В этом случае размеры родительского элемента определяются с помощью CSS, а размеры изображения с помощью свойств object-fit и object-position
  • нормализация изображений: если источник изображений находится под нашим контролем, мы можем добавить изменение размеров изображения при его возвращении в ответ на запрос
  • модификация вызовов к API: в ответ на запрос может включаться не только само изображение, но и его размеры

Правила стилизации изображений


  • выбирайте правильный режим макетирования
  • используйте className — он устанавливается соответствующему элементу img. Обратите внимание: проп style не передается
  • при использовании layout="fill" родительский элемент должен иметь position: relative
  • при использовании layout="responsive" родительский элемент должен иметь display: block

Подробнее о компоненте Image см. ниже.


Оптимизация шрифтов


Next.js автоматически встраивает шрифты в CSS во время сборки:


// было
<link
  href="https://fonts.googleapis.com/css2?family=Inter"
  rel="stylesheet"
/>

// стало
<style data-href="https://fonts.googleapis.com/css2?family=Inter">
  @font-face{font-family:'Inter';font-style:normal...}
</style>

Для добавления на страницу шрифта используется компонент Head, импортируемый из next/head:


// pages/index.js
import Head from 'next/head'

export default function IndexPage() {
  return (
    <div>
      <Head>
        <link
          rel="stylesheet"
          href="https://fonts.googleapis.com/css2?family=Inter&display=optional"
        />
      </Head>
      <p>Привет, народ!</p>
    </div>
  )
}

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


// pages/_document.js
import Document, { Html, Head, Main, NextScript } from 'next/document'

class MyDoc extends Document {
  render() {
    return (
      <Html>
        <Head>
          <link
            rel="stylesheet"
            href="https://fonts.googleapis.com/css2?family=Inter&display=optional"
          />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

Автоматическую оптимизацию шрифтов можно отключить:


// next.config.js
module.exports = {
  optimizeFonts: false
}

Подробнее о компоненте Head см. ниже.


Компонент Script


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


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


  • beforeInteractive: предназначено для важных скриптов, которые должны быть загружены и выполнены до того, как страница станет интерактивной. К таким скриптам относятся, например, обнаружение ботов и запрос разрешений. Такие скрипты внедряются в первоначальный HTML и запускаются перед остальным JS
  • afterInteractive: для скриптов, которые могут загружаться и выполняться после того, как страница стала интерактивной. К таким скриптам относятся, например, менеджеры тегов и аналитика. Такие скрипты выполняются на стороне клиента и запускаются после гидратации
  • lazyOnload: для скриптов, которые могут быть загружены в период простоя. К таким скриптам относятся, например, поддержка чатов и виджеты социальных сетей

Обратите внимание:


  • Script поддерживает встроенные скрипты со стратегиями afterInteractive и lazyOnload
  • встроенные скрипты, обернутые в Script, должны иметь атрибут id для их отслеживания и оптимизации

Примеры


Обратите внимание: компонент Script не должен помещаться внутрь компонента Head или кастомного документа.


Загрузка полифилов


import Script from 'next/script'

export default function Home() {
  return (
    <>
      <Script
        src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserverEntry%2CIntersectionObserver"
        strategy="beforeInteractive"
      />
    </>
  )
}

Отложенная загрузка


import Script from 'next/script'

export default function Home() {
  return (
    <>
      <Script
        src="https://connect.facebook.net/en_US/sdk.js"
        strategy="lazyOnload"
      />
    </>
  )
}

Выполнение кода после полной загрузки страницы


import { useState } from 'react'
import Script from 'next/script'

export default function Home() {
  const [stripe, setStripe] = useState(null)

  return (
    <>
      <Script
        id="stripe-js"
        src="https://js.stripe.com/v3/"
        onLoad={() => {
          setStripe({ stripe: window.Stripe('pk_test_12345') })
        }}
      />
    </>
  )
}

Встроенные скрипты


import Script from 'next/script'

<Script id="show-banner" strategy="lazyOnload">
  {`document.getElementById('banner').classList.remove('hidden')`}
</Script>

// или
<Script
  id="show-banner"
  dangerouslySetInnerHTML={{
    __html: `document.getElementById('banner').classList.remove('hidden')`
  }}
/>

Передача атрибутов


import Script from 'next/script'

export default function Home() {
  return (
    <>
      <Script
        src="https://www.google-analytics.com/analytics.js"
        id="analytics"
        nonce="XUENAJFW"
        data-test="analytics"
      />
    </>
  )
}

Обслуживание статических файлов


Статические ресурсы должны размещаться в директории public, находящейся в корневой директории проекта. Файлы, находящиеся в директории public, доступны по базовой ссылке /:


import Image from 'next/image'

export default function Avatar() {
  return <Image src="/me.png" alt="me" width="64" height="64" >
}

Данная директория также отлично подходит для хранения таких файлов, как robots.txt, favicon.png, файлов, необходимых для верификации сайта Гуглом и другой статики (включая .html).


Обновление в режиме реального времени


Next.js поддерживает обновление компонентов в режиме реального времени с сохранением локального состояния в большинстве случаев (это относится только к функциональным компонентам и хукам). Состояние компонента также сохраняется при возникновении ошибок (не связанных с рендерингом).


Для перезагрузки компонента достаточно в любом месте добавить // @refresh reset.


TypeScript


Next.js поддерживает TypeScript из коробки:


yarn create next-app --typescript app-name
# или
yarn create next-app --ts app-name

Для getStaticProps, getStaticPaths и getServerSideProps существуют специальные типы GetStaticProps, GetStaticPaths и GetServerSideProps:


import { GetStaticProps, GetStaticPaths, GetServerSideProps } from 'next'

export const getStaticProps: GetStaticProps = async (context) => {
  // ...
}

export const getStaticPaths: GetStaticPaths = async () => {
  // ...
}

export const getServerSideProps: GetServerSideProps = async (context) => {
  // ...
}

Пример использования встроенных типов для интерфейса маршрутизации (API Routes):


import type { NextApiRequest, NextApiResponse } from 'next'

export default (req: NextApiRequest, res: NextApiResponse) => {
  res.status(200).json({ message: 'Привет!' })
}

Ничто не мешает нам типизировать данные, содержащиеся в ответе:


import type { NextApiRequest, NextApiResponse } from 'next'

type Data = {
  name: string
}

export default (req: NextApiRequest, res: NextApiResponse<Data>) => {
  res.status(200).json({ message: 'Пока!' })
}

Для кастомного приложения существует специальный тип AppProps:


// import App from 'next/app'
import type { AppProps /*, AppContext */ } from 'next/app'

function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}

// Данный метод следует использовать только в случае, когда все страницы приложения
// должны предварительно рендериться на сервере
MyApp.getInitialProps = async (context: AppContext) => {
  // вызывает функцию `getInitialProps`, определенную на странице
  // и заполняет `appProps.pageProps`
  const props = await App.getInitialProps(context)

  return { ...props }
}

Next.js поддерживает настройки paths и baseUrl, определяемые в tsconfig.json.


Переменные среды окружения


Next.js имеет встроенную поддержку переменных среды окружения, что позволяет делать следующее:


  • использовать .env.local для загрузки переменных
  • экстраполировать переменные в браузер с помощью префикса NEXT_PUBLIC_

Предположим, что у нас имеется такой файл .env.local:


DB_HOST=localhost
DB_USER=myuser
DB_PASS=mypassword

Это приведет к автоматической загрузке process.env.DB_HOST, process.env.DB_USER и process.env.DB_PASS в среду выполнения Node.js, позволяя использовать их в методах получения данных и интерфейсе маршрутизации:


// pages/index.js
export async function getStaticProps() {
  const db = await myDB.connect({
    host: process.env.DB_HOST,
    username: process.env.DB_USER,
    password: process.env.DB_PASS
  })

  // ...
}

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


HOSTNAME=localhost
PORT=8080
HOST=http://$HOSTNAME:$PORT

Для того, чтобы передать переменную среды окружения в браузер к ней нужно добавить префикс NEXT_PUBLIC_:


NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk

// pages/index.js
import setupAnalyticsService from '../lib/my-analytics-service'

setupAnalyticsService(process.env.NEXT_PUBLIC_ANALYTICS_ID)

function HomePage() {
  return <h1>Привет, народ!</h1>
}

export default HomePage

В дополнение к .env.local можно создавать файлы .env (для обоих режимов), .env.development (для режима разработки) и .env.production (для производственного режима). Обратите внимание: .env.local всегда имеет приоритет над другими файлами, содержащими переменные среды окружения.


Маршрутизация


Введение


Маршрутизация в Next.js основана на концепции страниц.


Файл, помещаемый в директорию pages, автоматически становится роутом.


Файлы index.js привязываются к корневой директории:


  • pages/index.js -> /
  • pages/blog/index.js -> /blog

Роутер поддерживает вложенные файлы:


  • pages/blog/first-post.js -> /blog/first-post
  • pages/dashboard/settings/username.js -> /dashboard/settings/username

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


  • pages/blog/[slug].js -> /blog/:slug (blog/first-post)
  • pages/[username]/settings.js -> /:username/settings (/johnsmith/settings)
  • pages/post/[...all].js -> /post/* (/post/2021/id/title)

Связь между страницами


Для маршрутизации на стороне клиента используется компонент Link:


import Link from 'next/link'

export default function Home() {
  return (
    <ul>
      <li>
        <Link href="/">
          Главная
        </Link>
      </li>
      <li>
        <Link href="/about">
          О нас
        </Link>
      </li>
      <li>
        <Link href="/blog/first-post">
          Пост номер раз
        </Link>
      </li>
    </ul>
  )
}

Здесь:


  • /pages/index.js
  • /aboutpages/about.js
  • /blog/first-postpages/blog/[slug].js

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


import Link from 'next/link'

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

Или объект URL:


import Link from 'next/link'

export default function Post({ posts }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link
            href={{
              pathname: '/blog/[slug]',
              query: { slug: post.slug },
            }}
          >
            <a>{post.title}</a>
          </Link>
        </li>
      ))}
    </ul>
  )
}

Здесь:


  • pathname — это название страницы в директории pages (/blog/[slug] в данном случае)
  • query — это объект с динамическим сегментом (slug в данном случае)

Для доступа к объекту router в компоненте можно использовать хук useRouter или утилиту withRouter. Рекомендуется использовать useRouter.


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


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


Рассмотрим страницу pages/post/[pid].jd:


import { useRouter } from 'next/router'

export default function Post() {
  const router = useRouter()
  const { pid } = router.query

  return <p>Пост: {pid}</p>
}

Роуты /post/1, /post/abc и т.д. будут совпадать с pages/post/[pid].js. Совпавший параметр передается странице как параметр строки запроса, объединяясь с другими параметрами.


Например, для роута /post/abc объект query будет выглядеть так:


{ "pid": "abc" }

А для роута /post/abc?foo=bar так:


{ "pid": "abc", "foo": "bar" }

Параметры роута перезаписывают параметры строки запроса, поэтому объект query для роута /post/abc?pid=123 будет выглядеть так:


{ "pid": "abc" }

Для роутов с несколькими динамическими сегментами query формируется точно также. Например, страница pages/post/[pid]/[cid].js будет совпадать с роутом /post/123/456, а query будет выглядеть так:


{ "pid": "123", "cid": "456" }

Навигация между динамическими роутами на стороне клиента обрабатывается с помощью next/link:


import Link from 'next/link'

export default function Home() {
  return (
    <ul>
      <li>
        <Link href="/post/abc">
          Ведет на страницу `pages/post/[pid].js`
        </Link>
      </li>
      <li>
        <Link href="/post/abc?foo=bar">
          Также ведет на страницу `pages/post/[pid].js`
        </Link>
      </li>
      <li>
        <Link href="/post/123/456">
          <a>Ведет на страницу `pages/post/[pid]/[cid].js`</a>
        </Link>
      </li>
    </ul>
  )
}

Перехват всех путей


Динамические роуты могут быть расширены для перехвата всех путей посредством добавления многоточия (...) в квадратные скобки. Например pages/post/[...slug].js будет совпадать с /post/a, /post/a/b, /post/a/b/c и т.д.


Обратите внимание: вместо slug можно использовать любое другое название, например, [...param].


Совпавшие параметры передаются странице как параметры строки запроса (slug в данном случае) со значением в виде массива. Например, query для /post/a будет иметь такую форму:


{ "slug": ["a"] }

А для /post/a/b такую:


{ "slug": ["a", "b"] }

Роуты для перехвата всех путей могут быть опциональными — для этого параметр необходимо обернуть еще в одни квадратные скобки ([[...slug]]).


Например, pages/post/[[...slug]].js будет совпадать с /post, /post/a, /post/a/b и т.д.


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


Примеры объекта query:


{ } // GET `/post` (пустой объект)
{ "slug": ["a"] } // `GET /post/a` (массив с одним элементом)
{ "slug": ["a", "b"] } // `GET /post/a/b` (массив с несколькими элементами)

Обратите внимание на следующие особенности:


  • статические роуты имеют приоритет над динамическими, а динамические — над перехватчиками, например:
    • pages/post/create.js — будет совпадать /post/create
    • pages/post/[pid].js — будут совпадать /post/1, /post/abc и т.д., но не с /post/create
    • pages/post/[...slug].js — будут совпадать с /post/1/2, /post/a/b/c и т.д., но не с /post/create и /post/abc
  • страницы, обработанные с помощью автоматической статической оптимизации, будут гидратированы без параметров роутов, т.е. query будет пустым объектом ({}). После гидратации будет запущено обновление приложения для заполнения query

Императивный подход к навигации на стороне клиента


В большинстве случаев для реализации навигации на стороне клиента достаточно компонента Link из next/link. Однако, для этого можно использовать и роутер из next/router:


import { useRouter } from 'next/router'

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

  return (
    <button onClick={() => router.push('/about')}>
      Читать подробнее
    </button>
  )
}

Поверхностная (тихая) маршрутизация (Shallow Routing)


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


Мы получаем обновленные pathname и query через объект router (получаемый с помощью useRouter() или withRouter()) без потери состояния компонента.


Для включения тихого роутинга используется настройка { shallow: true }:


import { useEffect } from 'react'
import { useRouter } from 'next/router'

// текущий `URL` имеет значение `/`
export default function Page() {
  const router = useRouter()

  useEffect(() => {
    // выполнить навигацию после первого рендеринга
    router.push('?counter=1', undefined, { shallow: true })
  }, [])

  useEffect(() => {
    // значение `counter` изменилось!
  }, [router.query.counter])
}

При обновлении URL изменится только состояние роута.


Обратите внимание: тихий роутинг работает только в пределах одной страницы. Предположим, что у нас имеется страница pages/about.js и мы выполняем следующее:


router.push('?counter=1', '/about?counter=1', { shallow: true })

В этом случае текущая страница выгружается, загружается новая, методы для получения данных перезапускаются (несмотря на наличие { shallow: true }).


Интерфейс маршрутизации (API Routes)


Введение


Любой файл, находящийся в директории pages/api, привязывается к /api/* и считается конечной точкой (endpoint) API, а не страницей. Код интерфейса маршрутизации остается на сервере и не влияет на размер клиентской сборки.


Следующий пример роута pages/api/user.js возвращает статус-код 200 и данные в формате JSON:


export default function handler(req, res) {
  res.status(200).json({ name: 'Иван Петров' })
}

Функция handler принимает два параметра:


  • req — экземпляр http.IncomingMessage + несколько встроенных посредников (см. ниже)
  • res — экземпляр http.ServerResponse + несколько вспомогательных функций (см. ниже)

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


export default function handler(req, res) {
  if (req.method === 'POST') {
    // работаем с POST-запросом
  } else {
    // работаем с другим запросом
  }
}

Случаи использования


В новом проекте весь API может быть построен с помощью интерфейса маршрутизации. Существующий API не требуется обновлять. Другие случаи:


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

Особенности


  • интерфейс маршрутизации не определяет CORS-заголовки по умолчанию. Это делается с помощью посредников (см. ниже)
  • интерфейс маршрутизации не может использоваться вместе с next export

Что касается динамических сегментов в роутах интерфейса маршрутизации, то они подчиняются тем же правилам, что и динамические части роутов страниц.


Посредники (Middlewares)


Интерфейс маршрутизации включает следующих посредников, преобразующих входящий запрос (req):


  • req.cookies — объект, содержащий куки, включенные в запрос (значением по умолчанию является {})
  • req.query — объект, содержащий строку запроса (значением по умолчанию является {})
  • req.body — объект, содержащий тело запроса, преобразованное на основе заголовка Content-Type, или null

Кастомизация посредников


Каждый роут может экспортировать объект config с настройками для посредников:


export const config = {
  api: {
    bodyParser: {
      sizeLimit: '1mb'
    }
  }
}

  • bodyParser: false — отключает разбор ответа (возвращается сырой поток данных — Stream)
  • bodyParser.sizeLimit — максимальный размер тела запроса в любом формате, поддерживаемом bytes
  • externalResolver: true — сообщает серверу, что данный роут обрабатывается внешним резолвером, таким как express или connect

Добавление посредников


Рассмотрим добавление промежуточного обработчика cors.


Устанавливаем модуль:


yarn add cors

Добавляем cors в роут:


import Cors from 'cors'

// инициализируем посредника
const cors = Cors({
  methods: ['GET', 'HEAD']
})

// вспомогательная функция для ожидания успешного разрешения посредника
// перед выполнением другого кода
// или для выбрасывания исключения при возникновении ошибки в посреднике
const runMiddleware = (req, res, next) =>
  new Promise((resolve, reject) => {
    fn(req, res, (result) =>
      result instanceof Error ? reject(result) : resolve(result)
    )
  })

export default async function handler(req, res) {
  // запускаем посредника
  await runMiddleware(req, res, cors)

  // остальная логика `API`
  res.json({ message: 'Всем привет!' })
}

Вспомогательные функции


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


Этот набор включает в себя следующее:


  • res.status(code) — функция для установки статус-кода ответа
  • res.json(body) — для отправки ответа в формате JSON, body — любой сериализуемый объект
  • res.send(body) — для отправки ответа, bodystring, object или Buffer
  • res.redirect([status,] path) — для перенаправления на указанную страницу, status по умолчанию имеет значение 307 (временное перенаправление)

Подготовка к продакшну


  • используйте кеширование (см. ниже) везде, где это возможно
  • убедитесь в том, что сервер и база данных находятся (развернуты) в одном регионе
  • минимизируйте количество JavaScript-кода
  • откладывайте загрузку "тяжелого" JS до момента его фактического использования
  • убедитесь в правильной настройке логирования
  • убедитесь в правильной обработке ошибок
  • настройте страницы 500 (ошибка сервера) и 404 (страница отсутствует)
  • убедитесь, что приложение отвечает лучшим критериям производительности
  • запустите Lighthouse для проверки производительности, лучших практик, доступности и поисковой оптимизации. Используйте производственную сборку и режим "Инкогнито" в браузере, чтобы ничто постороннее не влияло на результаты
  • убедитесь, что используемые в вашем приложении фичи поддерживаются современными браузерами
  • для повышения производительности используйте следующее:
    • next/image и автоматическую оптимизацию изображений
    • автоматическую оптимизацию шрифтов
    • оптимизацию скриптов

Кеширование


Кеширование уменьшает время ответа и количество запросов к внешним сервисам. Next.js автоматически добавляет заголовки кеширования к иммутабельным ресурсам, доставляемым из _next/static, включая JS, CSS, изображения и другие медиа.


Cache-Control: public, max-age=31536000, immutable

Для ревалидации кеша страницы, которая была предварительно отрендерена в статическую разметку, используется настройка revalidate в функции getStaticProps.


Обратите внимание: запуск приложения в режиме разработки с помощью next dev отключает кеширование.


Cache-Control: no-cache, no-store, max-age=0, must-revalidate

Заголовки кеширования также можно использовать в getServerSideProps и интерфейсе маршрутизации для динамических ответов. Пример использования stale-while-revalidate:


// Значение считается свежим в течение 10 сек (s-maxage=10).
// Если запрос повторяется в течение 10 сек, предыдущее кешированное значение
// считается свежим. Если запрос повторяется в течение 59 сек,
// кешированное значение считается устаревшим, но все равно используется для рендеринга
// (stale-while-revalidate=59)
// После этого запрос выполняется в фоновом режиме и кеш заполняется свежими данными.
// После обновления на странице будут отображаться новое значение
export async function getServerSideProps({ req, res }) {
  res.setHeader(
    'Cache-Control',
    'public, s-maxage=10, stale-while-revalidate=59'
  )

  return {
    props: {}
  }
}

Уменьшение количества используемого JavaScript


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


  • Import Cost — расширение для VSCode, показывающее размер импортируемого пакета
  • Package Phobia — сервис для определения "стоимости" добавления в проект новой зависимости для разработки (dev dependency)
  • Bundle Phobia — сервис для определения того, насколько добавление зависимости увеличит размер сборки
  • Webpack Bundle AnalyzerWebpack-плагин для визуализации сборки в виде интерактивной, масштабируемой древовидной структуры

Каждый файл в директории pages выделяется в отдельную сборку в процессе выполнения команды next build. Для ленивой загрузки компонентов и библиотек можно использовать динамический импорт.


Аутентификация (Authentication)


Аутентификация — это процесс определения того, кем является пользователь, а авторизация — процесс определения его полномочий, т.е. того, к чему пользователь имеет доступ. Next.js поддерживает несколько паттернов аутентификации.


Паттерны аутентификации


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


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

Аутентификация при статической генерации


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


Одним из преимуществ использования данного паттерна является возможность доставки страниц из глобального CDN и их предварительная загрузка с помощью next/link. Это приводит к уменьшению времени до интерактивности (Time to Interactive, TTI).


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


// pages/profile.js
import useUser from '../lib/useUser'
import Layout from './components/Layout'

export default function Profile() {
  // получаем данные пользователя на стороне клиента
  const { user } = useUser({ redirectTo: '/login' })

  // состояние загрузки, полученное от сервера
  if (!user || user.isLoggedIn === false) {
    return <Layout>Загрузка...</Layout>
  }

  // после выполнения запроса отображаются данные пользователя
  return (
    <Layout>
      <h1>Ваш профиль</h1>
      <pre>{JSON.stringify(user, null, 2)}</pre>
    </Layout>
  )
}

Аутентификация при рендеринге на стороне сервера


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


export async function getServerSideProps(context) {
  return {
    props: {} // будут переданы компоненту страницы как пропы
  }
}

Перепишем приведенный выше пример. При наличии сессии компонент Profile получит проп user. Обратите внимание на отсутствие шаблона:


// pages/profile.js
import withSession from '../lib/session'
import Layout from '../components/Layout'

export const getServerSideProps = withSession(async (req, res) => {
  const user = req.session.get('user')

  if (!user) {
    return {
      redirect: {
        destination: '/login',
        permanent: false
      }
    }
  }

  return {
    props: {
      user
    }
  }
})

export default function Profile({ user }) {
  // отображаем данные пользователя, состояния загрузки не требуется
  return (
    <Layout>
      <h1>Ваш профиль</h1>
      <pre>{JSON.stringify(user, null, 2)}</pre>
    </Layout>
  )
}

Преимуществом данного подхода является предотвращение вспышки неаутентифицированного контента перед выполнением перенаправления. Важно отметить, что запрос данных пользователя в getServerSideProps блокирует рендеринг до разрешения запроса. Поэтому во избежание создания узких мест и увеличения времени до первого байта (Time to Fist Byte, TTFB), следует убедиться в хорошей производительности сервиса аутентификации.


Провайдеры аутентификации


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


  • next-iron-session — низкоуровневая закодированная сессия без состояния
  • next-auth — полноценная система аутентификации со встроенными провайдерами (Google, Facebook, GitHub и т.д.), JWT, JWE, email/пароль, магическими ссылками и др.

Если вы предпочитаете старый-добрый passport:



Пожалуй, на сегодня это все.


Благодарю за внимание и хорошего дня!




Комментарии (3)


  1. Psychosynthesis
    17.11.2021 14:07

    У меня иногда возникает ощущение, будто некоторые статьи специально раздуваются до конских размеров, будто это повышает качество материала.Но нет. Вместо того чтоб расписывать то, что и так отлично расписано в документации проекта (а это, я думаю, около половины того что вы тут написали), лучше бы подробнее описали баги и косяки этого фреймворка. Как минимум, в этом есть смысл ещё и потому что документация проекта меняется постоянно, т.к. фреймворк активно дорабатывается. Т.е. часть написанного может стать бесполезна уже через полгода-год. А вот баги порой кочуют из версии в версию.

    А проблем у фреймворка хватает. По сути, единственное для чего разумно использовать Next это либо малые приложения на несколько страниц (не стоит оно того), либо приложения среднего размера, с относительно простой архитектурой, которую представляется возможным продумать полностью до того как начнёшь её писать. Большой же проект неизбежно столкнётся с проблемами и багами которые непонятно как исправить (в код фреймворка же не полезешь), а кроме того, есть риск что придётся постоянно переписывать приложение чтоб оно нормально работало, потому что практики, изложенные в документации, которые вы начнёте использовать, уже через полгода изменят на какой-нибудь новый подход (это камень в огород data fetching, где раньше фигурировал, например, метод getInitialProps, а в какой-то момент он стал deprecated, причём не просто deprecated, а буквально пришлось всё переписвывать). Вообще я гораздо больше конкретных проблем бы мог описать, если бы все их записывал, но увы, мне было не до заметок, а память не резиновая. По этой причине хочу отметить однозначно плохие практики, которые доставили лично мне больше всего проблем:

    • Если ваше приложение требует общего хранилища данных, то я не бы не стал использовать Redux. К примеру, в 10.х версии NextJS буквально единственным вариантом оптимального использования Next c редаксом был next-redux-wrapper (сами разработчики его и советуют в общем-то). Вот только и он глючит в некоторых кейсах при работе с SSR, причём глюки абсолютно необъяснимые. Также, при использовании Redux в компонентах, если вам помимо компонентов нужны данные из стора также в каком-нибудь хелпере (вне компонента) возникает проблема неконсистентности стора и я не представляю как её решать с помощью Next. Короче с этим фреймворком я вообще не стал бы использовать какие-то библиотеки управления состоянием... может быть разве что mobx, но я всё-таки не думаю что это хорошая идея, лучше обойтись без хранилища, например постараться использовать useContext для хранения состояния, если это возможно.

    • Я не знаю что на выходе даёт create-next-app, однако я пользовался старым бойлерплейтом для Next от Vercel, который сейчас уже не поддерживается. Так вот на основе опыта работы с тем бойлерплейтом, я бы посоветовал вообще не пользоваться подобными штуками, а всё-таки собирать проект с нуля самостоятельно. Причин тому достаточно много, начиная с того что может внезапно оказаться крайне времязатратно перейти на желаемую версию какого-либо модуля (в моём случае сложность возникла с обновлением webpack), что затем влечёт за собой также обновление половины пакетов, практически полное переписывание конфигов и т.д.


  1. bongoman-by
    17.11.2021 17:04

    Полезная статья. Спасибо!


  1. 51ned
    17.11.2021 17:04

    Мнение новичка в современном front-end в целом и Next.js в частности.

    Эта статья, по сути, перевод документации с официального сайта фреймворка. Только без удобной навигации по разделам и ссылок на репозитории Github с примерами. Зачем, для чего? Людям вроде меня с месяц назад это не поможет от слова совсем.

    К тому же, опущено и (или) не раскрыто множество деталей (а дьявол, как известно, в них). Например, почему нужно "обращать внимание" на структуру paths? Почему нет комментария из официального обучающего курса, что представленная структура обязательна? Как именно взаимодействуют между собой функции getStaticPaths и getStaticProps? Почему именно в таком порядке?