Hello world!


Представляю вашему вниманию вторую часть обновленного руководства по Next.js.



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


Предполагается, что вы хорошо знаете JavaScript и React, а также хотя бы поверхностно знакомы с Node.js.


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


При подготовке руководства я опирался в основном на официальную документацию, но в "отсебятине" мог и приврать (или просто очепятаться) ? При обнаружении подобного не стесняйтесь писать в личку ?


Парочка полезных ссылок:



Содержание



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


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


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


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

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


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


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


Пример:


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

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

  return res.json()
}

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

  return <main></main>
}

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


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


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


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

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


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


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


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


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

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


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


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

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


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

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


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


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


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


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

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


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

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


// app/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

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

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


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


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


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


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

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


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


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

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


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


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


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


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


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


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


Пример


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


  • функция cache используется для мемоизации запроса данных
  • настройка revalidate установлена в значение 3600 в макете и на странице. Это означает, что данные будут кешироваться и ревалидироваться хотя бы раз в час

// app/utils.ts
import { cache } from 'react'

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

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


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

export const revalidate = 3600

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

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

export const revalidate = 3600

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

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


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


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


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


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


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


Соглашение


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


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


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


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

  return (
    // ...
  )
}

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


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


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


// app/actions.ts
'use server'

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

// app/ui/button.tsx
'use client'
import { create } from '@/app/actions'

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

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


// `updateItem` - серверная операция
<ClientComponent updateItem={updateItem} />

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

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

Поведение


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

Примеры


Формы


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


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


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

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

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

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

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


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


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

import { updateUser } from './actions'

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

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

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


// app/actions.js
'use server'

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

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


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


  • useFormStatus возвращает статус родительской формы, т.е. компонент, в котором вызывается этот хук, должен быть потомком элемента form
  • useFormStatus — это хук, поэтому он может вызываться только в клиентских компонентах

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

import { useFormStatus } from 'react-dom'

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

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

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


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

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

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


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


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


// app/actions.ts
'use server'

import { z } from 'zod'

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

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

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

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

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


  • При передаче операции в useFormState, сигнатура операции меняется для получения prevState или initialState в качестве первого аргумента
  • useFormState — это хук, поэтому он может использоваться только в клиентских компонентах

// app/actions.ts
'use server'

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

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


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

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

const initialState = {
  message: '',
}

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

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

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


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


// app/page.tsx
'use client'

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

type Message = {
  message: string
}

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

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

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


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


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


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


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


// app/entry.tsx
'use client'

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

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

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


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


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


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


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


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

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

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

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

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


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


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

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

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

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


useEffect


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


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

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

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

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

    updateViews()
  }, [])

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

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


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


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


// app/actions.ts
'use server'

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

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


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


// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

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

  revalidatePath('/posts')
}

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


// app/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

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

  revalidateTag('posts')
}

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


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


// app/actions.ts
'use server'

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

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

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

Куки


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


'use server'

import { cookies } from 'next/headers'

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

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

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

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


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


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


// app/actions.ts
'use server'

import { auth } from './lib'

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

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

  // ...
}

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


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


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

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

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

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

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


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


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


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


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


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


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

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


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


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


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


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


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


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





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


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





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

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


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


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


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

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

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

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

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


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


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


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


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


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

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

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

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

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

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

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


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


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


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

export const preload = (id: string) => {
  // `void` оценивает переданное выражение и возвращает `undefined`
  // https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/void
  void getItem(id)
}
export default async function Item({ id }: { id: string }) {
  const result = await getItem(id)
  // ...
}

// app/item/[id]/page.tsx
import Item, { preload, checkIsAvailable } from '@/components/Item'

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

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

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


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


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

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

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

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


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


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


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


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


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

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


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

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

// app/page.tsx
import { getUserData } from './data'

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

Рендеринг


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


Основы


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


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

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


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





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

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


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


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


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


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


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

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


Граница сети


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


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


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


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


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


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


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


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


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


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


Серверные компоненты позволяют писать UI, который рендерится и, опционально, кешируется на сервере. В Next.js работа по рендерингу еще больше разделяется по сегментам роута для обеспечения стриминга и частичного рендеринга. Существует три основные стратегии серверного рендеринга:


  • статический рендеринг
  • динамический рендеринг
  • потоковая передача данных (стриминг)

Преимущества серверного рендеринга


Среди преимуществ серверного рендеринга можно отметить следующее:


  • получение данных. Серверные компоненты позволяют переместить получение данных на сервер, ближе к источнику данных. Это может улучшить производительность за счет уменьшения времени получения данных, необходимых для рендеринга, а также количества запросов, которые нужно выполнить клиенту
  • безопасность. Серверные компоненты позволяют хранить конфиденциальные данные, такие как токены доступа и ключи API, и логику работы с ними на сервере, без риска их попадания на клиент
  • кеширование. Серверные компоненты позволяют кешировать результаты рендеринга и повторно использовать их при последующих запросах и для других пользователей
  • размер сборки. Серверные компоненты позволяют хранить большие зависимости на сервере. Это выгодно, прежде всего, людям с медленным соединением и слабыми устройствами, поскольку клиенту не нужно загружать, разбирать и выполнять код серверных компонентов
  • начальная загрузка страницы и First Contentful Paint (FCP). При рендеринге на сервере пользователь получает HTML незамедлительно, ему не нужно ждать загрузки, разбора и выполнения JS для рендеринга страницы
  • поисковая оптимизация. Отрендеренный HTML может использоваться ботами поисковиков для индексации страниц и ботами социальных сетей для генерации превью страниц
  • стриминг. Серверные компоненты позволяют разделить работу по рендерингу на части и отправлять их клиенту по готовности. Это позволяет пользователю видеть части страницы раньше, чем если ждать рендеринга всей страницы на сервере

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


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


Рендеринг серверных компонентов


На сервере Next.js использует API React для оркестрации рендеринга. Работа по рендерингу делится на части: по сегментам роута и компонентам Suspense.


Каждая часть рендерится в два этапа:


  1. React рендерит серверные компоненты в специальный формат данных, который называется полезной нагрузкой серверного компонента React (React Server Component Payload, RSC Payload).
  2. Next.js использует полезную нагрузку RSC и инструкции JS клиентских компонентов (Client Component JavaScript Instructions) для рендеринга HTML роута на сервере.

Затем на клиенте:


  1. HTML используется для моментального отображения неинтерактивного превью роута — только при первоначальной загрузке страницы.
  2. Полезная нагрузка RSC используется для сравнения деревьев клиентских и серверных компонентов и обновления DOM (Document Object Model — объектная модель документа).
  3. Инструкции JS используются для гидратации клиентских компонентов, что делает приложение интерактивным.

Стратегии серверного рендеринга


Существует три разновидности серверного рендеринга: статический, динамический и стриминг.


Статический рендеринг (по умолчанию)


При статическом рендеринге роуты рендерятся во время сборки или в фоновом режиме после ревалидации данных. Результат кешируется и может помещаться в CDN (Content Delivery Network — сеть доставки контента). Это оптимизация позволяет распределять результат рендеринга между пользователями и запросами.


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


Динамический рендеринг


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


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


Переключение на динамический рендеринг


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


Динамические функции Данные Роут
Нет Кеш Статический
Да Кеш Динамический
Нет Не кеш Динамический
Да Не кеш Динамический

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


Как разработчику нам не нужно выбирать между статическим и динамическим рендерингом, поскольку Next.js автоматически выбирает лучшую стратегию рендеринга для каждого роута на основе возможностей и API, которые он использует. Мы выбираем? когда кешировать или ревалидировать определенные данные, а также можем передавать UI по частям.


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


Динамические функции используют информацию, которая известна только во время запроса, такую как пользовательские куки, заголовки текущего запроса и параметры поиска URL. Такими функциями являются:


  • cookies и headers. Использование этих функция в серверном компоненте делает роут динамическим
  • searchParams. Использование этого пропа на странице делает роут динамическим

Стриминг





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





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


Стриминг сегментов роута обеспечивается файлами loading.js, а стриминг компонентов — Suspense.


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


Клиентские компоненты позволяют писать интерактивный UI, который рендерится на клиенте во время запроса. В Next.js рендеринг на клиенте является опциональным: мы должны явно указать, что компонент является клиентским.


Преимущества рендеринга на клиенте


Существует несколько преимуществ клиентского рендеринга:


  • интерактивность. Клиентские компоненты могут использовать состояние, эффекты и обработчики событий: они могут предоставить мгновенную обратную связь пользователю и обновление UI
  • браузерные API. Клиентские компоненты имеют доступ к браузерным API, таким как геолокация или localStorage, позволяя создавать более функциональный UI

Использование клиентских компонентов в Next.js


Для того, чтобы сделать компонент клиентским, достаточно добавить директиву use client в начало соответствующего файла, перед импортами.


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


// app/counter.tsx
'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>Вы кликнули {count} раз</p>
      <button onClick={() => setCount(count + 1)}>Нажми на меня</button>
    </div>
  )
}

На диаграмме ниже показано, что использование обработчика onClick и хука useState во вложенном компоненте (toggle.js) вызовет ошибку при отсутствии директивы use client. Это связано с тем, что по умолчанию компоненты рендерятся на сервере, где эти API отсутствуют. Директива use client сообщает React о том, что компонент и его потомки должны рендерится на клиенте, где эти API доступны.





Рендеринг клиентских компонентов


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


Полная загрузка страницы


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


На сервере:


  1. React рендерит серверные компоненты в специальный формат данных, который называется полезной нагрузкой серверного компонента React (React Server Component Payload, RSC Payload).
  2. Next.js использует полезную нагрузку RSC и инструкции JS клиентских компонентов (Client Component JavaScript Instructions) для рендеринга HTML роута на сервере.

Затем на клиенте:


  1. HTML используется для моментального отображения неинтерактивного превью роута — только при первоначальной загрузке страницы.
  2. Полезная нагрузка RSC используется для сравнения деревьев клиентских и серверных компонентов и обновления DOM.
  3. Инструкции JS используются для гидратации клиентских компонентов, что делает приложение интерактивным.

Последующие навигации


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


Это означает загрузку и разбор сборки JS клиентских компонентов. После этого React использует полезную нагрузку RSC для сравнения деревьев клиентских и серверных компонентов и обновления DOM.


Возвращение в серверную среду


Иногда после определения границы use client, возникает потребность вернуться в серверную среду. Например, для уменьшения размера сборки для клиента, получения данных на сервере или использования серверных API.


Замечательная новость состоит в том, что клиентские и серверные компоненты, а также серверные операции можно чередовать с помощью паттернов композиции.


Паттерны композиции


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


Случаи использования серверных и клиентских компонентов


Задача Серверные компоненты Клиентские компоненты
Получение данных
Прямой доступ к серверным ресурсам
Сокрытие конфиденциальной информации
Хранение больших зависимостей
Добавление интерактивности и обработчиков событий
Использование состояния и методов жизненного цикла компонента
Использование браузерных API
Использование кастомных хуков, которые зависят от состояния, эффектов или браузерных API
Использование классовых компонентов

Паттерны серверных компонентов


Распределение данных между компонентами


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


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


Предотвращение попадания серверного кода на клиент


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


Рассмотрим следующую функцию получения данных:


// lib/data.ts
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })

  return res.json()
}

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


Поскольку у API_KEY нет префикса NEXT_PUBLIC_, эта переменная считается закрытой. Такие переменные на клиенте заменяются пустыми строками.


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


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


npm install server-only

// lib/data.js
import 'server-only'

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })

  return res.json()
}

Для клиентского кода (например, кода который обращается в объекту window) можно использовать аналогичный пакет — client-only.


Использование сторонних пакетов и провайдеров


Поскольку серверные компоненты являются новыми, еще не все сторонние пакеты и провайдеры добавили директиву use client в компоненты, которые используют такие клиентские фичи, как useState, useEffect или createContext.


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


Предположим, что мы установили гипотетический пакет acme-carousel, который предоставляет компонент Carousel. Этот компонент использует useState и не содержит директивы use client.


Если мы используем Carousel в клиентском компоненте, то все будет хорошо:


// app/gallery.tsx
'use client'

import { useState } from 'react'
import { Carousel } from 'acme-carousel'

export default function Gallery() {
  let [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Показать изображения</button>

      {/* Работает, поскольку `Carousel` используется в клиентском компоненте */}
      {isOpen && <Carousel />}
    </div>
  )
}

Но если мы попробуем использовать Carousel в серверном компоненте, то получим ошибку:


// app/page.tsx
import { Carousel } from 'acme-carousel'

export default function Page() {
  return (
    <div>
      {/* Ошибка: `useState` не может использоваться в серверных компонентах */}
      <Carousel />
    </div>
  )
}

Это связано с тем, что Next.js не знает о том, что Carousel — это клиентский компонент.


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


// app/carousel.tsx
'use client'

import { Carousel } from 'acme-carousel'

export default Carousel

После этого Carousel можно использовать в серверных компонентах:


// app/page.tsx
import Carousel from './carousel'

export default function Page() {
  return (
    <div>
      {/* Работает, поскольку `Carousel` - это клиентский компонент */}
      <Carousel />
    </div>
  )
}

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


Использование провайдеров контекста


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


// app/layout.tsx
import { createContext } from 'react'

// `createContext` не поддерживается в серверных компонентах
export const ThemeContext = createContext({})

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  )
}

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


// app/theme-provider.tsx
'use client'

import { createContext } from 'react'

export const ThemeContext = createContext({})

export default function ThemeProvider({
  children,
}: {
  children: React.ReactNode
}) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

После этого провайдер можно использовать в серверных компонентах:


// app/layout.tsx
import ThemeProvider from './theme-provider'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

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


Перемещение клиентских компонентов ниже по дереву


Для уменьшения сборки JS для клиента рекомендуется помещать клиентские компоненты максимально глубоко в дереве компонентов.


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


Вместо того, чтобы делать весь макет клиентским компонентом, перемещаем интерактивную логику в клиентский компонент (например, SearchBar) и оставляем макет серверным компонентом.


// app/layout.tsx
// `SearchBar` - это клиентский компонент
import SearchBar from './searchbar'
// `Logo` - серверный компонент
import Logo from './logo'

// `Layout` - серверный компонент по умолчанию
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  )
}

Передача пропов от серверных к клиентским компонентам (сериализация)


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


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


Чередование серверных и клиентских компонентов


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


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


  • в течение жизненного цикла "запрос-ответ" код перемещается с сервера на клиент. Для доступа к ресурсам на сервере из клиента нужно отправить новый запрос
  • при отправке на сервер нового запроса сначала рендерятся все серверные компоненты, включая те, которые вложены внутрь клиентских компонентов. Результат рендеринга (полезная нагрузка RSC) будет содержать ссылки на локации клиентских компонентов. Затем, на клиенте, React использует полезную нагрузку RSC для согласования серверных и клиентских компонентов
  • поскольку клиентские компоненты рендерятся после серверных, серверные компоненты не могут импортироваться в клиентские. Однако серверные компоненты могут передаваться клиентским как props

Неподдерживаемый паттерн: импорт серверных компонентов в клиентские


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

// Серверный компонент не может импортироваться в клиентский
import ServerComponent from './Server-Component'

export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)

  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>

      <ServerComponent />
    </>
  )
}

Поддерживаемый паттерн: передача серверного компонента клиентскому как пропа


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


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

import { useState } from 'react'

export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)

  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}
    </>
  )
}

ClientComponent не знает, что children предназначен для серверного компонента. Единственная ответственность ClientComponent — определение локации children.


В родительском серверном компоненте мы можем импортировать ClientComponent и ServerComponent, и сделать последнего потомком первого:


// app/page.tsx
import ClientComponent from './client-component'
import ServerComponent from './server-component'

// Страницы являются серверными компонентами по умолчанию
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

Такой подход позволяет ClientComponent и ServerComponent рендериться независимо. В данном случае ServerComponent рендерится на сервере до рендеринга ClientComponent на клиенте.


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


В контексте Next.js среда выполнения (runtime) означает набор библиотек, API и общей функциональности, доступной коду во время выполнения.


На сервере существует две среды, в которых может выполняться код приложения:


  • среда Node.js (по умолчанию) предоставляет доступ ко всем API Node.js и совместимым с ним пакетов
  • граничная (edge) среда, основанная на Web API

Отличия сред


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


  • Node.js среда Бессерверная (serverless) среда Граничная среда
    Холодный запуск \/ Обычный Быстрый
    Потоковая передача данных Да Да Да
    Ввод-вывод Весь Весь fetch
    Масштабируемость \/ Высокая Самая высокая
    Задержка Обычная Низкая Самая низкая
    Пакета NPM Все Все Небольшой набор
    Статический рендеринг Да Да Нет
    Динамический рендеринг Да Да Да
    Ревалидация данных с помощью fetch Да Да Да


Граничная среда


Граничная среда выполнения представляет собой небольшой набор API Node.js.


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


Например, код, выполняемый в граничной среде в Vercel, должен иметь размер от 1 до 4 Мб. Этот размер включает импортируемые пакеты, шрифты и файлы, и зависит от инфрастуктуры развертывания. Кроме того, граничная среда поддерживает не все API Node.js, поэтому некоторые пакеты npm могут не работать (Module not found: Can't resolve 'fs' и похожие ошибки).


Среда Node.js


Это среда выполнения предоставляет доступ ко всем API Node.js. В ней работают все пакеты, совместимые с Node.js. Но она не такая быстрая, как граничная.


Деплой приложения Next.js на сервере Node.js требует управления, масштабирования и настройки инфраструктуры. Вместо этого, для развертывания приложения можно использовать бессерверную платформу, такую как Vercel.


Бессерверный Node.js


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


Бессерверная среда немного медленнее граничной, особенно при холодном старте.


Примеры


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


Среду выполнения для определенного сегмента роута можно определить путем создания и экспорта переменной runtime. Значением этой переменной может быть "nodejs" и "edge".


// app/page.tsx
export const runtime = 'edge' // по умолчанию 'nodejs'

runtime можно определять на уровне макета, что автоматически определит среду выполнения для всех его роутов.


Дефолтной средой выполнения является Node.js, для нее явно определять runtime не нужно.


Кеширование


Обзор


Механизмы кеширования, применяемые в Next.js, и их цели:


Механизм Что Где Цель Длительность
Мемоизация запросов Значения, возвращаемые функциями Сервер Повторное использование данных в компонентах Жизненный цикл запроса
Кеш данных Данные Сервер Хранение данных между запросами пользователя и деплоями Постоянно (доступна ревалидация)
Кеш всего роута HTML и полезная нагрузка RSC Сервер Уменьшение стоимости рендеринга и улучшение производительности Постоянно (доступна ревалидация)
Кеш роутера Полезная нагрузка RSC Клиент Уменьшение количества запросов при навигации Сессия пользователя или в течение определенного времени

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





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


Мемоизация запросов


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





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


// app/example.tsx
async function getItem() {
  // Функция `fetch` автоматически мемоизируется, а ее результат кешируется
  const res = await fetch('https://.../item/1')
  return res.json()
}

// Функция вызывается дважды, но выполнятся только один раз
const item = await getItem() // MISS (данные в кеше отсутствуют)

// Второй вызов может выполняться в любом месте роута
const item = await getItem() // HIT (данные в кеше имеются)

Логика мемоизации запроса





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

Продолжительность


Кеш существует в течение жизненного цикла серверного запроса до завершения рендеринга дерева компонентов.


Ревалидация


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


Отключение


Для отключения мемоизации fetch можно передать в запрос signal экземпляра AbortController:


const { signal } = new AbortController()
fetch(url, { signal })

Кеш данных


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


По умолчанию запросы данных, использующие fetch, кешируются. Настройки cache и next.revalidate позволяют настраивать логику кеширования.


Логика кеширования данных





  • При первом вызове fetch в процессе рендеринга, кешированный ответ проверяется в кеше данных
  • при обнаружении кешированного ответа он незамедлительно возвращается и мемоизируется
  • при отсутствии кешированного ответа, запрос выполняется, и результат записывается в кеш данных и мемоизируется
  • для не кешируемых (например, { cache: 'no-store' }) запросов результат всегда запрашивается из источника данных и мемоизируется
  • независимо от кешируемости данных, запрос всегда мемоизируется для предотвращения дублирования запросов одинаковых данных в одном цикле рендеринга

Продолжительность


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


Ревалидация


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


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

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


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


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

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


Логика ревалидации на основе времени





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

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


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


Логика ревалидации по запросу





  • При первом вызове запроса с revalidate, данные запрашиваются из внешнего источника и записываются в кеш данных
  • при запуске ревалидации по запросу, соответствующие записи удаляются из кеша
    • это отличается от ревалидации на основе времени, когда устаревшие данные сохраняются в кеше до получения новых
  • при выполнении следующего запроса, данные в кеше отсутствуют, поэтому они запрашиваются из внешнего источника и записываются в кеш данных

Отключение


Кеширование отдельного запроса можно отключить путем установки настройки cache в значение no-store:


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

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


export const dynamic = 'force-dynamic'

Кеш всего роута


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


Для понимания работы кеша всего роута (full route cache), полезно рассмотреть, как React выполняет рендеринг, и как Next.js кеширует его результат.


1. React выполняет рендеринг на сервере


На сервере Next.js использует API React для оркестрации рендеринга. Работа по рендерингу делится на части: по сегментам роута и компонентам Suspense.


Каждая часть рендерится в два этапа:


  1. React рендерит серверные компоненты в специальный формат данных, который называется полезной нагрузкой RSC.
  2. Next.js использует полезную нагрузку RSC и инструкции JS клиентского компонента для рендеринга HTML роута на сервере.

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


2. Next.js выполняет кеширование на сервере (кеш всего роута)





Дефолтным поведением Next.js является кеширование результата рендеринга (полезной нагрузки RSC и HTML) роута на сервере. Это применяется к статическим роутам во время сборки или в процессе ревалидации.


3. React гидратирует и согласовывает компоненты на клиенте


Во время запроса на клиенте:


  1. HTML используется для моментального отображения неинтерактивного начального превью клиентских и серверных компонентов.
  2. Полезная нагрузка RSC используется для согласования компонентов и обновления DOM.
  3. Инструкции JS используются для гидратации клиентских компонентов и добавления интерактивности в приложение.

4. Next.js кеширует данные на клиенте (клиентский кеш)


Полезная нагрузка RSC хранится на стороне клиента в кеша роутера (router cache) — отдельном кеше в памяти, разделенном по сегментам роута. Этот кеш используется для улучшения опыта навигации путем хранения ранее посещенных роутов и предварительного запроса будущих роутов.


5. Последующие навигации


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


Статический и динамический рендеринг


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





Продолжительность


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


Инвалидация


Существует два способа инвалидировать кеш всего роута:


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

Отключение


Отключить кеширование всего роута можно следующим образом:


  • путем использования динамических функций — это отключает кеш всего роута и приводит к динамическому рендерингу во время запроса. Кеш данных по-прежнему может использоваться
  • путем использования настроек сегмента роута dynamic = 'force-dynamic' или revalidate = 0 — это отключает кеш всего роута и кеш данных. Это означает, что компоненты будут рендериться, а данные запрашиваться на сервере при каждом запросе. Кеш роутера будет по-прежнему применяться, поскольку это клиентский кеш
  • отключение кеша данных — если роут содержит fetch, который не кешируется, кеш всего роута отключается. Этот fetch будет выполняться при каждом запросе. Другие fetch будут кешироваться. Это позволяет использовать кешированные и не кешированные данные одновременно

Кеш роутера


Next.js использует кеш на стороне клиента, который хранит полезную нагрузку RSC, разделенную на сегменты роута, на протяжении сессии пользователя. Он называется кешем роутера.


Логика работы кеша роутера





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


Это улучшает опыт навигации пользователя:


  • мгновенная навигация "вперед-назад" за счет кеширования посещенных роутов и быстрая навигация к новым роутам благодаря предварительному получению данных и частичному рендерингу
  • навигации не влекут полную перезагрузку страницы — состояние React и браузера сохраняется

Продолжительность


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


  • сессия — кеш сохраняется между навигациями. Однако при перезагрузке страницы он очищается
  • автоматическая инвалидации — кеш отдельных сегментов роута автоматически инвалидируется по истечении определенного периода времени. Этот период зависит от того, статическим является роут или динамическим:
    • для динамических роутов он составляет 30 секунд
    • для статических — 5 минут

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


Добавление prefetch={true} или вызов router.prefetch для динамического роута включают его кеширование на 5 минут.


Инвалидация


Существует два способа инвалидировать кеш роутера:


  • в серверной операции:
    • ревалидация данных по запросу по пути (revalidatePath) и по тегу кеша (revalidateTag)
    • использование метода cookies.set или cookies.delete инвалидируют кеш роутера для предотвращения устаревания роутов, использующих куки (это актуально, например, для аутентификации)
  • вызов метода router.refresh инвалидирует кеш роутера и отправляет новый запрос на сервер для текущего роута

Отключение


Кеш роутера отключить нельзя. Однако его можно инвалидировать путем вызова router.refresh, revalidatePath или revalidateTag. Это очистит кеш и отправит новый запрос, обеспечив отображение актуальных данных.


Также можно отключить предварительное получение данных путем передачи prefetch={false} компоненту Link. Однако сегменты роута все равно будут храниться в течение 30 секунд. Посещенные роуты будут кешироваться.


Взаимодействие частей кеша между собой


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


Кеш данных и кеш всего роута


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

Кеш данных и кеш роутера


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

API


API Кеш роутера Кеш всего роута Кеш данных Кеш React
<Link prefetch> Кеширование - - -
router.prefetch Кеширование - - -
router.refresh Ревалидация - - -
fetch - - Кеширование Кеширование
fetch options.cache - - Кеширование или отключение -
fetch options.next.revalidate - Ревалидация Ревалидация -
fetch options.next.tags - Кеширование Кеширование -
revalidateTag Ревалидация (серверная операция) Ревалидация Ревалидация -
revalidatePath Ревалидация (серверная операция) Ревалидация Ревалидация -
const revalidate - Ревалидация или отключение Ревалидация или отключение -
const dynamic - Кеширование или отключение Кеширование или отключение -
cookies Ревалидация (серверная операция) Отключение - -
headers, searchParams - Отключение - -
generateStaticParams - Кеширование - -
React.cache - - - Кеширование

Link


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


Для отключения предварительного запроса можно установить проп prefetch в значение false. Но это не отключает кеширование, сегмент роута будет кешироваться на клиенте при посещении роута пользователем.


router.prefetch


Настройка prefetch хука useRouter может использоваться для ручного предварительного запроса роута. Это добавляет полезную нагрузку RSC в кеш роутера.


router.refresh


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


Результат рендеринга согласовывается на клиенте при сохранении состояния React и браузера.


fetch


Данные, возвращаемые fetch, автоматически кешируются в кеше данных.


fetch с настройкой cache


Отключить кеширование отдельного fetch можно путем установки настройки cache в значение no-store:


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

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


fetch с настройкой next.revalidate


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


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

fetch с настройкой next.tags и revalidateTag


Для детального кеширования и ревалидации данных Next.js предоставляет теги кеша.


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

Пример установки тегов кеша:


fetch(`https://...`, { next: { tags: ['a', 'b', 'c'] } })

Пример очистки кеша:


revalidateTag('a')

revalidateTag может использоваться в двух местах:


  1. Обработчик роута — для ревалидации данных в ответ на стороннее событие (например, веб-хук). Это не влечет инвалидации кеша роутера, поскольку обработчик роута не привязан к конкретному роуту.
  2. Серверная операция — для ревалидации данных после действия пользователя (например, отправки формы). Это влечет инвалидацию кеша роутера для соответствующего роута.

revalidatePath


Функция revalidatePath позволяет вручную ревалидировать данные и повторно отрендерить сегменты роута по определенному пути за одну операцию. Вызов revalidatePath ревалидирует кеш данных, что, в свою очередь, инвалидирует кеш всего роута.


revalidatePath('/')

revalidatePath может использоваться в двух местах:


  1. Обработчик роута — для ревалидации данных в ответ на стороннее событие (например, веб-хук). Это не влечет инвалидации кеша роутера, поскольку обработчик роута не привязан к конкретному роуту.
  2. Серверная операция — для ревалидации данных после действия пользователя (например, отправки формы).

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


Динамические функции, вроде cookies и headers, а также проп страниц searchParams зависят от входящего запроса. Их использование отключает кеш всего роута, другими словами, роут будет рендериться динамически.


cookies


Использование метода cookies.set или cookies.delete в серверной операции инвалидирует кеш роутера для предотвращения использования роутами устаревших куки.


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


Конфигурация сегмента роута может применяться для перезаписи настроек по умолчанию или когда в отсутствие возможности использовать fetch.


Следующие настройки отключают кеш данных и кеш всего роута:


  • const dynamic = 'force-dynamic'
  • const revalidate = 0

generateStaticParams


Для динамических сегментов (например, app/blog/[slug]/page.js) пути, предоставляемые функцией generateStaticParams, записываются в кеш всего роута во время сборки. Во время запроса Next.js также кеширует пути, которые не были известны во время сборки, при их посещении в первый раз.


Кеширование во время запроса можно отключить с помощью настройки сегмента роута export const dynamicParams = false. В этом случае будут обслуживаться только пути, предоставленные generateStaticParams.


React.cache


Функция cache из React позволяет мемоизировать значение, возвращаемое функцией, позволяя вызывать функцию несколько раз при ее однократном выполнении.


Поскольку запросы fetch мемоизируются автоматически, их не нужно оборачивать в cache. Однако cache может использоваться для ручной мемоизации запросов данных в случаях, когда fetch недоступен. Такими случаями может быть использование клиентов БД, CMS или GraphQL.


// utils/get-item.ts
import { cache } from 'react'
import db from '@/lib/db'

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

  return item
})

Стилизация


Next.js поддерживает несколько способов стилизации приложения, включая:


  • глобальный CSS — способ простой в использовании и хорошо знакомый разработчикам, которые привыкли иметь дело с обычным CSS, но может приводить к увеличению размера сборки и сложностям в управлении стилями при росте приложения
  • модули CSS — позволяют создавать классы CSS с локальной областью видимости, что предотвращает конфликты названий классов и улучшает поддерживаемость стилей
  • Tailwind CSS — фреймворк CSS, основанный на классах-утилитах
  • Sass — популярный препроцессор CSS, расширяющий CSS фичами, вроде переменных, вложенности и миксинов
  • CSS в JS — позволяет внедрять CSS в компоненты JS, что позволяет писать динамические стили с ограниченной областью видимости

Модули CSS


Next.js имеет встроенную поддержку модулей CSS — файлов с расширением .module.css.


Модули CSS — это стили с локальной областью видимости, которая обеспечивается уникальными названиями классов. Это позволяет использовать одинаковые названия классов в разных файлах, не беспокоясь о возможных коллизиях. Такое поведение делает модули CSS идеальным способом определения стилей компонентов.


Пример


Модули CSS могут импортироваться в любой файл, находящийся в директории app:


// app/dashboard/layout.tsx
import styles from './styles.module.css'

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return <section className={styles.dashboard}>{children}</section>
}

/* app/dashboard/styles.module.css */
.dashboard {
  padding: 24px;
}

Модули CSS являются опциональной возможностью и включены только для файлов с расширением .module.css. Также поддерживаются обычные таблицы стилей, подключаемые с помощью элемента link, а также глобальные стили.


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


Глобальные стили


Глобальные стили могут импортироваться в любой макет, страницу или компонент в директории app.


Предположим, что у нас есть такая таблица стилей app/global.css:


body {
  padding: 20px 20px 60px;
  max-width: 680px;
  margin: 0 auto;
}

Для применения этих стилей ко всем роутам приложения импортируем эту таблицу в корневой макет приложения (app/layout.js):


// Эти стили буду применяться ко всем роутам приложения
import './global.css'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

Внешние таблицы стилей


Таблицы стилей сторонних пакетов могут импортироваться в любое место в директории app:


// app/layout.tsx
import 'bootstrap/dist/css/bootstrap.css'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className="container">{children}</body>
    </html>
  )
}

Дополнительные возможности


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


  • при локальном запуске с помощью next dev локальные таблицы стилей (глобальные или модули CSS) используют быструю перезагрузку для отражения изменений после их сохранения
  • при сборке с помощью next build стили собираются в несколько минифицированных файлов .css для уменьшения количества сетевых запросов на получение стилей

Tailwind CSS


Tailwind CSS — это фреймворк CSS, основанный на классах-утилитах, который прекрасно работает с Next.js.


Установка


npm i -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Настройка


// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}',
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',

    // При использовании директории `src`
    './src/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Импорт стилей


/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

// app/layout.tsx
import type { Metadata } from 'next'

// Эти стили будут применяться к каждому роуту приложения
import './globals.css'

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

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


// app/page.tsx
export default function Page() {
  return <h1 className="text-3xl font-bold underline">Привет, Next.js!</h1>
}

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


Начиная с Next.js 13.1, Tailwind CSS и PostCSS поддерживаются с Turbopack.


CSS в JS


В настоящее время библиотеки CSS в JS, для которых требуется JS во время выполнения, не поддерживаются в серверных компонентах.


Следующие библиотеки поддерживаются в клиентских компонентах в директории app (в алфавитном порядке):


  • chakra-ui
  • kuma-ui
  • mui/material
  • pandacss
  • styled-jsx
  • styled-components
  • stylex
  • tamagui
  • tss-react
  • vanilla-extract

Для стилизации серверных компонентов рекомендуется использовать модули CSS или другие решения, генерирующие файлы CSS, такие как PostCSS или Tailwind CSS.


Настройка CSS в JS в app


Настройка CSS в JS состоит из 3 этапов:


  1. Создание реестра стилей для сбора всего CSS при рендеринге.
  2. Использование нового хука useServerInsertedHTML для внедрения стилей перед контентом, который может их использовать.
  3. Создание клиентского компонента, который оборачивает приложение в реестр стилей во время начального рендеринга на стороне сервера.

styled-jsx


Создаем новый реестр:


// app/registry.tsx
'use client'

import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { StyleRegistry, createStyleRegistry } from 'styled-jsx'

export default function StyledJsxRegistry({
  children,
}: {
  children: React.ReactNode
}) {
  // Создаем таблицу стилей один раз с ленивым начальным состоянием
  // https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
  const [jsxStyleRegistry] = useState(() => createStyleRegistry())

  useServerInsertedHTML(() => {
    const styles = jsxStyleRegistry.styles()
    jsxStyleRegistry.flush()
    return <>{styles}</>
  })

  return <StyleRegistry registry={jsxStyleRegistry}>{children}</StyleRegistry>
}

Оборачиваем корневой макет в реестр:


// app/layout.tsx
import StyledJsxRegistry from './registry'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <StyledJsxRegistry>{children}</StyledJsxRegistry>
      </body>
    </html>
  )
}

styled-components


Включаем styled-components в файле next.config.js:


module.exports = {
  compiler: {
    styledComponents: true,
  },
}

Используем API styled-components для создания компонента глобального реестра для сбора всех стилей, генерируемых во время рендеринга, и функцию для возврата этих стилей. Затем используем хук useServerInsertedHTML для внедрения стилей в элемент head в корневом макете.


// lib/registry.tsx
'use client'

import React, { useState } from 'react'
import { useServerInsertedHTML } from 'next/navigation'
import { ServerStyleSheet, StyleSheetManager } from 'styled-components'

export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode
}) {
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())

  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement()
    styledComponentsStyleSheet.instance.clearTag()
    return <>{styles}</>
  })

  if (typeof window !== 'undefined') return <>{children}</>

  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  )
}

Оборачиваем children корневого макета в реестр стилей:


// app/layout.tsx
import StyledComponentsRegistry from './lib/registry'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>{children}</StyledComponentsRegistry>
      </body>
    </html>
  )
}

Sass


Next.js имеет встроенную поддержку Sass после установки соответствующего пакета. Поддерживаются не только файлы с расширением .scss и .sass, но также модули CSS (.module.scss и .module.sass).


Устанавливаем sass:


npm i -D sass

Настройки Sass


Для конфигурации компилятора Sass можно воспользоваться настройкой sassOptions в файле next.config.js:


const path = require('path')

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

Переменные Sass


Next.js поддерживает переменные Sass, экспортируемые из файлов модулей CSS.


Пример использования переменной primaryColor:


/* app/variables.module.scss */
$primary-color: #64ff00;

:export {
  primaryColor: $primary-color;
}

// app/page.js
import variables from './variables.module.scss'

export default function Page() {
  return <h1 style={{ color: variables.primaryColor }}>Привет, Next.js!</h1>
}

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


Happy coding!




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

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


  1. Litvinov_Serge
    25.04.2024 08:02
    +1

    По поводу useFormState: похоже, что от него собираются отказаться в пользу нового хука useActionState

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

    Кроме того, useActionState будет возвращать третий параметр – pending (сейчас для его получения нужно использовать useFormStatus).

    const [state, action, isPending] = useActionState(formAction);

    <form action={action}>


    1. aio350 Автор
      25.04.2024 08:02

      Спасибо за уточнение.


  1. Litvinov_Serge
    25.04.2024 08:02

    В разделе «Паттерны и лучшие практики» говорится: «рекомендуется получать данные на сервере с помощью серверных компонентов». Пока речь идет о запросах без авторизации юзера, всё хорошо. Но если запрос предполагает авторизацию пользователя, начинаются проблемы.

    Например, мы получаем данные профиля пользователя в серверном компоненте (при первом рендере страницы) с помощью запроса на REST API стороннего сервера. Для этого мы отправляем стороннему серверу accessToken, прочитанный из куков пользователя. Если сервер вернул нам 401 (т.е. токен устарел и требует обновления), мы тут же с помощью refreshToken получаем новую пару accessToken и refreshToken, но не имеем возможности записать эти новые токены в куки (поскольку запись в куки возможна только в route handlers, server actions и middleware). На всякий случай уточню, что запрос данных при первом рендере страницы не является server action, поскольку не вызывается в результате действия пользователя (например, отправки формы с атрибутом action, в который передан server action).

    Новые токены можно было бы записать в куки в middleware, но на стадии перехвата запроса в middleware мы еще не знаем, вернет ли нам сторонний сервер 401 (поскольку перехват происходит раньше). Казалось бы, в middleware можно запрашивать новые токены вообще при каждом запросе, но переписывать куки мы можем лишь в заголовках ответа, а не запроса, поэтому новые токены будут записаны в куки лишь при получении ответа пользователем, а сам текущий запрос будет осуществлен со старыми куками (а, значит, и со старым accessToken).

    Судя по тематическим ресурсам, сообщество сейчас не очень понимает, как быть с этой проблемой.