Server Actions чудово оптимізовані для внутрішніх інтерфейсних мутацій та обробки форм у зв'язці з React Server Components. Проте архітектура сучасних вебплатформ регулярно вимагає наявності публічних RESTful-інтерфейсів, доступних для зовнішніх систем: мобільних клієнтів, сторонніх мікросервісів, вхідних вебхуків (Stripe, Telegram, GitHub) або генерації динамічних файлів (RSS-стрічки, XML sitemaps, динамічні OG-зображення).
Для створення ізольованих HTTP-ендпоінтів у Next.js App Router використовуються Route Handlers. Вони прийшли на зміну API Routes із застарілого Pages Router і забезпечують повний контроль над мережевим рівнем: HTTP-методами, заголовками, статус-кодами, куками та бінарними потоками даних.
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 15+ контекстна властивість 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 15+
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 15+ вимагає асинхронного виклику утиліт 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.
Рішення: Зберігайте результат першого читання у змінну або клонуйте запит перед читанням (req.clone()).
// ❌ ПОМИЛКА: Повторне читання тіла запиту
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 15+ властивість params суворо асинхронна. Пряме звернення params.id без попереднього await params призводить до попереджень компілятора та ламає типобезпеку.
Розробники-початківці часто створюють файл route.ts, щоб потім викликати fetch('http://localhost:3000/api/data') всередині page.tsx.
Це створює зайвий мережевий оверхед (loopback HTTP-запит через мережевий стек), витрачає ресурси мережевих сокетів та збільшує затримку TTFB. Якщо дані потрібні серверному компоненту, виносьте логіку в Data Access Layer (DAL) і викликайте її напряму як звичайну функцію.