10. Route Handlers (API)
🛣️ Route Handlers: API-маршруты в App Router
Заголовок раздела «🛣️ Route Handlers: API-маршруты в App Router»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/*🟢 GET-запрос: простейший пример
Заголовок раздела «🟢 GET-запрос: простейший пример»import { NextResponse } from 'next/server';
const users = [];
export async function GET() { // Просто возвращаем JSON return NextResponse.json(users);}Теперь на GET /api/users ты получишь список пользователей. Магия! ✨
📬 POST-запрос: создание данных
Заголовок раздела «📬 POST-запрос: создание данных»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 });}🔧 Динамические маршруты: [id]
Заголовок раздела «🔧 Динамические маршруты: [id]»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: читаем всё что нужно
Заголовок раздела «🌐 NextRequest: читаем всё что нужно»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, });}🍪 Cookies и заголовки в ответе
Заголовок раздела «🍪 Cookies и заголовки в ответе»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;}🌍 CORS: настраиваем заголовки
Заголовок раздела «🌍 CORS: настраиваем заголовки»Если твоё API используется другими доменами, нужно настроить CORS:
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.tsimport { 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), });}🌊 Streaming Response: стриминг данных
Заголовок раздела «🌊 Streaming Response: стриминг данных»Route Handlers поддерживают стриминг — идеально для AI-ответов и больших данных:
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.tsimport { 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();}⚡ Query Parameters: фильтрация и пагинация
Заголовок раздела «⚡ Query Parameters: фильтрация и пагинация»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, }, });}🔐 Защита маршрутов: авторизация
Заголовок раздела «🔐 Защита маршрутов: авторизация»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.tsimport { 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 Handlers | Server Actions |
|---|---|---|
| Внешние клиенты (мобильные приложения) | ✅ Отлично | ❌ Нет |
| Публичное API | ✅ Отлично | ❌ Нет |
| Webhooks (Stripe, GitHub) | ✅ Отлично | ❌ Нет |
| Форма в React-компоненте | ⚠️ Можно | ✅ Лучше |
| Мутации из Server Components | ❌ Нет | ✅ Отлично |
| Кеш-инвалидация после мутации | ⚠️ Сложно | ✅ revalidatePath |
| Оптимистичные обновления | ⚠️ Вручную | ✅ useOptimistic |
Правило большого пальца: Если нужно публичное API или webhook — используй Route Handlers. Если это внутренняя мутация из React-компонента — используй Server Actions.
🛡️ Обработка ошибок
Заголовок раздела «🛡️ Обработка ошибок»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 } ); }}🔄 Revalidation из Route Handler
Заголовок раздела «🔄 Revalidation из Route Handler»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() });}