Next.js Route Handler
Next.js 13 이전에는 서버 사이드 API 엔드포인트를 만들려면 pages/api/ 디렉토리에 파일을 만들어야 했다. 이 API Routes는 잘 동작했지만, App Router의 서버 컴포넌트·레이아웃 시스템과는 별개의 세계였다. 라우팅 규칙도 달랐고, 미들웨어와의 통합도 어색했다.
Route Handler는 App Router 안에서 네이티브하게 동작하는 서버 사이드 엔드포인트다. app/ 디렉토리 어디든 route.ts (또는 route.js) 파일을 만들면 그 경로가 API 엔드포인트가 된다. 페이지(page.tsx)와 같은 라우팅 규칙을 공유하면서도, 브라우저에 HTML을 렌더링하는 대신 JSON이나 리다이렉트 같은 서버 응답을 반환한다.
API Routes vs Route Handler
기존 Pages Router의 API Routes와 비교하면 차이가 명확하다.
API Routes (Pages Router)
// pages/api/user.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
res.status(200).json({ name: 'John' });
} else if (req.method === 'POST') {
const body = req.body;
res.status(201).json({ created: true });
} else {
res.setHeader('Allow', ['GET', 'POST']);
res.status(405).end();
}
}
하나의 handler 함수 안에서 req.method로 분기해야 했다. Express 스타일의 req/res 객체를 사용하고, Node.js에 종속적인 API 형태였다.
Route Handler (App Router)
// app/api/user/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
return NextResponse.json({ name: 'John' });
}
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ created: true }, { status: 201 });
}
HTTP 메서드별로 named export 함수를 작성한다. GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS를 지원한다. 메서드 분기 로직이 사라지고, 지원하지 않는 메서드에 대해 Next.js가 자동으로 405 Method Not Allowed를 반환한다.
가장 큰 차이는 Web API 표준을 따른다는 점이다. Request/Response 객체를 기반으로 하기 때문에 Node.js뿐 아니라 Edge Runtime에서도 동작한다.
Request 객체 다루기
Route Handler의 첫 번째 인자는 NextRequest다. 이건 Web API Request를 확장한 것으로, 몇 가지 편의 기능이 추가되어 있다.
URL 파라미터와 쿼리스트링
export async function GET(request: NextRequest) {
// 쿼리스트링 파싱
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q'); // ?q=hello → 'hello'
const page = searchParams.get('page'); // ?page=2 → '2'
// URL 정보
const pathname = request.nextUrl.pathname; // '/api/search'
const origin = request.nextUrl.origin; // 'https://example.com'
return NextResponse.json({ query, page });
}
request.nextUrl은 URL 객체를 확장한 NextURL이다. 표준 URL 속성에 더해 basePath, locale 같은 Next.js 특화 속성을 제공한다.
Body 파싱
export async function POST(request: NextRequest) {
// JSON
const json = await request.json();
// FormData
const formData = await request.formData();
const file = formData.get('file') as File;
// Raw text
const text = await request.text();
// ArrayBuffer (바이너리)
const buffer = await request.arrayBuffer();
return NextResponse.json({ received: true });
}
Web API Request의 메서드를 그대로 사용한다. 주의할 점은 body는 한 번만 읽을 수 있다는 것이다. request.json()을 호출한 뒤 request.text()를 호출하면 에러가 난다. 이건 Web Streams API의 특성으로, body stream이 이미 소비된 후에는 다시 읽을 수 없다.
헤더와 쿠키
export async function GET(request: NextRequest) {
// 헤더 읽기
const authorization = request.headers.get('authorization');
const contentType = request.headers.get('content-type');
// 쿠키 읽기 (NextRequest 확장 기능)
const token = request.cookies.get('session-token');
const allCookies = request.cookies.getAll();
return NextResponse.json({ authorized: !!authorization });
}
request.cookies는 NextRequest가 추가한 편의 API로, Map 스타일로 쿠키를 다룰 수 있다. 일반 Request였다면 Cookie 헤더를 직접 파싱해야 한다.
Response 만들기
NextResponse 유틸리티
NextResponse는 Web API Response를 확장해서 자주 쓰는 패턴을 메서드로 제공한다.
// JSON 응답
return NextResponse.json(
{ message: 'Created' },
{ status: 201, headers: { 'X-Custom': 'value' } }
);
// 리다이렉트
return NextResponse.redirect(new URL('/login', request.url));
// 상태 코드 지정 리다이렉트
return NextResponse.redirect(
new URL('/new-page', request.url),
301 // Permanent redirect
);
// 빈 응답
return new NextResponse(null, { status: 204 });
쿠키 설정
export async function POST(request: NextRequest) {
const response = NextResponse.json({ success: true });
response.cookies.set('session', 'abc123', {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7일
path: '/',
});
// 쿠키 삭제
response.cookies.delete('old-cookie');
return response;
}
스트리밍 응답
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 5; i++) {
controller.enqueue(encoder.encode(`data: chunk ${i}\n\n`));
await new Promise(resolve => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
Server-Sent Events(SSE)나 스트리밍 응답이 필요할 때 ReadableStream을 직접 생성해서 반환할 수 있다. 이건 NextResponse가 아니라 표준 Response를 사용해도 된다.
동적 라우트 세그먼트
페이지와 동일한 동적 라우트를 사용한다.
// app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return NextResponse.json({ userId: id });
}
// app/api/posts/[...slug]/route.ts (Catch-all)
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
const { slug } = await params;
// /api/posts/2024/01/hello → slug = ['2024', '01', 'hello']
return NextResponse.json({ slug });
}
두 번째 인자로 params가 Promise로 전달된다. Next.js 15부터 params가 비동기로 변경되었기 때문에 await이 필요하다. 이전 버전에서는 동기적으로 접근할 수 있었다.
캐싱 동작
Route Handler의 캐싱은 약간 직관적이지 않다. GET 메서드에서 Request 객체를 사용하지 않으면 기본적으로 정적으로 캐싱된다.
// ✅ 캐싱됨 (빌드 시 한 번 실행, 이후 캐시된 결과 반환)
export async function GET() {
const data = await fetch('https://api.example.com/data');
return NextResponse.json(await data.json());
}
// ❌ 캐싱 안 됨 (Request 객체를 사용하므로 매 요청마다 실행)
export async function GET(request: NextRequest) {
const token = request.headers.get('authorization');
return NextResponse.json({ token });
}
캐싱을 명시적으로 제어하려면 route segment config를 사용한다.
// 매 요청마다 실행 (캐싱 비활성화)
export const dynamic = 'force-dynamic';
// 또는 revalidate 주기 설정 (ISR처럼)
export const revalidate = 60; // 60초마다 재검증
POST, PUT, DELETE 등 다른 메서드는 캐싱되지 않는다. 데이터를 변경하는 요청을 캐싱하면 안 되니까 당연한 동작이다.
Edge Runtime
Route Handler는 기본적으로 Node.js 런타임에서 실행되지만, Edge Runtime으로 전환할 수 있다.
export const runtime = 'edge';
export async function GET(request: NextRequest) {
return NextResponse.json({
message: 'Edge에서 실행 중',
region: request.headers.get('x-vercel-id'),
});
}
Edge Runtime은 Vercel의 엣지 네트워크처럼 사용자와 가까운 곳에서 실행된다. 응답 지연이 줄어들지만, Node.js 전체 API를 사용할 수 없다는 제약이 있다. fs 모듈, 네이티브 바이너리 의존성 등은 Edge에서 동작하지 않는다.
CORS 설정
브라우저에서 다른 도메인의 Route Handler를 호출하면 CORS 에러가 발생한다. Route Handler 안에서 직접 CORS 헤더를 설정할 수 있다.
export async function GET(request: NextRequest) {
return NextResponse.json(
{ data: 'hello' },
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
}
);
}
// Preflight 요청 처리
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
여러 Route Handler에 동일한 CORS 설정이 필요하면 미들웨어(middleware.ts)에서 일괄 처리하는 것이 더 깔끔하다.
실전 패턴: OAuth 콜백 처리
Route Handler가 진짜 빛나는 순간은 외부 서비스의 콜백을 처리할 때다. 대표적인 예가 OAuth 인증 플로우다.
Apple Sign-In은 다른 OAuth 제공자와 다르게 콜백을 form_post 방식으로 보낸다. 즉, 리다이렉트가 아니라 POST 요청으로 인증 코드가 전달된다. 이 POST 요청을 받아서 처리하려면 서버 사이드 엔드포인트가 필요한데, 바로 Route Handler가 이 역할을 한다.
// app/auth/provider.apple/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const formData = await request.formData();
const code = formData.get('code') as string | null;
const error = formData.get('error') as string | null;
const origin = getOrigin(request);
if (error) {
return NextResponse.redirect(
new URL(`/auth/provider.apple/continue?error=${encodeURIComponent(error)}`, origin),
303,
);
}
if (!code) {
return NextResponse.redirect(
new URL('/auth/provider.apple/continue?error=missing_code', origin),
303,
);
}
// 303 See Other: POST → GET 변환
// 브라우저가 리다이렉트된 URL을 GET으로 요청하게 만든다
return NextResponse.redirect(
new URL(`/auth/provider.apple/continue?code=${encodeURIComponent(code)}`, origin),
303,
);
}
여기서 리다이렉트 상태 코드로 303을 쓰는 이유가 중요하다. HTTP 303 See Other는 "POST 요청의 결과를 GET으로 확인하라"는 의미다. Apple에서 POST로 콜백이 오면, 이 Route Handler가 인증 코드를 쿼리스트링에 담아 클라이언트 페이지로 리다이렉트한다. 303을 쓰면 브라우저가 반드시 GET으로 리다이렉트하므로, POST 데이터가 재전송되는 문제를 방지할 수 있다.
getOrigin() 함수는 프록시 환경에서 올바른 origin을 찾기 위해 x-forwarded-host와 x-forwarded-proto 헤더를 먼저 확인하고, 없으면 host 헤더를, 그것도 없으면 요청 URL에서 추출한다. 리버스 프록시나 로드 밸런서 뒤에서 동작할 때 리다이렉트 URL이 깨지지 않도록 하는 패턴이다.
page.tsx와의 공존 규칙
같은 경로에 route.ts와 page.tsx가 동시에 존재할 수 없다. 둘 다 같은 URL을 처리하려 하기 때문이다.
app/
├── dashboard/
│ ├── page.tsx ← /dashboard (HTML 렌더링)
│ └── route.ts ← ❌ 충돌! 같은 경로에 둘 다 있으면 안 됨
├── api/
│ └── dashboard/
│ └── route.ts ← /api/dashboard (API 응답)
일반적으로 API 엔드포인트는 app/api/ 하위에 모아두지만, 반드시 그럴 필요는 없다. OAuth 콜백처럼 특정 경로에 바인딩되어야 하는 경우 app/auth/provider.apple/callback/route.ts처럼 의미 있는 경로에 배치하는 것이 자연스럽다.
정리
Route Handler는 App Router에서 서버 사이드 로직을 처리하는 표준 방법이다. Web API 기반이라 Node.js와 Edge 양쪽에서 동작하고, 메서드별 함수 분리로 코드가 깔끔해진다. OAuth 콜백, 웹훅 수신, 파일 업로드 처리, 스트리밍 응답 등 클라이언트 컴포넌트만으로는 할 수 없는 서버 작업에 사용하면 된다.
다만 서버 컴포넌트에서 직접 데이터를 가져올 수 있는 경우에는 Route Handler를 거칠 필요가 없다. 클라이언트에서 서버로 요청을 보내야 하거나, 외부에서 들어오는 요청을 받아야 할 때 Route Handler를 사용하는 것이 맞다.