Next.js 15 把 Server Action 作为全栈数据流的主通道,但文件上传、Excel 导入、大 JSON 批量操作这三类场景一踩一个坑。后台日志只有一句 Body exceeded 2mb limit,Server Action 内部的 try-catch 像没写一样——因为那行代码根本没机会执行。
这个问题在一个人维护 SaaS 时尤其烦人。运营同事急着导入 5MB 的 CSV,你只在 Vercel 面板看到 413,找不到任何堆栈信息。下面按「为什么抓不住→配置怎么调→Vercel 生产环境差异→超过 10MB 怎么绕」来拆。
bodySizeLimit 为什么挡在 Server Action 之前?
Next.js 的 body parser 在请求到达 Server Action 函数之前就已经运行。它的处理顺序是:
- HTTP 请求到达 Next.js 服务器
- Body parser 读取 Content-Length,对比 bodySizeLimit
- 超过限制→直接返回 413,不调用 Server Action
- 未超过→解析请求体,传给 Server Action 函数
这意味着 Server Action 内部的 try-catch 拦截不到 413——错误出在框架层,函数体未被调用。
Next.js 15 的默认限制是 1MB(1048576 字节)。错误信息里写的 2mb 来自 bytes 库的字符串格式化,不代表实际阈值。你可以在 next.config.ts 中显式调大:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
serverActions: {
bodySizeLimit: '10mb',
},
}
export default nextConfig
但仅有这段配置还不够——生产环境的坑往往在 Runtime 上。
在 Vercel 上调了配置还报 413,是 Edge Runtime 的锅吗?
可能性很高。Vercel 上的 Edge Runtime 对请求体有硬性限制:约 4.5MB。这个限制来自平台基础设施,不是 Next.js 能控制的。
更隐蔽的是,很多独立开发者用 Vercel 默认部署时,没有显式设 Runtime,但项目里某处写了:
export const runtime = 'edge'
这会把整个路由切到 Edge Runtime,bodySizeLimit 配置只影响 Node.js Runtime 下的 body parser。Edge 环境下,Vercel 在到达你的代码之前就能拒绝超 4.5MB 的请求。
排查步骤:
- 检查报错路由文件顶部有没有
export const runtime = 'edge' - 有→改成
export const runtime = 'nodejs',或直接删掉让它默认走 Node.js - 确认
vercel.json没额外限制:
{
"functions": {
"app/api/**/*.js": {
"memory": 3008,
"maxDuration": 60
}
}
}
这里还有一个经常被忽略的点:Server Action 和 Route Handler(app/api/xxx/route.ts)用不同的 body parser。Route Handler 不受 serverActions.bodySizeLimit 控制,需要在路由内手动处理请求流。
| 场景 | 配置位置 | 默认上限 | 能否调整 |
|---|---|---|---|
| Server Action(Node.js Runtime) | next.config.ts 中的 serverActions.bodySizeLimit | 1MB | 可调,最大受平台内存限制 |
| Server Action(Edge Runtime) | Vercel/平台基础设施 | ~4.5MB | 不可调,硬限制 |
| Route Handler(Node.js) | 路由中 export const config = { api: { bodyParser: false } } | 无默认(手动读流) | 完全自定义 |
| Middleware | Edge Runtime | ~4.5MB | 不可调 |
上传大文件:什么时候该绕过 Server Action?
如果你的 SaaS 涉及图片上传、PDF 处理、CSV 导入这类场景,最佳路径不是把 bodySizeLimit 调到 20MB,而是让大文件不经过 Server Action。
直接调整 bodySizeLimit 的风险:
- 单次 HTTP 超时概率增大(Vercel Hobby 计划 10 秒、Pro 计划最长 300 秒)
- 服务器内存压力上升
- 同一请求失败后整个 payload 重传,用户体验差
替代方案:预签名 URL 直传。
以 Cloudflare R2 为例(S3 兼容,零出网费用):
- Server Action 或 Route Handler 生成一个预签名上传 URL
- 前端直接用这个 URL 把文件 PUT 到 R2
- 上传成功后把 file key 或 URL 传回 Server Action,写进数据库
// app/api/upload-url/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
export async function POST() {
const R2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
})
const key = `uploads/${Date.now()}-${crypto.randomUUID()}`
const url = await getSignedUrl(
R2,
new PutObjectCommand({ Bucket: 'my-bucket', Key: key }),
{ expiresIn: 3600 }
)
return Response.json({ url, key })
}
这样文件流完全不经过 Next.js 服务器,bodySizeLimit 不再是瓶颈。
低于 10MB 但本地正常、Vercel 报 413 怎么定位?
这类情况通常是本地和生产的 Runtime 不一致造成的。本地 next dev 默认跑在 Node.js Runtime,而 Vercel 部署时可能自动匹配了 Edge。
按以下顺序排查,优先做最少改变量的测试:
- 确认 Runtime:在报错的 Server Action 文件顶部加
export const runtime = 'nodejs',重新部署看是否解决 - 看 Vercel 日志的 function 类型:Vercel Dashboard → Functions,看报错函数是 Edge 还是 Serverless(Node.js)
- 对比本地和生产 bodySizeLimit 生效情况:curl 一个 2MB 以内的请求到生产环境,确认配置本身是生效的;再逐步增大 payload 看断点在哪
- 排除反向代理限制:如果你在 Next.js 前面有 Cloudflare Proxy、Nginx 或 CDN,检查它们各自的 body 上限(Cloudflare 免费计划为 100MB,但 Enterprise 可调)
部署在非 Vercel 平台(Docker 自托管、Railway、Fly.io)通常不受 Edge Runtime 限制。如果你选择自托管运行 Next.js 以绕过平台限制,需要考虑服务器所在地的网络稳定性。
如果团队管理多个部署环境,可以给核心后台操作固定一个稳定的部署通道,并使用独立开发者出海稳定专线承载 Vercel 与 R2 之间的数据同步。
客户端怎么优雅处理 413 而不是崩白屏?
Server Action 在客户端调用时可以捕获到 HTTP 状态码。React 19 的 useActionState 提供了比较干净的入口:
'use client'
import { useActionState } from 'react'
import { uploadFile } from './actions'
export function UploadForm() {
const [state, formAction, isPending] = useActionState(uploadFile, null)
return (
<form action={formAction}>
<input type="file" name="file" />
<button disabled={isPending}>上传</button>
{state?.error && <p class="text-red-600">{state.error}</p>}
</form>
)
}
Server Action 端:
'use server'
export async function uploadFile(prevState: any, formData: FormData) {
try {
const file = formData.get('file') as File
// ...处理上传
return { success: true }
} catch (e: any) {
return { error: '上传失败,请检查文件大小是否超过上限。' }
}
}
但这里的 try-catch 仍然抓不到 413。真正可靠的做法是在客户端 fetch 层面判断:
async function safeServerAction(action: Function, ...args: any[]) {
try {
return await action(...args)
} catch (e: any) {
if (e?.statusCode === 413 || e?.message?.includes('Body exceeded')) {
return { error: '文件超过服务器处理上限(1MB),请压缩后重试或联系管理员。' }
}
throw e
}
}
前端再加一道文件大小预检,在提交前就拦截明显超标的文件:
const MAX_BYTES = 1_000_000 // 1MB,与服务器配置保持一致
if (file.size > MAX_BYTES) {
setError(`文件大小 ${(file.size / 1024).toFixed(0)}KB 超过限制,请压缩到 1MB 以内`)
return
}
Server Action 返回 FormData 被二次累加怎么办?
社区提交过一个容易踩的坑:Server Action 校验失败后把整个 FormData 重新返回给前端(用于回填表单字段),下次提交时客户端把返回的 FormData 和新追加的文件拼在一起,请求体翻倍,即使单个文件不超限也会触发 413。
避免方式很简单:Server Action 的返回值只用纯对象({ error: string; fields: Record<string, string> }),不传 FormData 回客户端。