The Problem
Built a dashboard for a client where customers upload signed PDFs through a Server Action. Worked locally with files up to 30 MB. Pushed to Vercel, ran a smoke test with a 6 MB contract, and the browser network tab returned this:
POST /dashboard/contracts 413 Content Too Large
{
"error": "FUNCTION_PAYLOAD_TOO_LARGE",
"message": "Request body exceeded the function size limit."
}
The error fired before any of my code ran. There was no log entry in the Vercel function dashboard, no Sentry event, no try/catch ever caught it. The Server Action function showed zero invocations for that route. The only signal was the 413 in the browser and a single line in the Vercel build inspector under "Edge Network" that read body exceeded 4 MB limit.
Worst part: I had already set bodySizeLimit in next.config.ts:
// next.config.ts
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
}
So the limit was raised in Next.js. The platform was still rejecting before the framework got a turn.
Why It Happens
A Server Action upload travels through two body-size gates, and most write-ups only mention the first one.
The first gate is experimental.serverActions.bodySizeLimit. That setting tells Next.js how much body the Server Action handler will accept. Default is 1mb. If you do not set it, the framework returns a 413 itself, and you see a Next.js error page. Once you raise it, Next.js will accept whatever you allow.
The second gate is the Vercel platform limit. Serverless Functions on Vercel cap the incoming request body at 4.5 MB on the Hobby plan and 4.5 MB on Pro by default, and the Edge runtime caps at the same number. This is enforced by the Vercel routing layer before the function is invoked. Raising bodySizeLimit in next.config.ts does not change the platform limit, because the platform never gets a chance to read your Next.js config.
So you can have a perfectly configured Next.js app that still cannot accept a 6 MB upload through a Server Action, because the request is rejected upstream. The Vercel limits documentation explicitly lists the cap, but the error message in production says "function payload" which most people read as a Next.js error and start tuning the wrong knob.
The third wrinkle is that this is platform-locked. There is no Vercel project setting that raises the body limit above the platform default. You can pay for a larger function size, more memory, longer timeout, but the body cap is fixed. The only way to ship larger uploads from the browser through a Vercel-hosted app is to keep the body off the function entirely.
The Fix
Two production patterns. The right choice depends on whether you control the storage destination.
Pattern 1: Direct-to-storage uploads with a signed URL. The Server Action mints a pre-signed upload URL, the browser sends the file straight to S3 or Vercel Blob, and the function only handles metadata. This is the only pattern that scales past the platform limit for any file size.
// app/dashboard/contracts/actions.ts
'use server'
import { put } from '@vercel/blob'
import { generateUploadUrl } from '@vercel/blob/client'
export async function getUploadToken(filename: string, contentType: string) {
const allowed = ['application/pdf', 'image/png', 'image/jpeg']
if (!allowed.includes(contentType)) {
throw new Error('Unsupported file type')
}
return generateUploadUrl({
pathname: `contracts/${crypto.randomUUID()}-${filename}`,
contentType,
addRandomSuffix: false,
maximumSizeInBytes: 50 * 1024 * 1024,
})
}
export async function recordContract(blobUrl: string, customerId: string) {
return db.contracts.create({
data: { url: blobUrl, customerId, uploadedAt: new Date() },
})
}
The client component calls getUploadToken, uploads to the returned URL with fetch, then calls recordContract with the resulting blob URL. The largest body the function ever sees is the URL string. The browser side is straightforward:
'use client'
import { getUploadToken, recordContract } from './actions'
export function UploadButton({ customerId }: { customerId: string }) {
async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const { url, headers } = await getUploadToken(file.name, file.type)
const upload = await fetch(url, { method: 'PUT', body: file, headers })
if (!upload.ok) throw new Error('Upload failed')
await recordContract(upload.url, customerId)
}
return <input type="file" accept="application/pdf" onChange={onChange} />
}
Validate the content type on the server, never trust the client-supplied one for storage rules. The signed URL has an expiry so you do not have to track tokens.
Pattern 2: Move the Server Action onto a Node runtime with the bodySizeLimit raised, only useful for self-hosted. If you are not on Vercel and your reverse proxy lets larger bodies through, you can stay with the Server Action body itself. The Next.js settings still matter:
// next.config.ts
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '25mb',
},
},
}
export default nextConfig
If you are behind Nginx, raise client_max_body_size to match. If you are behind a managed proxy that you cannot reconfigure, you are back at Pattern 1.
Verify it before you ship. Run the upload against the production URL with a file just over the platform limit:
curl -i -X POST https://your-app.vercel.app/dashboard/contracts \
-F "file=@./test-fixture-6mb.pdf"
If the response is 200 OK, the new path works. If it is 413, the request never reached your function and your client code is still posting the file body to the Server Action rather than to storage. Double check that the action signature accepts a URL string, not a FormData containing the file.
The Lesson
bodySizeLimit in next.config.ts controls Next.js. It does not control Vercel. The platform body limit is fixed and lives in front of your function. For uploads above a few megabytes, do not push the file through a Server Action at all. Mint a signed URL, upload the file directly to storage, then call a small Server Action with the resulting URL. The function stays well under the limit, your error rate goes to zero, and you do not have to rebuild this layer when you move hosts later.
If your Next.js upload flow on Vercel is failing with payload errors and you need it shipped today, that is what I get paid for. See my services. For a related Server Action gotcha after the Next.js 16 upgrade, read Next.js 16 server actions invalid error.
Server Actions failing on production uploads? Get it shipped.