第 8 章:Route Handlers(API 路由)

深入理解 Next.js App Router 中的 Route Handlers,从基础 HTTP 方法到文件上传、流式响应、安全加固,最终构建一个生产级 REST API。

本章目标:掌握 Next.js App Router 中 Route Handlers 的完整使用方式——从 HTTP 方法映射、请求/响应对象、文件上传、流式 Body,到 CORS 配置、鉴权中间层,最终构建一个完整的 REST API。


8.1 Route Handlers 概述

什么是 Route Handler?

Route Handler 是 Next.js App Router 中用于处理 HTTP 请求的服务端函数。它类似于传统后端框架(Express / Fastify)中的路由,但深度集成了 Next.js 的缓存、流式响应和 Edge Runtime。

app/
└── api/
    └── users/
        └── route.ts      ← Route Handler 文件

与 Pages Router API Routes 的区别

特性Pages Router (pages/api/*)App Router (app/api/*/route.ts)
文件命名pages/api/users.tsapp/api/users/route.ts
函数签名export default function handler(req, res)export async function GET(request: NextRequest)
HTTP 方法同一个函数内 switch (req.method)每个方法导出为独立函数
流式响应需要手动处理原生支持 ReadableStream
Edge Runtime需要额外配置export const runtime = 'edge'
缓存控制手动设置 headersexport const dynamic = 'force-static'
TypeScriptNextApiRequest / NextApiResponseNextRequest / NextResponse

Route Handler 的本质

Route Handler 本质上是一个 HTTP 端点,它:

  • 运行在 Node.js 环境(默认)或 Edge Runtime
  • 返回标准的 Response 对象(兼容 Web Fetch API)
  • 可以与 Server ComponentsServer Actions 协同工作
  • 支持 流式响应增量渲染

8.2 HTTP 方法实现

基础示例:CRUD 操作

// app/api/articles/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

// GET /api/articles - 获取文章列表
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const page = Number(searchParams.get('page')) || 1;
  const limit = Number(searchParams.get('limit')) || 10;
  const category = searchParams.get('category');

  const skip = (page - 1) * limit;

  const [articles, total] = await Promise.all([
    prisma.article.findMany({
      where: category ? { category } : undefined,
      skip,
      take: limit,
      orderBy: { createdAt: 'desc' },
      select: {
        id: true,
        title: true,
        slug: true,
        excerpt: true,
        category: true,
        createdAt: true,
        author: {
          select: { name: true, avatar: true },
        },
      },
    }),
    prisma.article.count({ where: category ? { category } : undefined }),
  ]);

  return NextResponse.json({
    data: articles,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  });
}

// POST /api/articles - 创建文章
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { title, slug, content, excerpt, category, tags } = body;

    // 验证必填字段
    if (!title || !slug || !content) {
      return NextResponse.json(
        { error: 'Missing required fields: title, slug, content' },
        { status: 400 }
      );
    }

    const article = await prisma.article.create({
      data: {
        title,
        slug,
        content,
        excerpt,
        category,
        tags,
      },
    });

    return NextResponse.json(article, { status: 201 });
  } catch (error) {
    console.error('Failed to create article:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

动态路由:单个资源操作

// app/api/articles/[id]/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

// 路由参数类型
type Params = { params: Promise<{ id: string }> };

// GET /api/articles/:id - 获取单篇文章
export async function GET(request: NextRequest, { params }: Params) {
  const { id } = await params;

  const article = await prisma.article.findUnique({
    where: { id },
    include: {
      author: { select: { id: true, name: true, avatar: true } },
      comments: {
        orderBy: { createdAt: 'desc' },
        take: 10,
      },
    },
  });

  if (!article) {
    return NextResponse.json(
      { error: 'Article not found' },
      { status: 404 }
    );
  }

  return NextResponse.json(article);
}

// PUT /api/articles/:id - 更新文章
export async function PUT(request: NextRequest, { params }: Params) {
  const { id } = await params;

  try {
    const body = await request.json();

    // 检查文章是否存在
    const existing = await prisma.article.findUnique({ where: { id } });
    if (!existing) {
      return NextResponse.json(
        { error: 'Article not found' },
        { status: 404 }
      );
    }

    const article = await prisma.article.update({
      where: { id },
      data: {
        title: body.title,
        slug: body.slug,
        content: body.content,
        excerpt: body.excerpt,
        category: body.category,
        tags: body.tags,
        updatedAt: new Date(),
      },
    });

    return NextResponse.json(article);
  } catch (error) {
    console.error('Failed to update article:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

// DELETE /api/articles/:id - 删除文章
export async function DELETE(request: NextRequest, { params }: Params) {
  const { id } = await params;

  try {
    const existing = await prisma.article.findUnique({ where: { id } });
    if (!existing) {
      return NextResponse.json(
        { error: 'Article not found' },
        { status: 404 }
      );
    }

    await prisma.article.delete({ where: { id } });

    return NextResponse.json({ message: 'Article deleted successfully' });
  } catch (error) {
    console.error('Failed to delete article:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

// PATCH /api/articles/:id - 部分更新
export async function PATCH(request: NextRequest, { params }: Params) {
  const { id } = await params;

  try {
    const body = await request.json();

    // 只更新提供的字段
    const allowedFields = ['title', 'slug', 'content', 'excerpt', 'category', 'tags', 'published'];
    const data: Record<string, unknown> = {};

    for (const field of allowedFields) {
      if (body[field] !== undefined) {
        data[field] = body[field];
      }
    }

    data.updatedAt = new Date();

    const article = await prisma.article.update({
      where: { id },
      data,
    });

    return NextResponse.json(article);
  } catch (error) {
    console.error('Failed to patch article:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

支持的 HTTP 方法

Next.js Route Handlers 支持以下 HTTP 方法:

// app/api/example/route.ts

export async function GET(request: NextRequest) { }
export async function HEAD(request: NextRequest) { }
export async function POST(request: NextRequest) { }
export async function PUT(request: NextRequest) { }
export async function PATCH(request: NextRequest) { }
export async function DELETE(request: NextRequest) { }

// 如果 GET 存在,HEAD 会自动复用 GET 的逻辑(除非显式导出 HEAD)
// OPTIONS 由 Next.js 自动生成(用于 CORS 预检)

8.3 NextRequest 与 NextResponse

NextRequest 详解

NextRequest 继承自 Web Fetch API 的 Request,并扩展了以下实用属性:

// app/api/debug/route.ts

import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  // 1. URL 参数
  const { searchParams } = new URL(request.url);
  const query = searchParams.get('q');

  // 2. Cookies
  const token = request.cookies.get('auth-token');
  const allCookies = request.cookies.getAll();

  // 3. Headers
  const userAgent = request.headers.get('user-agent');
  const contentType = request.headers.get('content-type');

  // 4. 地理位置(Vercel Edge Network 自动注入)
  const country = request.geo?.country;
  const city = request.geo?.city;
  const region = request.geo?.region;

  // 5. IP 地址
  const ip = request.ip;

  // 6. 请求体(POST/PUT/PATCH)
  // const body = await request.json();
  // const text = await request.text();
  // const formData = await request.formData();
  // const blob = await request.blob();
  // const arrayBuffer = await request.arrayBuffer();

  return NextResponse.json({
    url: request.url,
    method: request.method,
    query,
    cookies: allCookies,
    headers: {
      userAgent,
      contentType,
    },
    geo: { country, city, region },
    ip,
  });
}

NextResponse 详解

NextResponse 继承自 Web Fetch API 的 Response,提供以下便捷方法:

// app/api/response-examples/route.ts

import { NextRequest, NextResponse } from 'next/server';

// 1. JSON 响应
export async function GET(request: NextRequest) {
  return NextResponse.json(
    { message: 'Hello, World!' },
    {
      status: 200,
      headers: {
        'X-Custom-Header': 'custom-value',
      },
    }
  );
}

// 2. 重定向
export async function POST(request: NextRequest) {
  return NextResponse.redirect(
    new URL('/dashboard', request.url),
    302
  );
}

// 3. URL 重写(不改变浏览器地址栏)
export async function PUT(request: NextRequest) {
  return NextResponse.rewrite(
    new URL('/api/internal/data', request.url)
  );
}

// 4. 设置 Cookies
export async function PATCH(request: NextRequest) {
  const response = NextResponse.json({ success: true });

  response.cookies.set('session-id', 'abc123', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 天
    path: '/',
  });

  // 删除 Cookie
  response.cookies.delete('old-cookie');

  return response;
}

// 5. 流式响应
export async function DELETE(request: NextRequest) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 5; i++) {
        controller.enqueue(encoder.encode(`Chunk ${i}\n`));
        await new Promise(resolve => setTimeout(resolve, 1000));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain',
      'Transfer-Encoding': 'chunked',
    },
  });
}

8.4 请求体验证

手动验证

// app/api/users/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

// 定义验证 Schema
const createUserSchema = z.object({
  email: z.string().email('Invalid email format'),
  name: z.string().min(2, 'Name must be at least 2 characters'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  role: z.enum(['admin', 'user']).default('user'),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();

    // 验证请求体
    const validation = createUserSchema.safeParse(body);

    if (!validation.success) {
      return NextResponse.json(
        {
          error: 'Validation failed',
          details: validation.error.errors.map(err => ({
            field: err.path.join('.'),
            message: err.message,
          })),
        },
        { status: 422 }
      );
    }

    // 验证通过,使用类型安全的数据
    const { email, name, password, role } = validation.data;

    // 创建用户...
    const user = await createUser({ email, name, password, role });

    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    if (error instanceof SyntaxError) {
      return NextResponse.json(
        { error: 'Invalid JSON in request body' },
        { status: 400 }
      );
    }
    throw error;
  }
}

封装验证工具

// lib/validate.ts

import { NextRequest, NextResponse } from 'next/server';
import { ZodSchema, ZodError } from 'zod';

export async function validateRequest<T>(
  request: NextRequest,
  schema: ZodSchema<T>
): Promise<{ data: T } | { error: NextResponse }> {
  try {
    const body = await request.json();
    const data = schema.parse(body);
    return { data };
  } catch (error) {
    if (error instanceof ZodError) {
      return {
        error: NextResponse.json(
          {
            error: 'Validation failed',
            details: error.errors.map(err => ({
              field: err.path.join('.'),
              message: err.message,
            })),
          },
          { status: 422 }
        ),
      };
    }

    return {
      error: NextResponse.json(
        { error: 'Invalid request body' },
        { status: 400 }
      ),
    };
  }
}

使用方式:

// app/api/users/route.ts

import { validateRequest } from '@/lib/validate';
import { createUserSchema } from '@/lib/schemas';

export async function POST(request: NextRequest) {
  const result = await validateRequest(request, createUserSchema);

  if ('error' in result) {
    return result.error;
  }

  // result.data 是类型安全的
  const { email, name, password, role } = result.data;

  // 创建用户...
}

8.5 文件上传

单文件上传

// app/api/upload/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'fs/promises';
import { join } from 'path';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File | null;

    if (!file) {
      return NextResponse.json(
        { error: 'No file provided' },
        { status: 400 }
      );
    }

    // 验证文件类型
    const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
    if (!allowedTypes.includes(file.type)) {
      return NextResponse.json(
        { error: 'Invalid file type. Only JPEG, PNG, WebP, and GIF are allowed.' },
        { status: 400 }
      );
    }

    // 验证文件大小(5MB)
    const maxSize = 5 * 1024 * 1024;
    if (file.size > maxSize) {
      return NextResponse.json(
        { error: 'File too large. Maximum size is 5MB.' },
        { status: 400 }
      );
    }

    // 读取文件内容
    const bytes = await file.arrayBuffer();
    const buffer = Buffer.from(bytes);

    // 生成唯一文件名
    const ext = file.name.split('.').pop();
    const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;

    // 保存到 public/uploads/
    const uploadDir = join(process.cwd(), 'public', 'uploads');
    const filePath = join(uploadDir, filename);

    await writeFile(filePath, buffer);

    return NextResponse.json({
      success: true,
      url: `/uploads/${filename}`,
      filename,
      size: file.size,
      type: file.type,
    });
  } catch (error) {
    console.error('Upload failed:', error);
    return NextResponse.json(
      { error: 'Upload failed' },
      { status: 500 }
    );
  }
}

多文件上传

// app/api/upload-multiple/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'fs/promises';
import { join } from 'path';

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILES = 10;

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const files = formData.getAll('files') as File[];

    if (files.length === 0) {
      return NextResponse.json(
        { error: 'No files provided' },
        { status: 400 }
      );
    }

    if (files.length > MAX_FILES) {
      return NextResponse.json(
        { error: `Too many files. Maximum is ${MAX_FILES}.` },
        { status: 400 }
      );
    }

    const uploadDir = join(process.cwd(), 'public', 'uploads');
    const results: Array<{ url: string; filename: string; size: number }> = [];
    const errors: Array<{ filename: string; error: string }> = [];

    for (const file of files) {
      try {
        if (!ALLOWED_TYPES.includes(file.type)) {
          errors.push({
            filename: file.name,
            error: 'Invalid file type',
          });
          continue;
        }

        if (file.size > MAX_FILE_SIZE) {
          errors.push({
            filename: file.name,
            error: 'File too large',
          });
          continue;
        }

        const bytes = await file.arrayBuffer();
        const buffer = Buffer.from(bytes);

        const ext = file.name.split('.').pop();
        const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
        const filePath = join(uploadDir, filename);

        await writeFile(filePath, buffer);

        results.push({
          url: `/uploads/${filename}`,
          filename,
          size: file.size,
        });
      } catch (error) {
        errors.push({
          filename: file.name,
          error: 'Upload failed',
        });
      }
    }

    return NextResponse.json({
      success: results.length > 0,
      uploaded: results,
      errors: errors.length > 0 ? errors : undefined,
    });
  } catch (error) {
    console.error('Upload failed:', error);
    return NextResponse.json(
      { error: 'Upload failed' },
      { status: 500 }
    );
  }
}

流式文件上传(大文件)

// app/api/upload-stream/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { createWriteStream } from 'fs';
import { join } from 'path';
import { mkdir } from 'fs/promises';

export async function POST(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const filename = searchParams.get('filename');

    if (!filename) {
      return NextResponse.json(
        { error: 'Missing filename parameter' },
        { status: 400 }
      );
    }

    // 确保上传目录存在
    const uploadDir = join(process.cwd(), 'uploads');
    await mkdir(uploadDir, { recursive: true });

    const filePath = join(uploadDir, filename);

    // 创建写入流
    const writeStream = createWriteStream(filePath);

    // 获取请求体的 ReadableStream
    const body = request.body;
    if (!body) {
      return NextResponse.json(
        { error: 'No body provided' },
        { status: 400 }
      );
    }

    const reader = body.getReader();
    let totalBytes = 0;

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      writeStream.write(value);
      totalBytes += value.length;
    }

    writeStream.end();

    // 等待写入完成
    await new Promise<void>((resolve, reject) => {
      writeStream.on('finish', resolve);
      writeStream.on('error', reject);
    });

    return NextResponse.json({
      success: true,
      filename,
      size: totalBytes,
    });
  } catch (error) {
    console.error('Stream upload failed:', error);
    return NextResponse.json(
      { error: 'Upload failed' },
      { status: 500 }
    );
  }
}

客户端使用:

// app/components/UploadButton.tsx

'use client';

import { useState } from 'react';

export function UploadButton() {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);

  const handleUpload = async (file: File) => {
    setUploading(true);
    setProgress(0);

    try {
      const response = await fetch(
        `/api/upload-stream?filename=${encodeURIComponent(file.name)}`,
        {
          method: 'POST',
          body: file,
        }
      );

      if (!response.ok) {
        throw new Error('Upload failed');
      }

      const result = await response.json();
      console.log('Upload complete:', result);
    } catch (error) {
      console.error('Upload error:', error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <input
      type="file"
      onChange={(e) => {
        const file = e.target.files?.[0];
        if (file) handleUpload(file);
      }}
      disabled={uploading}
    />
  );
}

8.6 流式响应

Server-Sent Events (SSE)

// app/api/events/route.ts

import { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // 发送初始连接事件
      controller.enqueue(
        encoder.encode('event: connected\ndata: {"status":"connected"}\n\n')
      );

      // 模拟实时数据推送
      let count = 0;
      const interval = setInterval(() => {
        if (count >= 10) {
          clearInterval(interval);
          controller.close();
          return;
        }

        const data = {
          count,
          timestamp: new Date().toISOString(),
          message: `Update ${count}`,
        };

        controller.enqueue(
          encoder.encode(`event: update\ndata: ${JSON.stringify(data)}\n\n`)
        );
        count++;
      }, 1000);

      // 处理客户端断开连接
      request.signal.addEventListener('abort', () => {
        clearInterval(interval);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

客户端使用:

// app/components/EventStream.tsx

'use client';

import { useEffect, useState } from 'react';

export function EventStream() {
  const [events, setEvents] = useState<Array<{ count: number; message: string }>>([]);

  useEffect(() => {
    const eventSource = new EventSource('/api/events');

    eventSource.addEventListener('update', (event) => {
      const data = JSON.parse(event.data);
      setEvents(prev => [...prev, data]);
    });

    eventSource.addEventListener('connected', () => {
      console.log('Connected to event stream');
    });

    eventSource.onerror = () => {
      console.error('EventSource error');
      eventSource.close();
    };

    return () => {
      eventSource.close();
    };
  }, []);

  return (
    <div>
      <h2>Live Events</h2>
      <ul>
        {events.map((event, i) => (
          <li key={i}>{event.message}</li>
        ))}
      </ul>
    </div>
  );
}

流式 JSON(增量渲染)

// app/api/stream-json/route.ts

import { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      // 开始 JSON 数组
      controller.enqueue(encoder.encode('['));

      const items = [
        { id: 1, name: 'Item 1' },
        { id: 2, name: 'Item 2' },
        { id: 3, name: 'Item 3' },
      ];

      for (let i = 0; i < items.length; i++) {
        const prefix = i > 0 ? ',' : '';
        controller.enqueue(encoder.encode(prefix + JSON.stringify(items[i])));

        // 模拟延迟
        await new Promise(resolve => setTimeout(resolve, 500));
      }

      // 结束 JSON 数组
      controller.enqueue(encoder.encode(']'));
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'application/json',
      'Transfer-Encoding': 'chunked',
    },
  });
}

8.7 安全加固

CORS 配置

// app/api/cors-example/route.ts

import { NextRequest, NextResponse } from 'next/server';

// CORS 配置
const ALLOWED_ORIGINS = [
  'http://localhost:3000',
  'https://yourdomain.com',
];

function corsHeaders(origin: string | null) {
  const isAllowed = origin && ALLOWED_ORIGINS.includes(origin);

  return {
    'Access-Control-Allow-Origin': isAllowed ? origin : ALLOWED_ORIGINS[0],
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400', // 24 小时
  };
}

// 处理预检请求
export async function OPTIONS(request: NextRequest) {
  const origin = request.headers.get('origin');

  return new NextResponse(null, {
    status: 204,
    headers: corsHeaders(origin),
  });
}

export async function GET(request: NextRequest) {
  const origin = request.headers.get('origin');

  return NextResponse.json(
    { message: 'Hello with CORS' },
    { headers: corsHeaders(origin) }
  );
}

export async function POST(request: NextRequest) {
  const origin = request.headers.get('origin');

  const body = await request.json();

  return NextResponse.json(
    { received: body },
    { headers: corsHeaders(origin) }
  );
}

鉴权中间层

// lib/auth-middleware.ts

import { NextRequest, NextResponse } from 'next/server';
import { verify } from 'jsonwebtoken';

export interface AuthUser {
  id: string;
  email: string;
  role: 'admin' | 'user';
}

export async function authenticate(
  request: NextRequest
): Promise<AuthUser | null> {
  const authHeader = request.headers.get('authorization');

  if (!authHeader?.startsWith('Bearer ')) {
    return null;
  }

  const token = authHeader.substring(7);

  try {
    const decoded = verify(token, process.env.JWT_SECRET!) as AuthUser;
    return decoded;
  } catch (error) {
    return null;
  }
}

// 鉴权装饰器
export function withAuth<T extends unknown[]>(
  handler: (request: NextRequest, user: AuthUser, ...args: T) => Promise<NextResponse>
) {
  return async (request: NextRequest, ...args: T) => {
    const user = await authenticate(request);

    if (!user) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    return handler(request, user, ...args);
  };
}

// 角色检查
export function withRole<T extends unknown[]>(
  role: AuthUser['role'],
  handler: (request: NextRequest, user: AuthUser, ...args: T) => Promise<NextResponse>
) {
  return withAuth(async (request, user, ...args) => {
    if (user.role !== role) {
      return NextResponse.json(
        { error: 'Forbidden' },
        { status: 403 }
      );
    }

    return handler(request, user, ...args);
  });
}

使用方式:

// app/api/protected/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withRole, AuthUser } from '@/lib/auth-middleware';

// 需要登录
export const GET = withAuth(async (request, user) => {
  return NextResponse.json({
    message: `Hello, ${user.email}!`,
    user,
  });
});

// 需要管理员角色
export const POST = withRole('admin', async (request, user) => {
  const body = await request.json();

  // 只有管理员可以执行此操作
  return NextResponse.json({
    message: 'Admin action performed',
    performedBy: user.email,
  });
});

速率限制

// lib/rate-limit.ts

import { NextRequest, NextResponse } from 'next/server';

interface RateLimitEntry {
  count: number;
  resetAt: number;
}

const rateLimitMap = new Map<string, RateLimitEntry>();

export function rateLimit(
  request: NextRequest,
  options: {
    windowMs: number;
    maxRequests: number;
  } = { windowMs: 60000, maxRequests: 100 }
): { success: boolean; response?: NextResponse } {
  const ip = request.ip || request.headers.get('x-forwarded-for') || 'unknown';
  const now = Date.now();
  const entry = rateLimitMap.get(ip);

  if (!entry || now > entry.resetAt) {
    // 新窗口
    rateLimitMap.set(ip, {
      count: 1,
      resetAt: now + options.windowMs,
    });
    return { success: true };
  }

  if (entry.count >= options.maxRequests) {
    // 超过限制
    const retryAfter = Math.ceil((entry.resetAt - now) / 1000);

    return {
      success: false,
      response: NextResponse.json(
        { error: 'Too many requests' },
        {
          status: 429,
          headers: {
            'Retry-After': retryAfter.toString(),
            'X-RateLimit-Limit': options.maxRequests.toString(),
            'X-RateLimit-Remaining': '0',
            'X-RateLimit-Reset': entry.resetAt.toString(),
          },
        }
      ),
    };
  }

  // 增加计数
  entry.count++;
  rateLimitMap.set(ip, entry);

  return {
    success: true,
    // 可以添加 headers 显示剩余次数
  };
}

使用方式:

// app/api/login/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';

export async function POST(request: NextRequest) {
  // 速率限制:每分钟最多 5 次
  const { success, response } = rateLimit(request, {
    windowMs: 60000,
    maxRequests: 5,
  });

  if (!success) {
    return response!;
  }

  const body = await request.json();
  const { email, password } = body;

  // 验证登录...

  return NextResponse.json({ token: 'jwt-token' });
}

8.8 路由配置导出

// app/api/example/route.ts

// 1. 运行时选择
export const runtime = 'nodejs';  // 'nodejs' | 'edge'

// 2. 动态渲染策略
export const dynamic = 'auto';
// 'auto' | 'force-dynamic' | 'error' | 'force-static'

// 3. 重新验证时间(ISR)
export const revalidate = 60;  // 秒,false = 永久缓存

// 4. 路由段配置
export const fetchCache = 'auto';
// 'auto' | 'default-cache' | 'only-cache' | 'force-cache' | 'no-cache'

export const preferredRegion = 'iad1';  // 首选区域
export const maxDuration = 30;  // 最大执行时间(秒)

Edge Runtime 示例

// app/api/geo/route.ts

import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const country = request.geo?.country || 'Unknown';
  const city = request.geo?.city || 'Unknown';
  const region = request.geo?.region || 'Unknown';

  return NextResponse.json({
    message: `You are in ${city}, ${region}, ${country}`,
    geo: {
      country,
      city,
      region,
    },
    ip: request.ip,
  });
}

8.9 实战:完整 REST API

项目结构

app/
└── api/
    └── v1/
        ├── articles/
        │   ├── route.ts           # GET (列表), POST (创建)
        │   └── [id]/
        │       ├── route.ts       # GET, PUT, DELETE
        │       └── comments/
        │           └── route.ts   # GET, POST
        ├── users/
        │   ├── route.ts           # GET, POST
        │   ├── [id]/
        │   │   └── route.ts       # GET, PUT, DELETE
        │   └── me/
        │       └── route.ts       # 当前用户
        ├── auth/
        │   ├── login/
        │   │   └── route.ts
        │   ├── register/
        │   │   └── route.ts
        │   └── logout/
        │       └── route.ts
        └── upload/
            └── route.ts

完整的文章 API

// app/api/v1/articles/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthUser } from '@/lib/auth-middleware';
import { validateRequest } from '@/lib/validate';
import { z } from 'zod';

const createArticleSchema = z.object({
  title: z.string().min(1).max(200),
  slug: z.string().regex(/^[a-z0-9-]+$/),
  content: z.string().min(1),
  excerpt: z.string().max(500).optional(),
  category: z.string().optional(),
  tags: z.array(z.string()).optional(),
  published: z.boolean().default(false),
});

// GET /api/v1/articles - 获取文章列表
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);

  // 分页
  const page = Math.max(1, Number(searchParams.get('page')) || 1);
  const limit = Math.min(100, Math.max(1, Number(searchParams.get('limit')) || 20));
  const skip = (page - 1) * limit;

  // 筛选
  const category = searchParams.get('category');
  const tag = searchParams.get('tag');
  const author = searchParams.get('author');
  const published = searchParams.get('published');

  // 搜索
  const search = searchParams.get('q');

  // 排序
  const sortBy = searchParams.get('sortBy') || 'createdAt';
  const sortOrder = (searchParams.get('sortOrder') || 'desc') as 'asc' | 'desc';

  // 构建查询条件
  const where: any = {};

  if (category) where.category = category;
  if (tag) where.tags = { has: tag };
  if (author) where.author = { id: author };
  if (published === 'true') where.published = true;
  if (published === 'false') where.published = false;
  if (search) {
    where.OR = [
      { title: { contains: search, mode: 'insensitive' } },
      { content: { contains: search, mode: 'insensitive' } },
    ];
  }

  try {
    const [articles, total] = await Promise.all([
      prisma.article.findMany({
        where,
        skip,
        take: limit,
        orderBy: { [sortBy]: sortOrder },
        select: {
          id: true,
          title: true,
          slug: true,
          excerpt: true,
          category: true,
          tags: true,
          published: true,
          createdAt: true,
          updatedAt: true,
          author: {
            select: {
              id: true,
              name: true,
              avatar: true,
            },
          },
          _count: {
            select: {
              comments: true,
              likes: true,
            },
          },
        },
      }),
      prisma.article.count({ where }),
    ]);

    return NextResponse.json({
      data: articles,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
        hasNext: page * limit < total,
        hasPrev: page > 1,
      },
    });
  } catch (error) {
    console.error('Failed to fetch articles:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

// POST /api/v1/articles - 创建文章(需要登录)
export const POST = withAuth(async (request: NextRequest, user: AuthUser) => {
  const result = await validateRequest(request, createArticleSchema);

  if ('error' in result) {
    return result.error;
  }

  const data = result.data;

  try {
    // 检查 slug 是否已存在
    const existing = await prisma.article.findUnique({
      where: { slug: data.slug },
    });

    if (existing) {
      return NextResponse.json(
        { error: 'Slug already exists' },
        { status: 409 }
      );
    }

    const article = await prisma.article.create({
      data: {
        ...data,
        authorId: user.id,
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            avatar: true,
          },
        },
      },
    });

    return NextResponse.json(article, { status: 201 });
  } catch (error) {
    console.error('Failed to create article:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
});

单篇文章操作

// app/api/v1/articles/[id]/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthUser } from '@/lib/auth-middleware';

type Params = { params: Promise<{ id: string }> };

// GET /api/v1/articles/:id
export async function GET(request: NextRequest, { params }: Params) {
  const { id } = await params;

  try {
    const article = await prisma.article.findUnique({
      where: { id },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            avatar: true,
            bio: true,
          },
        },
        comments: {
          orderBy: { createdAt: 'desc' },
          take: 20,
          include: {
            author: {
              select: {
                id: true,
                name: true,
                avatar: true,
              },
            },
          },
        },
        _count: {
          select: {
            comments: true,
            likes: true,
          },
        },
      },
    });

    if (!article) {
      return NextResponse.json(
        { error: 'Article not found' },
        { status: 404 }
      );
    }

    // 增加浏览量
    await prisma.article.update({
      where: { id },
      data: { views: { increment: 1 } },
    });

    return NextResponse.json(article);
  } catch (error) {
    console.error('Failed to fetch article:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

// PUT /api/v1/articles/:id - 更新文章(需要登录且是作者)
export const PUT = withAuth(async (
  request: NextRequest,
  user: AuthUser,
  { params }: Params
) => {
  const { id } = await params;

  try {
    const existing = await prisma.article.findUnique({
      where: { id },
      select: { authorId: true },
    });

    if (!existing) {
      return NextResponse.json(
        { error: 'Article not found' },
        { status: 404 }
      );
    }

    // 检查权限:只有作者或管理员可以更新
    if (existing.authorId !== user.id && user.role !== 'admin') {
      return NextResponse.json(
        { error: 'Forbidden' },
        { status: 403 }
      );
    }

    const body = await request.json();

    const article = await prisma.article.update({
      where: { id },
      data: {
        title: body.title,
        slug: body.slug,
        content: body.content,
        excerpt: body.excerpt,
        category: body.category,
        tags: body.tags,
        published: body.published,
        updatedAt: new Date(),
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            avatar: true,
          },
        },
      },
    });

    return NextResponse.json(article);
  } catch (error) {
    console.error('Failed to update article:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
});

// DELETE /api/v1/articles/:id - 删除文章(需要登录且是作者)
export const DELETE = withAuth(async (
  request: NextRequest,
  user: AuthUser,
  { params }: Params
) => {
  const { id } = await params;

  try {
    const existing = await prisma.article.findUnique({
      where: { id },
      select: { authorId: true },
    });

    if (!existing) {
      return NextResponse.json(
        { error: 'Article not found' },
        { status: 404 }
      );
    }

    // 检查权限
    if (existing.authorId !== user.id && user.role !== 'admin') {
      return NextResponse.json(
        { error: 'Forbidden' },
        { status: 403 }
      );
    }

    await prisma.article.delete({ where: { id } });

    return NextResponse.json({
      message: 'Article deleted successfully',
    });
  } catch (error) {
    console.error('Failed to delete article:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
});

认证 API

// app/api/v1/auth/login/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { compare } from 'bcryptjs';
import { sign } from 'jsonwebtoken';
import { rateLimit } from '@/lib/rate-limit';

export async function POST(request: NextRequest) {
  // 速率限制:每分钟最多 5 次
  const { success, response } = rateLimit(request, {
    windowMs: 60000,
    maxRequests: 5,
  });

  if (!success) {
    return response!;
  }

  try {
    const { email, password } = await request.json();

    if (!email || !password) {
      return NextResponse.json(
        { error: 'Email and password are required' },
        { status: 400 }
      );
    }

    // 查找用户
    const user = await prisma.user.findUnique({
      where: { email },
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
        password: true,
      },
    });

    if (!user) {
      return NextResponse.json(
        { error: 'Invalid email or password' },
        { status: 401 }
      );
    }

    // 验证密码
    const isValid = await compare(password, user.password);
    if (!isValid) {
      return NextResponse.json(
        { error: 'Invalid email or password' },
        { status: 401 }
      );
    }

    // 生成 JWT
    const token = sign(
      {
        id: user.id,
        email: user.email,
        role: user.role,
      },
      process.env.JWT_SECRET!,
      { expiresIn: '7d' }
    );

    // 设置 HttpOnly Cookie
    const res = NextResponse.json({
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role,
      },
    });

    res.cookies.set('auth-token', token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 7, // 7 天
      path: '/',
    });

    return res;
  } catch (error) {
    console.error('Login failed:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

本章小结

Key Takeaways

  1. Route Handlers 是 Next.js 的原生 API 解决方案:基于 Web 标准 API(Request/Response),支持所有 HTTP 方法
  2. NextRequest/NextResponse 提供了丰富的扩展:cookies、geo、IP、流式响应等
  3. 文件上传支持多种模式:formData(小文件)、流式上传(大文件)、多文件上传
  4. 安全加固是必须的:CORS、鉴权中间层、速率限制、输入验证
  5. 流式响应适用于实时场景:SSE、增量 JSON、大文件下载
  6. 路由配置导出控制运行时行为:runtime、dynamic、revalidate、preferredRegion

下一步

下一章我们将深入 Server Actions——另一种后端函数调用方式。与 Route Handlers 不同,Server Actions 可以直接从 Server Components 和 Client Components 调用,无需手动管理 HTTP 请求。我们将对比两者的适用场景,并学习如何在表单提交、乐观更新、错误处理中使用 Server Actions。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页