Главная / Статьи / Modern Next.js Mapped: Что заменяет каждая функция и когда её использовать

Modern Next.js Mapped: Что заменяет каждая функция и когда её использовать

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

3113 слов

Теперь Next.js отвечает за маршрутизацию, загрузку данных, кэширование, стратегию отрисовки и большую часть функционала API. Однако команды часто продолжают использовать старые привычки: загрузку данных в функции useEffect, создание отдельных API-маршрутов для каждой формы, правила кэширования, которые никто не может объяснить. В этом руководстве рассматриваются восемь функций, которые заменяют старые подходы, а также детали, приводящие к проблемам в реальных проектах, чтобы вы могли по отдельности решить, что следует включить в ваш кодовый базис.

Почему фреймворк теперь охватывает всю стек-архитектуру

Компании Walmart, Nike, TikTok, OpenAI и Airbnb используют Next.js для разработки веб-приложений, преимущество которого заключается в объединении различных компонентов: маршрутизация, сборка кода, режимы отрисовки, доступ к данным и серверные концовки находятся в одном репозитории и подчиняются одному набору правил. Выбор маршрутизатора и настройка инструментов сборки больше не являются частью начала работы над проектом, поэтому время тратится на развитие самого продукта.

1. Компоненты сервера делают сервер стандартным местом отрисовки

React Server Components (RSC) представляют собой самое значительное архитектурное изменение в этом списке. Компонент сервера выполняется исключительно на сервере и отправляет отрисованный результат в браузер, поэтому его код никогда не попадает в пакет клиентского JavaScript.

Что исчезает с страницы, основанной на данных

В классическом React с отрисовкой на стороне клиента даже компонент, который лишь считывает данные и выводит список, добавляет свой код, логику загрузки данных и зависимости в пакет, после чего активируется в браузере. С использованием RSC компонент считывает данные на сервере и передаёт только результат. В App Router каждый компонент является компонентом сервера, если только вы не указали иное, поэтому приведённый ниже файл совсем не требует каких-либо директив:

// app/products/page.tsx
// This component runs ONLY on the server. No "use client" directive needed.

Эта страница представляет собой функцию async, которая напрямую запрашивает базу данных и возвращает маркировку. Между ней и базой данных нет API-маршрута, а также отсутствуют запросы с клиентской стороны:

import { db } from "@/lib/db";export default async function ProductsPage() {
  // Direct database access. No API route. No fetch boilerplate.
  const products = await db.product.findMany({ take: 20 });  return (
    <main>
      <h1>Our Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            <h2>{product.name}</h2>
            <p>${product.price}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

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

Где всё ещё применимы клиентские компоненты

Для любых интерактивных элементов вам всё ещё понадобятся клиентские компоненты: обработчики событий, состояние, эффекты и API браузера, такие как localStorage. Такой файл отмечается путем размещения директивы "use client" в верхней части:

// components/AddToCartButton.tsx
"use client";

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

import { useState } from "react";export function AddToCartButton({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);  return (
    <button onClick={() => setAdded(true)}>
      {added ? "Added!" : "Add to Cart"}
    </button>
  );
}

Начните с серверных компонентов и переходите на клиентские только там, где это необходимо для интерактивности, стараясь размещать указание "use client" как можно ниже в иерархии: страница сервера с одной небольшой клиентской кнопкой требует гораздо меньше JavaScript по сравнению с страницей, которая полностью является клиентским компонентом с самого начала. Чтобы узнать больше о том, как работает модель отрисовки внутри браузера, ознакомьтесь с архитектурой отрисовки без использования бандлов.

2. Действия сервера заменяют API-маршруты для выполнения изменений

Server Actions позволяют написать функцию, которая выполняется на сервере, и вызвать её непосредственно из компонента. Next.js генерирует HTTP-эндпоинт, сериализует аргументы и возвращает результат, поэтому больше не требуется отдельный маршрут только для обработки отправки формы.

Схема с двумя файлами, от которой отказались

Ранее для обработки изменений требовался хендлер в папке api роутера Pages, который считывал тело запроса, записывал данные в базу данных и отвечал JSON:

// You needed an API route
// pages/api/create-post.ts
export default async function handler(req, res) {
  const { title, content } = req.body;
  await db.post.create({ data: { title, content } });
  res.status(200).json({ success: true });
}

Затем компоненту приходилось вручную вызывать этот эндпоинт, самостоятельно сериализуя данные:

// Then in your component:
const response = await fetch("/api/create-post", {
  method: "POST",
  body: JSON.stringify({ title, content }),
});

Проверенная операция, размещённая рядом с формой

С помощью Server Actions страница импортирует всё необходимое, включая Zod для проверки входных данных:

// app/posts/new/page.tsx
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { z } from "zod";

Действие объявляется рядом с формой. Директива "use server" внутри тела функции обозначает его как код, доступный только на сервере; форма передаёт его в свой атрибут action. Действие проверяет объект FormData с помощью метода safeParse, возвращает ошибки полей при неудачной проверке и в противном случае записывает данные и перенаправляет:

const schema = z.object({
  title: z.string().min(3, "Title must be at least 3 characters"),
  content: z.string().min(10, "Content is too short"),
});async function createPost(formData: FormData) {
  "use server";  const parsed = schema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }  await db.post.create({ data: parsed.data });
  redirect("/posts");
}export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Write something..." required />
      <button type="submit">Publish</button>
    </form>
  );
}

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

Отображение ошибок и состояния ожидания с использованием useActionState

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

"use client";

Хук возвращает последнее состояние, сгенерированное действием, обёрнутый объект formAction для передачи форме, а также флаг isPending. Компонент отображает первую ошибку для каждого поля и меняет текст кнопки во время обработки запроса:

import { useActionState } from "react";
import { createPost } from "./actions";export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);  return (
    <form action={formAction}>
      <input name="title" placeholder="Post title" />
      {state?.error?.title && (
        <p className="text-red-500">{state.error.title[0]}</p>
      )}
      <textarea name="content" placeholder="Write something..." />
      {state?.error?.content && (
        <p className="text-red-500">{state.error.content[0]}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? "Publishing..." : "Publish"}
      </button>
    </form>
  );
}

Деталь, которая часто вызывает путаницу: когда действие используется через useActionState, React вызывает его с предыдущим состоянием в качестве первого аргумента и объектом FormData — в качестве второго. Функция createPost, показанная ранее, принимает только formData, поэтому версия, экспортируемая из ./actions для этого хука, должна иметь сигнатуру (prevState, formData). Кроме того, действие должно находиться в отдельном файле с указанием "use server" в верхней части, поскольку клиентский компонент не может определять функции сервера встроенно.

3. Turbopack сокращает цикл обратной связи в процессе разработки

В течение долгого времени webpack определял стандарты локальной разработки. Функция Fast Refresh была удобна после запуска сервера, но загрузка крупных приложений с нуля могла занимать от 30 до 60 секунд. Turbopack — это бандлер на языке Rust от Vercel, предназначенный для устранения этого проблемного момента.

Turbopack показал 100%-ный процент успешного пройдения всех 8,298 тестов интеграции с Next.js, и команды сообщают о времени загрузки проектов с нуля менее 3 секунд, тогда как раньше на это уходила более минуты. Уровень зрелости этого инструмента быстро менялся между версиями, поэтому обязательно проверяйте актуальную документацию для узнания его состояния в вашей версии, особенно что касается производственных сборок.

Включение функции

Чтобы включить её в процесс разработки, достаточно указать соответствующий флаг в скрипте dev:

// package.json
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build"
  }
}

Дополнительная настройка не требуется. Turbopack работает по принципу поэтапной обработки, пересоздавая только то, что изменилось, поэтому он наиболее эффективен для крупных проектов. Если вы используете собственные загрузчики или плагины webpack, сначала убедитесь в наличии эквивалентов в Turbopack. Наше сравнение бандлеров рассматривает все эти аспекты.

4. Частичная предварительная обработка объединяет статическую оболочку с потоковыми данными

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

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

Страница импортирует один статический компонент и два динамических:

// app/product/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./ProductDetails"; // static
import { PersonalizedPrice } from "./PersonalizedPrice"; // dynamic
import { StockStatus } from "./StockStatus"; // dynamic

Граница между статическими и динамическими компонентами обозначается с помощью Suspense. Всё, что находится за этой границей, может быть предварительно отрендерено; каждый элемент Suspense служит заменителем в шелле, который заменяется после завершения отрисовки его дочерних элементов на сервере:

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* This renders statically - instant */}
      <ProductDetails id={params.id} />      {/* These stream in dynamically */}
      <Suspense fallback={<div>Loading price...</div>}>
        <PersonalizedPrice productId={params.id} />
      </Suspense>      <Suspense fallback={<div>Checking stock...</div>}>
        <StockStatus productId={params.id} />
      </Suspense>
    </div>
  );
}

Обратите внимание, что здесь params задан как обычный объект. В более новых версиях Next.js params передаётся в виде Promise и требует ожидания, поэтому необходимо скорректировать его тип в соответствии с используемой версией.

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

// next.config.ts
import type { NextConfig } from "next";

а затем сам флаг:

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
};export default nextConfig;

На момент написания этого текста в более новых версиях эта опция была переорганизована (поведение PPR теперь связано с настройкой Cache Components в Next.js 16), поэтому рассматривайте этот пример как иллюстративный и уточните название текущей опции. В любом случае страница кажется статической, поскольку её шаблон хранится в кэше, в то время как данные остаются актуальными. Мы более подробно рассматриваем этот механизм в статье Что такое частичная предварительная обработка и одновременная обработка.

5. Директива use cache делает кэширование явным

В Next.js 13 и 14 кэширование по умолчанию происходило активно, и многие команды обнаруживали устаревшие страницы в продакшене без очевидных причин. Next.js 16 переходит к режиму опционального кэширования с использованием Cache Components и директивы use cache: вы сами указываете, что должно храниться в кэше, вместо того чтобы догадываться о том, что уже там находится.

Размещение данной директивы в верхней части асинхронного компонента позволяет кэшировать его отрендеренный вывод:

// A component that caches its output for 1 hour
async function PopularArticles() {
  "use cache";

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

  const articles = await fetch("https://api.example.com/popular-articles").then(
    (r) => r.json()
  );  return (
    <ul>
      {articles.map((article: { id: string; title: string }) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}

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

// next.config.ts
const nextConfig = {
  experimental: {
    cacheLife: {
      "stale-for-a-day": {
        stale: 60 * 60, // 1 hour
        revalidate: 60 * 60 * 24, // 1 day
        expire: 60 * 60 * 24 * 7, // 1 week
      },
    },
  },
};

Эти три значения отвечают на разные вопросы. stale — это срок, в течение которого клиент может использовать свою копию данных без проверки на сервере, revalidate — это частота обновления записи на сервере в фоновом режиме, а expire — это момент, после которого запись удаляется, и следующий запрос должен ждать свежих данных. То, находится ли cacheLife в разделе experimental, зависит от вашей версии. Чтобы узнать больше о аннулировании кэша на основе тегов, что потребуется при изменении данных при записи, ознакомьтесь с нашим руководством по использованию кэша и аннулированию кэша на основе тегов.

6. Функции потоковой обработки ИИ с помощью AI SDK

Vercel AI SDK интегрируется с обработчиками маршрутов и хуками React, благодаря чему потоковые чаты, поиск с помощью ИИ и генерируемый интерфейс становятся обычным кодом приложения.

На сервере обработчик маршрута импортирует streamText и поставщика моделей:

// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";

Обработчик POST считывает содержимое разговора из тела запроса, запускает потоковую обработку и возвращает её в виде потокового ответа (закрывающая скобка функции отсутствует в примере кода):

export async function POST(req: Request) {
  const { messages } = await req.json();  const result = streamText({
    model: openai("gpt-4o"),
    messages,
  });  return result.toDataStreamResponse();

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

// app/chat/page.tsx
"use client";

Хук useChat управляет списком сообщений, значением ввода и его отправкой, а также перерисовывает интерфейс по мере поступления новых токенов:

import { useChat } from "ai/react";export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit } = useChat();  return (
    <div>
      <div>
        {messages.map((m) => (
          <div key={m.id}>
            <strong>{m.role}:</strong> {m.content}
          </div>
        ))}
      </div>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} placeholder="Ask anything..." />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

Для стримингового чата это примерно 30 строк кода: обработчик маршрута на бэкенде и функция useChat на фронтенде. Имейте в виду, что API AI SDK быстро развивается: в более новых версиях хук импортируется из @ai-sdk/react, состояние входных данных управляется пользователем, а помощник для обработки ответов имеет другое название. Перед копированием убедитесь, что у вас установлены нужные версии, и ознакомьтесь с документацией SDK. Что касается интерфейсов в стиле агента с инструментами и несколькими шагами, см. создание UI для многошаговых AI-агентов с использованием Next.js и AI SDK.

7. Шаблоны App Router для сложных макетов

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

Параллельные маршруты для независимых панелей дашборда

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

app/
  dashboard/
    @analytics/
      page.tsx
    @recent/
      page.tsx
    layout.tsx
    page.tsx

Лейаут получает каждое поле в качестве параметра вместе с children и размещает их в сетке:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  recent,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  recent: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="col-span-2">{children}</div>
      <aside>
        {analytics}
        {recent}
      </aside>
    </div>
  );
}

Поскольку каждое поле является отдельным сегментом маршрута, оно загружает свои данные и может иметь собственные состояния загрузки и ошибок. Медленный запрос к аналитике не влияет на отображение панели с последними действиями. Одна из проблем: при переходе по подпути, который не определен в поле, Next.js требует наличия файла default.tsx в этом поле, чтобы знать, что отображать при повторной загрузке.

Перехват маршрутов для модалок с реальными URL

Распространённый шаблон интерфейса открывает элемент в модальном окне при изменении URL на этот элемент, что позволяет ему быть общедоступным. Обработка маршрутов осуществляется с использованием правил структурирования папок:

app/
  photos/
    [id]/
      page.tsx      // Full page view at /photos/123
    (..)[id]/
      page.tsx      // Intercepted modal view
  page.tsx

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

8. Основы SEO: метаданные и изображения

API метаданных превращает SEO-функции в обычный код, который находится рядом с страницей, которую он описывает. После импорта типа Metadata:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

страница экспортирует функцию generateMetadata, которая загружает запись и возвращает название, описание, данные Open Graph и информацию для карточки Twitter:

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await getPost(params.slug);  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
      type: "article",
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

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

Для изображений в качестве стандарта следует использовать next/image:

import Image from "next/image";

По умолчанию он загружается лениво, предоставляет варианты правильного размера и преобразуется в форматы WebP или AVIF, если браузер их поддерживает. Указание явных значений width и height также позволяет зарезервировать место и избежать смещения лейаута:

export function ProductCard({ product }: { product: Product }) {
  return (
    <div>
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={400}
        height={300}
        priority={false} // set true for above-the-fold images
      />
      <h2>{product.name}</h2>
    </div>
  );
}

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

Разумный порядок обучения

Каждый шаг здесь основан на предыдущем:

  1. Стандартные названия файлов в App Router: layout.tsx, page.tsx, loading.tsx и error.tsx.
  2. Серверные компоненты и местоположение границы клиента.
  3. Действия сервера с проверкой данных с помощью Zod для каждой модификации.
  4. Функции Suspense и стриминг для задания состояний загрузки.
  • Частичная предварительная обработка для модели static-plus-dynamic.
  • Rазрабатывается Turbopack для ускорения итераций.
  • Основные выводы

    • Рассматривайте сервер как стандартную среду выполнения и передавайте параметр "use client" до самых мелких интерактивных элементов.
    • Действия сервера являются конечными точками: внутри каждого из них необходимо проверять входные данные и разрешения.
    • Определяйте границы статического и динамического контента с помощью Suspense; PPR и кэширование строятся на этих границах.
    • Вместо использования стандартных настроек предпочитайте явное кэширование с помощью use cache и названных профилей cacheLife.
    • Многие из этих API изменились между последними версиями, поэтому необходимо проверять флаги и сигнатуры в соответствии с версией, которая фактически используется.

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

    Связанная литература