Server Actions отлично оптимизированы для внутренних интерфейсных мутаций и отправки форм в связке с React Server Components. Однако архитектура современных веб-платформ регулярно требует наличия публичных RESTful-интерфейсов, доступных для внешних систем: мобильных клиентов, сторонних микросервисов, входящих вебхуков (Stripe, Telegram, GitHub) или генерации динамических файлов (RSS, XML sitemaps, OG-изображений).
Для создания изолированных HTTP-эндпоинтов в Next.js App Router используются Route Handlers. Они пришли на смену API Routes из устаревшего Pages Router и обеспечивают полный контроль над сетевым уровнем: методами запроса, заголовками, статус-кодами, куками и бинарными потоками данных.
Route Handlers исполняются исключительно на стороне сервера в изолированном контексте, что гарантирует безопасность обращения к базам данных, защищает приватные переменные окружения и не утяжеляет клиентский JavaScript-бандл.
В Next.js App Router маршрутизация строго привязана к файловой системе:
page.tsx отвечают за визуальный интерфейс и возвращают React Server Components (HTML и RSC Flight Payload).route.ts отвечают за программный доступ к данным и возвращают объекты нативного Response (JSON, бинарные потоки, XML).[!IMPORTANT] Коллизия маршрутов: Файл
route.tsне может сосуществовать на одном уровне директории сpage.tsx. URL-сегмент не может одновременно отдавать HTML-страницу и сырые данные API. Попытка создания файлов/users/page.tsxи/users/route.tsв одной папке приведет к ошибке сборкиRoute conflict error.
Внутри файла route.ts экспортируются именованные асинхронные функции, строго соответствующие стандартным методам HTTP: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
Next.js опирается на нативные стандарты Fetch API — классы Request и Response, дополняя их серверными утилитами NextRequest и NextResponse.
Фреймворк анализирует входящий путь и разрешает файловое дерево route.ts.
При обнаружении конфликта route.ts и page.tsx в одной директории сборка прерывается с ошибкой.
Запрос маршрутизируется в указанное окружение: Node.js (по умолчанию) или Edge V8.
Параметры пути передаются в контекст как неразрешенный Promise.
Next.js сверяет метод входящего запроса (GET, POST, OPTIONS) со списком экспортов.
Если запрошенный метод отсутствует в модуле, клиенту возвращается 405 Method Not Allowed.
Асинхронно считываются заголовки (await headers()) и проверяются токены доступа.
Для вебхуков считывается сырое тело await req.text() для исключения искажения HMAC-подписи.
Исполняются запросы к базе данных или генерируется непрерывный поток ReadableStream.
Возвращается инстанс Response или NextResponse с кастомными заголовками и статус-кодом.
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { users } from '@/lib/schema';
// Обработчик для GET запросов
export async function GET(request: NextRequest) {
// Чтение query-параметров через расширение nextUrl
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(Number(searchParams.get('limit') ?? '10'), 50);
const userList = await db.query.users.findMany({
limit,
});
return NextResponse.json(userList, {
status: 200,
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
},
});
}
// Обработчик для POST запросов
export async function POST(request: NextRequest) {
try {
// Безопасный асинхронный парсинг JSON-тела
const body: unknown = await request.json();
if (!body || typeof body !== 'object' || !('name' in body)) {
return NextResponse.json(
{ error: 'Некорректные входные данные' },
{ status: 400 }
);
}
const newUser = await db.insert(users).values(body).returning();
return NextResponse.json(newUser[0], { status: 201 });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Internal Server Error';
return NextResponse.json({ error: message }, { status: 500 });
}
}В Next.js 16 контекстное свойство params является объектом Promise. Попытка прямого синхронного доступа к свойствам параметров запрещена компилятором.
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { users } from '@/lib/schema';
import { eq } from 'drizzle-orm';
interface RouteContext {
params: Promise<{
id: string;
}>;
}
export async function DELETE(
_request: NextRequest,
{ params }: RouteContext
) {
// Обязательное асинхронное разрешение параметров пути в Next.js 16
const { id: userId } = await params;
if (!userId) {
return NextResponse.json({ error: 'ID пользователя обязателен' }, { status: 400 });
}
await db.delete(users).where(eq(users.id, userId));
// Возврат пустого успешного ответа 204 No Content
return new Response(null, { status: 204 });
}Next.js 16 требует асинхронного вызова утилит cookies() и headers() из пакета next/headers.
import { cookies, headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// 1. Асинхронное чтение заголовков сервера
const headerStore = await headers();
const authorization = headerStore.get('authorization');
// 2. Асинхронное чтение cookies
const cookieStore = await cookies();
const theme = cookieStore.get('theme')?.value ?? 'dark';
// 3. Чтение параметров через объект NextRequest
const clientIp = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
// Формирование ответа с установкой новой сессионной куки
const response = NextResponse.json({
authorized: Boolean(authorization),
theme,
clientIp,
});
response.cookies.set('session_active', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 часа
path: '/',
});
return response;
}Почему в Route Handler для приема Stripe-вебхуков категорически запрещено вызывать await req.json() перед проверкой подписи, и обязательно использовать await req.text()?
Если API вызывается из браузера со сторонних доменов, требуется реализация механизма CORS с поддержкой preflight-метода OPTIONS.
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'https://dashboard.example.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
// Preflight запрос браузера перед отправкой основного запроса
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}
export async function GET() {
return Response.json(
{ status: 'healthy', timestamp: Date.now() },
{
status: 200,
headers: CORS_HEADERS,
}
);
}Route Handlers нативно поддерживают стандарт Web Streams API, что позволяет организовать стриминг токенов искусственного интеллекта или отдавать большие файлы порциями.
export async function GET() {
const encoder = new TextEncoder();
// Инициализация нативного ReadableStream
const stream = new ReadableStream({
async start(controller) {
const chunks = ['Поток ', 'данных ', 'генерируется ', 'в реальном ', 'времени.\n'];
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
// Эмуляция серверной задержки между токенами
await new Promise((resolve) => setTimeout(resolve, 300));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}Для валидации криптографической подписи вебхука категорически запрещено парсить тело в JSON до проверки подписи, так как любое изменение структуры строки разрушает HMAC-хэш.
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', {
apiVersion: '2025-02-24.acacia',
});
export async function POST(req: NextRequest) {
// Для валидации HMAC-подписи необходимо точное строковое представление байтов
const payload = await req.text();
const headerStore = await headers();
const signature = headerStore.get('stripe-signature');
if (!signature) {
return new Response('Отсутствует заголовок подписи', { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET ?? ''
);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : 'Unknown signature error';
return new Response(`Ошибка подписи вебхука: ${errMsg}`, { status: 400 });
}
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
// Бизнес-логика обработки платежа...
}
return NextResponse.json({ received: true }, { status: 200 });
}Разработчик в серверном компоненте страницы app/dashboard/page.tsx пишет: const res = await fetch('http://localhost:3000/api/metrics'). Почему это считается серьезным архитектурным антипаттерном?
Тело входящего запроса request.body представляет собой нативный Locked Stream. После того как вы вызвали метод парсинга (например, await request.json()), поток переходит в заблокированное состояние (bodyUsed = true).
Попытка повторного чтения тела (например, в логгере или сервисе аудита) выбросит исключение TypeError: Body has already been consumed or is locked.
Решение: Сохраняйте результат первого чтения в переменную или клонируйте запрос перед чтением.
// ❌ ОШИБКА: Повторное чтение тела запроса
export async function POST(req: Request) {
const text = await req.text();
const json = await req.json(); // Вызовет фатальное исключение TypeError!
}
// ✅ ПРАВИЛЬНО: Сохраняйте результат в переменную или парсите сырой текст
export async function POST(req: Request) {
const rawText = await req.text();
const parsedData = JSON.parse(rawText);
}Исторически в Next.js 13 и 14 параметры пути передавались синхронным объектом { params: { id: string } }. В Next.js 16 свойство params строго асинхронно. Прямое обращение params.id без предварительного await params приводит к фатальным ошибкам компилятора и ломает типобезопасность.
Начинающие разработчики часто создают файл route.ts, чтобы затем вызвать fetch('http://localhost/api/data') внутри page.tsx.
Это создает лишний сетевой оверхед (loopback HTTP-запрос через сетевой стек), тратит ресурсы сетевых сокетов и увеличивает задержку TTFB. Если данные требуются серверному компоненту, выносите логику в Data Access Layer (DAL) и вызывайте ее напрямую как обычную функцию.