Next.js API 代理模式深度解析:为什么需要 /api/client-proxy?
引言:从一个 308 重定向说起
在部署一个 Next.js + FastAPI 全栈项目时,我遇到了一个令人困惑的现象:前端页面调用 /api/client-proxy/api/v1/api-keys/ 返回 308 重定向(正常工作),而直接调用 /api/v1/api-keys/ 却返回 403 Forbidden。
为什么同样的后端接口,走不同的路径会得到截然不同的结果?/api/client-proxy/ 这个看似多余的路径究竟扮演了什么角色?
这篇文章将深入剖析这一代理模式的设计动机、实现细节和安全性考量。
一、核心概念:/api/client-proxy 是什么?
简单来说,/api/client-proxy/ 是前端自动生成的 SDK 和 Next.js 服务器之间的一条"秘密通道",它专门处理浏览器端的 API 请求。
它的工作原理可以概括为三步:
- 拦截:浏览器端的 SDK 自动检测运行环境,将原本指向后端 API 的请求拦截下来
- 重写:剥离后端域名,将路径拼接为
/api/client-proxy/+ 原始 API 路径 - 转发:Next.js 服务器收到请求后,附带 HttpOnly Cookie 转发到真正的后端
关键命名约定:路径中的 client-proxy 表明了它的职责——为客户端(浏览器)提供代理服务。这不是 Next.js 的内置功能,而是项目架构中的自定义设计。
二、完整请求链路
以下是一次完整的浏览器 API 调用过程:
用户点击 "获取 API Keys"
│
▼
┌─ 浏览器 ─────────────────────────────────────────────┐
│ SDK 构造请求: │
│ GET http://backend:8000/api/v1/api-keys/ │
│ │
│ clientFetch() 拦截: │
│ 1. 检测是浏览器环境 → 走 clientFetch │
│ 2. 剥离 API_URL 前缀 → api/v1/api-keys/ │
│ 3. 拼接代理路径 → /api/client-proxy/api/v1/api-keys/ │
│ 4. 发请求到同源地址(无跨域问题) │
└───────────────────────────────────────────────────────┘
│ GET /api/client-proxy/api/v1/api-keys/
▼
┌─ Nginx ───────────────────────────────────────────────┐
│ location /api/client-proxy/ → frontend:3000 │
│ (必须在 /api/ 前面,否则被后端拦截) │
└───────────────────────────────────────────────────────┘
│
▼
┌─ Next.js 服务器 ──────────────────────────────────────┐
│ [...path]/route.ts (proxyHandler): │
│ 1. path = ['api', 'v1', 'api-keys'] │
│ 2. 拼接 + 补充尾部斜杠(FastAPI 要求) │
│ 3. 转发到 backend:8000/api/v1/api-keys/ │
│ 4. 附带原始 Cookie(serverFetch 注入) │
│ 5. 返回后端响应给浏览器 │
└───────────────────────────────────────────────────────┘
│
▼
┌─ FastAPI 后端 ────────────────────────────────────────┐
│ 收到带 Cookie 的请求 → 认证通过 → 返回数据 │
└───────────────────────────────────────────────────────┘
两条路径的本质差异
| 维度 | /api/client-proxy/... |
/api/v1/... |
|---|---|---|
| 调用方 | 浏览器端 JS(SDK 自动) | 服务端 SSR / curl |
| Nginx 转发到 | frontend:3000(Next.js) | backend:8000(FastAPI) |
| 认证方式 | Next.js 代理注入 Cookie → 转发后端 | 后端直接从请求读取 Cookie |
| 典型结果 | 308 重定向(正常) | 403(未登录,无 Cookie) |
三、关键代码实现
3.1 SDK 请求拦截 —— clientFetch
浏览器的请求在发出前会被 clientFetch 函数拦截和改写:
// hey-api.ts — 核心拦截逻辑
const clientFetch: FetchFunction = async (request) => {
const url = new URL(request.url);
// 剥离后端域名,提取纯 API 路径
const apiPath = url.pathname + url.search;
// 拼接代理路径,让请求走 Next.js 代理
const proxyUrl = `/api/client-proxy${apiPath}`;
// 构造同源请求(无需处理跨域)
return fetch(proxyUrl, {
...request,
url: proxyUrl,
});
};
关键点:通过剥离后端域名并拼接 /api/client-proxy,浏览器发往不同域(如 backend:8000)的请求被改写为同源请求。由于浏览器在同源请求中会自动携带 HttpOnly Cookie,认证信息就自然传递到了 Next.js 服务器。
3.2 服务端请求转发 —— serverFetch
Next.js 服务端(SSR/SSG)不需要走代理,但需要用 serverFetch 将浏览器的 Cookie 手动注入到后端请求中:
// hey-api.ts — 服务端直接调用
const serverFetch: FetchFunction = async (request) => {
const { cookies } = await import('next/headers');
const cookieStore = cookies();
const cookieString = cookieStore.toString();
return fetch(request.url, {
...request,
headers: {
...request.headers,
Cookie: cookieString, // 手动注入浏览器 Cookie
},
});
};
3.3 代理路由处理器 —— route.ts
Next.js App Router 的 catch-all 路由负责接收代理请求并转发:
// app/api/client-proxy/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
const { path } = params;
const apiPath = path.join('/');
// 拼接后端地址 + 补充尾部斜杠(FastAPI 路由要求)
const backendUrl = `${process.env.API_URL}/api/v1/${apiPath}/`;
// 转发原始请求头(包含 Cookie)到后端
const response = await fetch(backendUrl, {
headers: {
'Content-Type': 'application/json',
Cookie: request.headers.get('cookie') || '',
},
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
}
3.4 环境判断逻辑
SDK 自动判断当前运行环境,选择合适的请求策略:
// 伪代码体现核心判断逻辑
export function createClient() {
const fetchFn = typeof window === 'undefined'
? serverFetch // SSR/服务端:直接调后端 + 注入 Cookie
: clientFetch; // 浏览器:走 /api/client-proxy/ 代理
return new ApiClient({ fetch: fetchFn });
}
四、与传统 React 方案的架构对比
方案 A:传统 React(简单但不够安全)
浏览器 localStorage 存 token
│
│ Authorization: Bearer xxx ← JS 手动附加 Header
▼
backend:8000 ← 直接跨域调用(需配置 CORS)
- Token 存在
localStorage,JavaScript 可读可写 - 每次请求手动在 Header 里添加
Authorization: Bearer xxx - 实现简单直接,但 XSS 攻击可轻易窃取 token
方案 B:本项目方案(安全但多一层代理)
浏览器 Cookie (HttpOnly) ← JS 完全无法读取!
│
│ Cookie 自动附带(同源请求)
▼
Next.js 服务器 ──→ 转发 + 注入 Cookie ──→ backend:8000
(/api/client-proxy/)
- JWT 存储在 HttpOnly Cookie 中,浏览器自动发送但 JS 无法读取
- XSS 攻击无法窃取 token(安全性大幅提升)
- 代价:浏览器 JS 无法直接带 Cookie 跨域调后端 → 必须通过 Next.js 代理转发
对比总结
| 维度 | 传统 React | 本项目 |
|---|---|---|
| Token 存储 | localStorage(JS 可读) |
HttpOnly Cookie(JS 不可读) |
| 浏览器调 API | 直接调后端 + 手动加 Header | 通过 Next.js 代理转发 |
| XSS 防护 | 弱(token 可被窃取) | 强(token 不可读取) |
| 架构复杂度 | 简单 | 多一层代理 |
| CORS 配置 | 必须配置 | 同源请求,无需 CORS |
五、Nginx 关键配置
Nginx 的 location 指令遵循前缀匹配优先级规则。如果配置不当,代理路径会被普通 /api/ 规则拦截。
正确配置顺序:
# ✅ 长前缀规则必须放在短前缀规则前面
location = /health { ... } # 最高优先级(精确匹配)
location /api/client-proxy/ { ... } # 长前缀,先于 /api/ 匹配
location /api/ { ... } # 短前缀
location / { ... } # 最低优先级
如果交换 location /api/ 和 location /api/client-proxy/ 的顺序,所有以 /api/ 开头的请求(包括代理请求)都会被直接转发到后端,而不会经过 Next.js 代理处理。结果就是:代理路径返回 404 Not Found,因为后端根本不存在 /client-proxy/ 这个路由。
六、这个模式的优势与注意事项
优势
- 安全性提升:HttpOnly Cookie 防止 XSS 攻击窃取 Token,这是该模式存在的首要原因
- 同源策略简化:浏览器端所有请求变成同源请求,无需处理跨域问题
- 关注点分离:SDK 层自动判断环境并选择策略,业务开发者无需关心请求路径
- SSR 兼容:
serverFetch保证服务端渲染时也能正确携带认证信息
注意事项
- 请求延迟增加:多了一层代理转发,每次客户端请求增加一次服务端 → 后端的网络往返
- Nginx 配置敏感:
location顺序必须正确,否则整个代理机制失效且难以排查 - 调试需要区分场景:同样一个 API 接口,浏览器调用和 SSR 调用走的是不同的代码路径,排查问题时要先确认运行环境
- Next.js 服务器压力:所有浏览器请求都经过 Next.js 服务端转发,高并发场景下需要关注服务端资源消耗
- Cookie 域名限制:HttpOnly Cookie 的 Domain 属性必须覆盖前后端域名,否则代理转发时 Cookie 无法传递
七、总结
/api/client-proxy/ 不是一个无意义的中间层,而是 HttpOnly Cookie 认证方案下必然产生的架构代价。它在"安全性"和"简单性"之间做出了明确的选择——用一层代理换取了 Token 不被 JavaScript 读取的安全保障。
理解这一模式的关键是认清一个事实:当认证信息存储在 HttpOnly Cookie 中时,浏览器端的 JavaScript 根本无法直接调用跨域后端 API。这不是 Next.js 的限制,而是浏览器安全模型的设计原则。
如果你的项目对安全性要求不高,传统的 localStorage + Authorization Header 方案依然有效且更简单。但如果安全性是优先级更高的考量,/api/client-proxy/ 代理模式是一个值得采纳的成熟方案。
延伸思考:这种代理模式本质上是一种 BFF(Backend For Frontend)模式的轻量实现——前端服务器充当中间层,处理认证、聚合等后端不关心的横切关注点。在微服务架构中,BFF 层通常会承担更多职责,而本项目中的代理层则是 BFF 思想的精简应用。