Перейти к содержимому

10. Route Handlers (API)

Route Handlers — это способ создать настоящий API прямо в Next.js-приложении. Представь, что твой фронтенд и бэкенд живут в одном доме 🏠. Route Handlers позволяют обрабатывать HTTP-запросы (GET, POST, PUT, DELETE…) прямо из папки app/. Это как mini-Express, но встроенный в Next.js!


Route Handlers создаются с помощью специального файла route.ts (или route.js). Важно: в одной папке не может быть одновременно page.tsx и route.ts!

app/
├── api/
│ ├── users/
│ │ ├── route.ts # GET /api/users, POST /api/users
│ │ └── [id]/
│ │ └── route.ts # GET /api/users/1, PUT /api/users/1, DELETE /api/users/1
│ ├── products/
│ │ └── route.ts # GET /api/products
│ └── auth/
│ └── [...nextauth]/
│ └── route.ts # GET/POST /api/auth/*

app/api/users/route.ts
import { NextResponse } from 'next/server';
const users = [
{ id: 1, name: 'Яша', email: '[email protected]' },
{ id: 2, name: 'Маша', email: '[email protected]' },
];
export async function GET() {
// Просто возвращаем JSON
return NextResponse.json(users);
}

Теперь на GET /api/users ты получишь список пользователей. Магия! ✨


app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET() {
const users = await db.user.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
// Читаем тело запроса
const body = await request.json();
// Валидация
if (!body.name || !body.email) {
return NextResponse.json(
{ error: 'Имя и email обязательны' },
{ status: 400 }
);
}
// Создаём пользователя
const newUser = await db.user.create({
data: { name: body.name, email: body.email },
});
// Возвращаем 201 Created
return NextResponse.json(newUser, { status: 201 });
}

app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface Params {
params: Promise<{ id: string }>; // В Next.js 15 params — Promise!
}
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
const user = await db.user.findUnique({
where: { id: parseInt(id) },
});
if (!user) {
return NextResponse.json({ error: 'Пользователь не найден' }, { status: 404 });
}
return NextResponse.json(user);
}
export async function PUT(request: NextRequest, { params }: Params) {
const { id } = await params;
const body = await request.json();
const updated = await db.user.update({
where: { id: parseInt(id) },
data: body,
});
return NextResponse.json(updated);
}
export async function DELETE(request: NextRequest, { params }: Params) {
const { id } = await params;
await db.user.delete({ where: { id: parseInt(id) } });
// 204 No Content — стандарт для DELETE
return new NextResponse(null, { status: 204 });
}

NextRequest — расширенная версия стандартного Request. Даёт доступ к заголовкам, кукам, query params и телу запроса:

import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// URL и query параметры
const url = request.nextUrl;
const page = url.searchParams.get('page') ?? '1';
const limit = url.searchParams.get('limit') ?? '10';
const search = url.searchParams.get('search') ?? '';
console.log(`URL: ${url.pathname}`);
console.log(`Query: page=${page}, limit=${limit}, search=${search}`);
// Заголовки
const authorization = request.headers.get('authorization');
const contentType = request.headers.get('content-type');
const userAgent = request.headers.get('user-agent');
// Cookies
const sessionToken = request.cookies.get('session-token')?.value;
const theme = request.cookies.get('theme')?.value ?? 'light';
// IP адрес
const ip = request.ip ?? request.headers.get('x-forwarded-for');
// Геолокация (Vercel)
const country = request.geo?.country ?? 'RU';
const city = request.geo?.city ?? 'Moscow';
return NextResponse.json({
page: parseInt(page),
limit: parseInt(limit),
search,
theme,
country,
city,
});
}

import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST(request: NextRequest) {
const { username, password } = await request.json();
// Проверяем credentials (упрощённо)
if (username !== 'admin' || password !== 'secret') {
return NextResponse.json({ error: 'Неверные данные' }, { status: 401 });
}
const token = generateToken(username);
// Способ 1: Через NextResponse
const response = NextResponse.json({ success: true });
response.cookies.set('auth-token', token, {
httpOnly: true, // недоступно из JS
secure: true, // только HTTPS
sameSite: 'strict', // защита от CSRF
maxAge: 60 * 60 * 24 * 7, // 7 дней
path: '/',
});
response.headers.set('X-Custom-Header', 'Hello from API!');
return response;
// Способ 2: Через cookies() из next/headers (Server Context)
// const cookieStore = await cookies();
// cookieStore.set('auth-token', token, { httpOnly: true });
}
export async function DELETE() {
// Удаляем куку (logout)
const response = NextResponse.json({ success: true });
response.cookies.delete('auth-token');
return response;
}

Если твоё API используется другими доменами, нужно настроить CORS:

lib/cors.ts
export function corsHeaders(origin: string = '*') {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
}
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { corsHeaders } from '@/lib/cors';
// Обязательно для pre-flight запросов!
export async function OPTIONS() {
return NextResponse.json({}, { headers: corsHeaders() });
}
export async function GET(request: NextRequest) {
const origin = request.headers.get('origin') ?? '*';
const products = await getProducts();
return NextResponse.json(products, {
headers: corsHeaders(origin),
});
}

Route Handlers поддерживают стриминг — идеально для AI-ответов и больших данных:

app/api/stream/route.ts
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
// Создаём ReadableStream
const stream = new ReadableStream({
async start(controller) {
const words = ['Hello', ' from', ' streaming', ' Next.js', '! 🚀'];
for (const word of words) {
// Кодируем и отправляем кусок данных
controller.enqueue(new TextEncoder().encode(word));
// Имитируем задержку
await new Promise(resolve => setTimeout(resolve, 300));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}
// Для AI стриминга (OpenAI / Vercel AI SDK)
// app/api/chat/route.ts
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(request: NextRequest) {
const { messages } = await request.json();
const result = await streamText({
model: openai('gpt-4o'),
messages,
});
return result.toDataStreamResponse();
}

app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
// Пагинация
const page = Math.max(1, parseInt(searchParams.get('page') ?? '1'));
const limit = Math.min(100, parseInt(searchParams.get('limit') ?? '20'));
const offset = (page - 1) * limit;
// Фильтрация
const category = searchParams.get('category');
const minPrice = searchParams.get('minPrice');
const maxPrice = searchParams.get('maxPrice');
const sort = searchParams.get('sort') ?? 'createdAt';
const order = (searchParams.get('order') ?? 'desc') as 'asc' | 'desc';
const where: any = {};
if (category) where.category = category;
if (minPrice) where.price = { ...where.price, gte: parseFloat(minPrice) };
if (maxPrice) where.price = { ...where.price, lte: parseFloat(maxPrice) };
const [products, total] = await Promise.all([
db.product.findMany({
where,
skip: offset,
take: limit,
orderBy: { [sort]: order },
}),
db.product.count({ where }),
]);
return NextResponse.json({
data: products,
meta: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
});
}

lib/auth.ts
export async function getAuthUser(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value
?? request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) return null;
try {
const payload = await verifyJwt(token);
return payload;
} catch {
return null;
}
}
// app/api/profile/route.ts
import { getAuthUser } from '@/lib/auth';
export async function GET(request: NextRequest) {
const user = await getAuthUser(request);
if (!user) {
return NextResponse.json(
{ error: 'Не авторизован' },
{ status: 401 }
);
}
const profile = await db.user.findUnique({
where: { id: user.id },
select: { id: true, name: true, email: true, avatar: true },
});
return NextResponse.json(profile);
}

⚖️ Route Handlers vs Server Actions: когда что использовать?

Заголовок раздела «⚖️ Route Handlers vs Server Actions: когда что использовать?»
КритерийRoute HandlersServer Actions
Внешние клиенты (мобильные приложения)✅ Отлично❌ Нет
Публичное API✅ Отлично❌ Нет
Webhooks (Stripe, GitHub)✅ Отлично❌ Нет
Форма в React-компоненте⚠️ Можно✅ Лучше
Мутации из Server Components❌ Нет✅ Отлично
Кеш-инвалидация после мутации⚠️ СложноrevalidatePath
Оптимистичные обновления⚠️ ВручнуюuseOptimistic

Правило большого пальца: Если нужно публичное API или webhook — используй Route Handlers. Если это внутренняя мутация из React-компонента — используй Server Actions.


app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const UpdateUserSchema = z.object({
name: z.string().min(2).max(50).optional(),
email: z.string().email().optional(),
age: z.number().int().min(0).max(150).optional(),
});
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Парсим и валидируем тело
const body = await request.json();
const validationResult = UpdateUserSchema.safeParse(body);
if (!validationResult.success) {
return NextResponse.json(
{
error: 'Ошибка валидации',
details: validationResult.error.flatten().fieldErrors,
},
{ status: 422 }
);
}
const user = await db.user.update({
where: { id: parseInt(id) },
data: validationResult.data,
});
return NextResponse.json(user);
} catch (error) {
// Prisma: запись не найдена
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2025') {
return NextResponse.json({ error: 'Пользователь не найден' }, { status: 404 });
}
console.error('Unexpected error:', error);
return NextResponse.json(
{ error: 'Внутренняя ошибка сервера' },
{ status: 500 }
);
}
}

import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
// Webhook от CMS — обновляем кэш при изменении контента
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-webhook-secret');
if (secret !== process.env.WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { type, slug } = await request.json();
if (type === 'article.published') {
// Инвалидируем кэш конкретной страницы
revalidatePath(`/blog/${slug}`);
// Или весь список статей
revalidatePath('/blog');
// Или по тегу
revalidateTag('articles');
}
return NextResponse.json({ revalidated: true, at: new Date().toISOString() });
}