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 函数之前就已经运行。它的处理顺序是:

  1. HTTP 请求到达 Next.js 服务器
  2. Body parser 读取 Content-Length,对比 bodySizeLimit
  3. 超过限制→直接返回 413,不调用 Server Action
  4. 未超过→解析请求体,传给 Server Action 函数

这意味着 Server Action 内部的 try-catch 拦截不到 413——错误出在框架层,函数体未被调用。

Next.js 15 的默认限制是 1MB1048576 字节)。错误信息里写的 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 的请求。

排查步骤:

  1. 检查报错路由文件顶部有没有 export const runtime = 'edge'
  2. 有→改成 export const runtime = 'nodejs',或直接删掉让它默认走 Node.js
  3. 确认 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.bodySizeLimit1MB可调,最大受平台内存限制
Server Action(Edge Runtime)Vercel/平台基础设施~4.5MB不可调,硬限制
Route Handler(Node.js)路由中 export const config = { api: { bodyParser: false } }无默认(手动读流)完全自定义
MiddlewareEdge Runtime~4.5MB不可调

上传大文件:什么时候该绕过 Server Action?

如果你的 SaaS 涉及图片上传、PDF 处理、CSV 导入这类场景,最佳路径不是把 bodySizeLimit 调到 20MB,而是让大文件不经过 Server Action

直接调整 bodySizeLimit 的风险:

  • 单次 HTTP 超时概率增大(Vercel Hobby 计划 10 秒、Pro 计划最长 300 秒)
  • 服务器内存压力上升
  • 同一请求失败后整个 payload 重传,用户体验差

替代方案:预签名 URL 直传。

以 Cloudflare R2 为例(S3 兼容,零出网费用):

  1. Server Action 或 Route Handler 生成一个预签名上传 URL
  2. 前端直接用这个 URL 把文件 PUT 到 R2
  3. 上传成功后把 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。

按以下顺序排查,优先做最少改变量的测试:

  1. 确认 Runtime:在报错的 Server Action 文件顶部加 export const runtime = 'nodejs',重新部署看是否解决
  2. 看 Vercel 日志的 function 类型:Vercel Dashboard → Functions,看报错函数是 Edge 还是 Serverless(Node.js)
  3. 对比本地和生产 bodySizeLimit 生效情况:curl 一个 2MB 以内的请求到生产环境,确认配置本身是生效的;再逐步增大 payload 看断点在哪
  4. 排除反向代理限制:如果你在 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 回客户端。