Статья опубликована на английском языке.
Миграция API Express в обработчики маршрутов Next.js App Router
Узнайте, как преобразовать маршруты Express, middleware и шаблоны данных в Next.js App Router с использованием серверных компонентов, а также о соображениях, связанных с развертыванием.
Когда миграция на Next.js действительно имеет смысл (а когда — нет)
App Router от Next.js становится разумным выбором, когда ваш проект соответствует по меньшей мере двум из следующих условий:
- Скорость первоначальной загрузки или видимость в поисковых системах имеют критическое значение, и в настоящее время вы используете одностраничное приложение, которое загружает данные с бэкенда Express после загрузки страницы.
- Ваша схема развертывания уже ориентирована на Vercel или подобные платформы или вы готовы использовать такую инфраструктуру — функции App Router, связанные с обработкой данных на краю сети и без серверов, предполагают именно такую среду хостинга.
- Ваше приложение на Express в основном отвечает за отображение страниц и выполнение стандартных операций CRUD, а не за управление постоянными соединениями, фоновыми задачами или вычислительно интенсивными процессами.
Миграция не рекомендуется в следующих случаях:
- Ваш бэкенд выполняет значительное количество задач помимо отображения страниц — очереди сообщений, запланированные задачи, концовки gRPC или постоянные сокет-соединения. Обработчики маршрутов в Next.js не могут заменить специализированный бэкенд-сервис; скорее всего, вы будете использовать Next.js в качестве фронтенд-слоя, сохраняя за ним отдельный сервис на базе Express или Node.
- Вы в значительной степени зависите от существующей экосистемы middleware Express — специализированных поставщиков механизмов аутентификации, библиотек ограничения частоты запросов или инструментов для отслеживания работы приложения — что потребует полной переработки с минимальной пользой.
- Ваше приложение в основном состоит из защищенных интерактивных панелей управления с ограниченными требованиями к поисковым системам; основные преимущества App Router (стриминговая обработка на сервере, оптимизация для поисковиков, статическое генерирование контента) практически не приносят пользы в такой ситуации, в то время как затраты на обучение остаются значительными.
Рекомендуемый подход: сохраните ваш Express API в качестве авторитетного источника данных и бизнес-правил, а Next.js используйте в качестве слоя отрисовки и обработки запросов с фронтенда. Сначала мигрируйте пользовательский интерфейс и маршруты, предназначенные для чтения, оставив внутренние сервисы с интенсивными операциями записи без изменений. Данное руководство следует этой стратегии миграции.
Сопоставление маршрутов Express с обработчиками маршрутов Next.js
Обработчики маршрутов, расположенные в app/api/**/route.ts, представляют собой наиболее прямую замену определениям маршрутов Express. Концептуальное изменение заключается в том, что вместо объектов req и res вы получаете объект Request и возвращаете объект Response (или используете NextResponse для дополнительного удобства), причем каждый метод HTTP становится отдельной экспортируемой функцией вместо вызова метода router.get().
Рассмотрим стандартный маршрут Express, который получает и создаёт заказы:
// express: routes/orders.ts
import ‹0› from „express“;
import ‹1› from „../db“;
import ‹2› from „../middleware/auth“;
const router = Router(); router.get(„/api/orders“, requireAuth, async (req, res) => {
const userId = req.user.id;
const orders = await db.order.findMany({ where: ‹3› });
res.json(‹4›);
}); router.post(„/api/orders“, requireAuth, async (req, res) => {
const ‹5› = req.body;
if (!items?.length) {
return res.status(400).json(‹6›);
}
const order = await db.order.create({
data: ‹7›,
});
res.status(201).json(‹8›);
}); export default router;
Реализация соответствующего обработчика маршрутов:
// app/api/orders/route.ts
import ‹0› from „next/server“;
import ‹1› from „@/lib/db“;
import ‹2› from „@/lib/auth“;
export async function GET(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json(‹3›, ‹4›);
} const orders = await db.order.findMany({ where: ‹5› });
return NextResponse.json(‹6›);
}export async function POST(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json(‹7›, ‹8›);
} const body = await req.json();
if (!body.items?.length) {
return NextResponse.json(‹9›, { status: 400 }); } const order = await db.order.create({
data: { userId: user.id, items: body.items },
});
return NextResponse.json({ order }, { status: 201 });
}
При переводе шаблонов Express в обработчики маршрутов следует уделить внимание нескольким деталям:
Динамические сегменты используют синтаксис, основанный на папках, вместо префиксов в виде двоеточий. Маршрут Express вида /api/orders/:id преобразуется в app/api/orders/[id]/route.ts. Параметр поступает как второй аргумент функции-обработчика: GET(req, { params }: { params: Promise<{ id: string }> }). В текущих версиях Next.js параметры передаются в виде обещания, поэтому их необходимо ожидать с помощью await перед чтением значения.
Цепочка промежуточных компонентов для отдельных маршрутов отсутствует. Промежуточный компонент requireAuth из Express преобразуется либо в общую утилитную функцию, вызываемую в начале каждого обработчика (как показано в примере выше), либо, что предпочтительнее, — в логику внутри файла middleware.ts (о чем будет рассказано в следующем разделе), чтобы отдельные обработчики маршрутов не знали о вопросах аутентификации.
Для парсинга тела запроса требуются явные вызовы — необходимо писать await req.json(), а не полагаться на express.json(). Автоматический парсинг не происходит, что на самом деле повышает ясность: вы избегаете неожиданных ограничений по размеру тела запроса, наложенных глобальным промежуточным компонентом, который был настроен месяцы назад и забыт.
Обработчики маршрутов остаются обычными функциями Node или Edge. Если вы проверяли входные данные с помощью zod в своих маршрутах Express, этот код проверки передается без изменений.
Компоненты сервера против существующих подходов к отрисовке интерфейса в React
Именно этот аспект чаще всего ставит команды в тупик. В архитектуре Express плюс React каждый компонент по умолчанию является клиентским компонентом: он отрисовывается в браузере, а при необходимости данных он обращается к вашему API через useEffect или с помощью библиотеки для загрузки данных, такой как React Query.
// старый подход: React, отрисовываемый на клиенте, взаимодействует с Express
function OrderList() {
const [orders, setOrders] = useState<Order[] | null>(null);
useEffect(() => {
fetch("/api/orders", { credentials: "include" })
.then((r) => r.json())
.then((data) => setOrders(data.orders));
}, []); if (!orders) return <Spinner />;
return (
<ul>
{orders.map((o) => (
<li key={o.id}>{o.id} — ${o.total}</li>
))}
</ul>
);
}
App Router нарушает этот стандарт. Каждый компонент является серверным компонентом, если только вы не указали иное, что означает, что он выполняется на сервере, напрямую обращается к вашей базе данных или сервисам и никогда не отправляет свой JavaScript на клиент. Для данных, принадлежащих конкретной странице, можно полностью обойтись без API-маршрута.
ищите его напрямую:// app/orders/page.tsx — серверный компонент, без использования «use client»
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function OrdersPage() {
const user = await getSessionUser();
if (!user) redirect("/login"); // прямой доступ к БД, без запросов fetch, без состояния загрузки, без дополнительной нагрузки на клиентскую часть
const orders = await db.order.findMany({
where: { userId: user.id }rver предоставляет HTML, уже содержащий данные. Добавлять "use client" следует только тогда, когда компоненту требуется интерактивность: состояние, эффекты, обработчики событий или API, доступные только в браузере:// app/orders/OrderFilters.tsx
"use client";
import > from "react";
import > from "next/navigation"; export function OrderFilters() {
const router = useRouter();
const params = useSearchParams();
const [status, setStatus] = useState(params.get("status") ?? "all"); function apply(next: string) {
setStatus(next);
const url = new URLSearchParams(params);
url.set("status", next);
router.push(`/orders?$>`);
} return (
<select value=> onChange=>>
<option value="all">Все</option>
<option value="pending">>В ожидании</option>
<option value="shipped">>Отправлено</option>
</select>
);
}
/code> Практическое руководство для команд: перемещайте директиву "use client" как можно ниже в дерево компонентов. Сохраняйте страницы и макеты в виде серверных компонентов; применяйте эту директиву только к листовым компонентам, которым действительно нужна интерактивность. Если вы по привычке добавляете "use client" ко всем компонентам (что происходит при переносе одностраничного приложения без пересмотра архитектуры), вы не получите никаких преимуществ от App Router, а ваша модель работы с приложением окажется хуже, чем у исходной конфигурации.
Замена сервиса аутентификации Express на сервис Next.js
В Express сервисы аутентификации выполняются для каждого маршрута внутри процесса Node.js. Next.js предоставляет файл middleware.ts, который перехватывает запросы на уровне edge-слоя ещё до того, как они достигнут какой-либо страницы или обработчика, выполняя функцию аналогичную сервисам аутентификации в Express:
// Старая версия: middleware/auth.ts (Express)
import jwt from "jsonwebtoken";
export function requireAuth(req, res, next) {
const token = req.cookies.session;
if (!token) return res.status(401).json([0]);
try {
req.user = jwt.verify(token, process.env.JWT_SECRET!);
next();
} catch {
res.status(401).json([1]);
}
}
// middleware.ts — находится в корне проекта
import { ServerRequest, ServerResponse } from "next/server";
import { verifyJWT } from "jose"; // совместим с Edge, в отличие от jsonwebtoken
const PROTECTED_PREFIXES = [/dashboard/, /orders/, /api/orders/];
export async functionfunction middleware(req: NextRequest) {
const isProtected = PROTECTED_PREFIXES.some((p) =>
req.nextUrl.pathname.startsWith(p)
);
if (!isProtected) return NextResponse.next(); const token = req.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
} try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const [0] = await jwtVerify(token, secret); // forward the verified user id downstream via a request header
const headers = new Headers(req.headers);
headers.set("x-user-id", String(payload.sub));
return NextResponse.next({ request: [1] });
} catch {
return NextResponse.redirect(new URL("/login", req.url));
}
}export const config = {
matcher: [/"/dashboard/:path*"/, "/"/orders/:path*"/, "/"/api/orders/:path*"/],
};
Два
Проблемы, которые постоянно мешают командам при миграции:- По умолчанию мидлвэр работает в среде Edge runtime, а не в Node.js. Любые библиотеки, зависящие от основных модулей Node.js —
jsonwebtoken, типичные клиенты баз данных — будут работать некорректно или без видимых проблем. Используйте альтернативы, подходящие для среды Edge:joseявляется стандартным выбором для операций с JWT. Перенесите все запросы к базам данных в Server Components или Route Handlers, где доступна полная среда Node.js; никогда не пытайтесь выполнять их в мидлвэре.
SELECT * FROM sessions WHERE id = ?. Ограничьте логику мидлвэра быстрыми, безсостоянийными операциями, такими как проверка подписи токена. Вопросы авторизации — «может ли этот пользователь просмотреть этот заказ?» — откладывайте на компонент страницы или сам обработчик, где полностью доступны возможности работы с базой данных и API Node.js.Паттерны загрузки данных: серверные компоненты против текущего подхода с вызовами API
Большинство архитектур на Express в сочетании с React следуют предсказуемой последовательности: компонент загружается, запрашивает данные у API, API обращается к базе данных, JSON возвращается в браузер, компонент обновляется. Два сетевых хопа — от клиента к серверу и от сервера к базе данных — передают информацию, которая уже находилась у сервера.
С помощью App Router операции чтения в серверных компонентах сокращаются до одного шага: сервер запрашивает базу данных и напрямую передает клиенту поток HTML с результатом, как показано в примере кода OrdersPage. Что касается операций записи, то существуют два распространенных подхода: Route Handlers, когда требуется традиционный API (например, публичный REST-интерфейс), и Server Actions для изменений, инициируемых собственными формами и интерфейсом.
Server Actions наиболее сильно отличаются от конвенций Express. Вы определяете функцию, которая выполняется на сервере, и вызываете её непосредственно из формы, не создавая при этом явного маршрута API:
// app/orders/actions.ts
"use server";
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";export async function createOrder(formData: FormData) {
const user = await getSessionUser();
if (!user) redirect("/login"); const itemId = formData.get("itemId");
if (typeof itemId !== "string" || !itemId) {
throw new Error("Необходим параметр itemId");
} await db.order.create({
data: { userId: user.id, items: [{ itemId, qty: 1 }] },
}); // обновление страницы с заказами с актуальными данными с сервера — повторный запрос с клиента не требуется
revalidatePath("/orders");
}
// app/orders/NewOrderForm.tsx
import ⟨0⟩ from "./actions";
export function NewOrderForm() {
return (
<form action=⟪1⟩>
<input type="text" name="itemId" placeholder="ID элемента" required />
<button type="submit">Создать заказ</button>
</form>
);
}
Выбор между действиями сервера и обработчиками маршрутов:
Действие сервера — это внутренний вызов функции с пользовательского интерфейса на сервер; обработчик маршрута — это полноценный HTTP-эндпоинт. Это различие важно при принятии решения о том, какой подход подходит для конкретной операции.
Используйте обработчик маршрутов, когда конечная точка должна оставаться стабильной и доступной для вызова извне данного приложения Next.js — с мобильных клиентов, через интеграции сторонних сервисов или публичные API. Обработчики маршрутов предоставляют вам четко определенные URL-пути, HTTP-действия, а также контракт, который находится под вашим контролем и имеет версии.
Используйте серверные действия, когда изменения происходят из ваших собственных форм и интерактивных компонентов. Серверные действия требуют меньше шаблонного кода и автоматически обновляют кэшированные данные с помощью revalidatePath или revalidateTag, исключая необходимость вручную обновлять кэш после вызова fetch. Они не предназначены для внешних пользователей или гарантий обратной совместимости; рассматривайте их как внутренние удаленные вызовы процедур.
Практический тест: если вы собираетесь документировать конечную точку для кого-то извне вашей команды, сделайте её обработчиком маршрута. Если она существует только для поддержки кнопки или формы в интерфейсе, более просто использовать серверную операцию.
Различия в развертывании (Vercel против ECS/EB)
Команды, привыкшие к развертыванию Express на ECS или Elastic Beanstalk, сталкиваются с фундаментальными архитектурными изменениями при использовании Next.js на Vercel. Приложение Express работает как долгоживущий процесс Node: один процесс обрабатывает множество запросов, поддерживает активные соединения с базой данных и демонстрирует предсказуемые характеристики использования памяти и времени запуска.
Next.js на Vercel развертывает страницы и обработчики маршрутов в виде отдельных безсерверных или функций на краю сети. Каждая функция запускается независимо, работает в рамках собственных лимитов выполнения и не использует общий пул подключений к базе данных, в отличие от единственного процесса Express. Если вы создадите клиент Prisma так же, как это делали в Express, при нагрузке вы исчерпаете лимит подключений к базе данных, поскольку каждый вызов функции может создавать собственное подключение.
// lib/db.ts — необходимый шаблон для серверлесс-решений Prisma
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };// повторное использование клиента при последовательных запросах вместо создания нового каждый раз
export const db =
globalForPrisma.prisma ??
new PrismaClient({
// используется строка подключения с пулом соединений (например, PgBouncer / Prisma Accelerate / RDS Proxy)
datasources: { db: { url: process.env.DATABASE_URL_POOLED } },
});if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
Этот шаблон позволяет повторно использовать клиент при последовательных запросах вместо создания новой инстанции с каждым запросом.
Если вы развертываете Next.js в ECS или Elastic Beanstalk вместо Vercel, фреймворк работает как традиционный сервер Node. Запустите команды next build, затем next start, или настройте режим автономного вывода для создания более компактных Docker-артефактов. Вы сохраняете преимущества долгосрочного выполнения процессов, но теряете автоматическую сеть Vercel, постепенную генерацию статических файлов в CDN и возможность развертывания превью без настройки. Вам придётся самостоятельно настраивать эти функции или смириться с их отсутствием. Это вполне обоснованный инженерный выбор, а не компромисс; многие команды используют Next.js в ECS именно потому, что у них уже есть соответствующая инфраструктура, и они предпочитают не разделять хостинг между двумя поставщиками.
Выделите время на два дополнительных задания по миграции: переменные окружения (Next.js требует префикса NEXT_PUBLIC_ для любых переменных, доступных браузеру — проверьте каждую переменную, которую вы передаете в процесс сборки React), а также настройка во время сборки по сравнению с временем выполнения (значения, встроенные в статический экспорт, ведут себя иначе, чем значения, загружаемые по запросу, в отличие от одиночного процесса Express, где всё обрабатывается во время выполнения).
Чек-лист миграции и распространенные ошибки
Выполняйте эти шаги по порядку:
- Разверните приложение Next.js параллельно с вашим сервером Express — во время первоначальной настройки оставьте существующее приложение без изменений.
- Сначала преобразуйте страницы, требующие интенсивного чтения и оптимизированные для поисковых систем — именно они получат наибольшую пользу от использования серверных компонентов и имеют минимальный риск при миграции.
middleware.ts с использованием библиотеки для проверки токенов, совместимой с средой выполнения на краю.next build && next start) — next dev маскирует некоторые ошибки, которые проявляются только в режиме производства, такие как нарушения границы между сервером и клиентом.Частые ошибки при миграции:
- Импорт кода, предназначенного только для сервера, в компонент клиента. Когда компонент с атрибутом
"use client"импортирует элементы, доступ к которым предоставляется черезfs, клиент базы данных или конфиденциальные данные, сборка либо проваливается, либо — что ещё опаснее — конфиденциальные данные попадают в JavaScript-код клиента. Установите пакет с атрибутомserver-only, чтобы вызвать ошибку сборки вместо того, чтобы тайно утечь учетные данные.
revalidatePath или revalidateTag после выполнения действия сервера. Без явной перепроверки интерфейс отображает устаревшие данные после изменений, поскольку компоненты сервера могут находиться в кэше."use client". Привычки, унаследованные из одностраничных приложений, являются основной причиной того, что приложение Next.js не может превзойти производительность оригинала.Заключение
Если вы всё ещё не уверены, начните с ограниченного эксперимента: выберите одну страницу с высокой нагрузкой на чтение, важную для SEO, из вашего текущего приложения, пересоздайте её в виде серверного компонента, который будет запрашивать данные из вашей базы данных или напрямую вызывать существующий API Express, и разверните её по маршруту, который не обрабатывается сервером Express. Сравните время загрузки первого байта и размер пакета перед тем, как мигрировать дополнительные страницы. После проверки эффективности этого подхода переместите логику аутентификации в файл middleware.ts, затем постепенно преобразуйте самые загруженные рабочие процессы в Server Actions — постепенная миграция безопаснее и практичнее, чем полная переработка. Проблемы возникают у тех командах, которые мигрируют весь кодовый базис до того, как выяснят, где серверные компоненты действительно сокращают объём работы, а где они лишь добавляют новый концептуальный уровень поверх уже функционирующей системы.
Связанные материалы
- Создание интерфейса поиска в React с четким разделением ответственности за состояние — Разделяйте события, вычисляемые значения, жизненный цикл запросов и отображение результатов, чтобы правильный ответ соответствовал конкретному запросу.
- Отладка RAG путем поиска первой неудачной стадии — Разделяйте сбои в источниках данных, процессе поиска, генерации контента и операциях, чтобы каждый эксперимент решал четко определенную проблему.
- Роль «моста» TypeScript 6 на пути к нативному компилятору TS 7 — Узнайте, как TypeScript 6 обновляет стандартные настройки, механизмы разрешения модулей и синтаксис импорта, чтобы подготовить кодовые базы к более быстрому компилятору TypeScript 7, основанному на языке Go.