Resume parser on Vercel: beating the 4.5 MB request limit
Published
To run a resume parser on Vercel, call a parsing API from a route handler and treat two platform limits as part of the design. A Vercel Function rejects any request body over 4.5 MB, and it ends any invocation that runs past its maximum duration. Most text-based CVs fit under both. A phone photo of a printed resume, or a scanned PDF, often does not, and that is the file your applicant has when they are in a hurry.
This article builds the route, then shows what to change when a file is too big for it. We build ResumeJSON, the parser the route calls, so read it as written by an interested party. The structure works with any parsing API that takes a file and returns JSON. If you want the React component that sits in front of this route, Resume parser React: parse an uploaded CV in a Next.js app covers it. This one is about what happens on the platform.
Why Vercel changes the design
A resume parser is a function that takes a file and returns fields. Nothing about that is specific to a host. What is specific is that your route sits between the applicant's browser and the parser, so both of the route's limits apply to every upload.
As of October 2026, Vercel's documentation for Functions lists these:
| Limit | Value | What it does to a CV upload |
|---|---|---|
| Request body | 4.5 MB | Larger uploads fail with 413: FUNCTION_PAYLOAD_TOO_LARGE before your code runs |
| Maximum duration, Hobby | 300 seconds, default and maximum | A parse that hangs ends in a 504 |
| Maximum duration, Pro and Enterprise | 300 seconds default, 800 seconds maximum | Same, with more room |
The duration is rarely the problem for a parse. The size is. A one-page PDF exported from a word processor is usually a few hundred kilobytes. A photo taken on a current phone is commonly several megabytes, and a multi-page scan can be larger still. The parser itself accepts files up to 20 MB, so on Vercel the platform is the first thing to refuse them.
Decide early which of two designs you are building.
- Direct upload through your route. Simple, one hop, and capped at 4.5 MB per file.
- Upload to storage first, parse from there. Handles large files, adds one step and one more thing to configure.
If your applicants submit PDFs they exported, start with the first. If they photograph paper, plan for the second.
The route handler
This is the direct version. It reads the file, forwards it unchanged, sets its own duration, and turns every failure into data your front end can switch on.
// app/api/parse-cv/route.ts
export const maxDuration = 60
export async function POST(request: Request) {
const incoming = await request.formData()
const file = incoming.get('file')
if (!(file instanceof File)) {
return Response.json({ ok: false, reason: 'no_file' }, { status: 400 })
}
const form = new FormData()
form.set('file', file, file.name)
let upstream: Response
try {
upstream = await fetch('https://resumejson-resume-cv-parser-api.p.rapidapi.com/v1/parse', {
method: 'POST',
headers: {
'x-rapidapi-key': process.env.RAPIDAPI_KEY!,
'x-rapidapi-host': 'resumejson-resume-cv-parser-api.p.rapidapi.com'
},
body: form,
signal: AbortSignal.timeout(50_000)
})
} catch {
return Response.json({ ok: false, reason: 'unreachable' }, { status: 502 })
}
const body = await upstream.json().catch(() => null)
if (upstream.ok && body?.resume) {
return Response.json({ ok: true, resume: body.resume })
}
const code = body?.error?.code ?? `http_${upstream.status}`
return Response.json({ ok: false, reason: code })
}Four details carry the weight:
maxDurationis exported from the route file. In the Next.js App Router that is how you set it, as the Vercel docs show. Setting it explicitly means a slow upstream cannot hold the function open for the platform default.- The fetch timeout is shorter than
maxDuration. If the platform ends the function first, the applicant gets a bare504page with no reason. If your own timeout fires first, your code runs thecatchand answers with a reason. - The key is a server-side environment variable. Add it in the project settings in the Vercel dashboard, without any
NEXT_PUBLIC_prefix, so it never reaches the browser bundle. - The original bytes are forwarded. A scanned CV has no text layer to extract in your function, and the parser can read the page itself.
You can try the same call with no key and no code on the free browser tool, which has smaller limits than the paid API.
The 413 you will not see in your logs
A request over 4.5 MB is refused by the platform before your handler starts. Your try and catch never run, and nothing in your own error handling records it. The applicant sees a failed upload and you see nothing.
So check the size in the browser, before you send, and say what is going on:
const VERCEL_BODY_LIMIT = 4.5 * 1024 * 1024
function checkSize(file: File): string | null {
if (file.size > VERCEL_BODY_LIMIT) {
return 'That file is over 4.5 MB. Try exporting it as a PDF, or a smaller photo.'
}
return null
}This check is a courtesy. It tells the applicant what to do instead of leaving them at a spinner, and it lets no large file through. If large files are part of your real traffic, use the next section.
Large files: upload to storage first
Vercel documents a way around the body limit for Blob storage: client uploads, where the browser sends the file straight to storage and your function only signs a token. The @vercel/blob package provides a handleUpload helper for the token route and an upload call for the browser. Vercel's own guide to bypassing the 4.5 MB limit walks through both steps.
The flow for a CV then has four stages:
- The browser asks your token route for permission and uploads the file to Blob.
- The upload finishes and the browser receives the stored file's URL.
- The browser calls your parse route with that URL, not with the file.
- The parse route reads the file from storage, forwards it to the parser, and returns the fields.
The parse route changes in one place. Instead of reading request.formData(), it reads a URL and fetches the file:
const { url } = await request.json()
const stored = await fetch(url)
if (!stored.ok) {
return Response.json({ ok: false, reason: 'stored_file_missing' })
}
const blob = await stored.blob()
const form = new FormData()
form.set('file', blob, 'resume')Everything after that line is the same as the direct route. Two things to decide on the way:
- Authenticate the token route. Vercel's guide warns that the token handler should check who is asking before it issues a token. An open one lets anyone store files in your account.
- Think about retention. A stored CV is personal data sitting in your storage after the parse has finished. Delete it once you have the fields, or say how long you keep it in your privacy policy.
If your site only ever receives small PDFs, you can skip this whole section and keep the direct route.
Every way it can end
The route will see these reasons, as listed in the ResumeJSON docs as of October 2026. Tell the applicant something different for each, and keep the manual form available for all of them.
| Reason | What happened | What to tell the applicant |
|---|---|---|
too_large | Over the parser's 20 MB limit | The file is too large. Try a smaller export. |
unsupported_type | Not a PDF, Word file, text or image | Upload a PDF, a Word file or a photo of the page. |
unreadable_file | The file could not be read | Save it as a PDF again and retry. |
not_a_resume | The document is not a CV | That does not look like a CV. |
upstream_unavailable | The parser was briefly busy | Try again. Worth one automatic retry. |
http_403 or http_429 | Your subscription or quota | Yours to fix. Log it, do not blame the applicant. |
unreachable | Your function could not reach the parser | Try again in a moment. |
Retry the transient reasons and report the definitive ones. One retry on upstream_unavailable or unreachable is cheap. A 422 returns the same answer again, only slower, and each attempt counts against your quota.
When a Vercel route is more than you need
Be honest about which of these applies:
- You only need the text of a PDF. A PDF text reader inside the function is enough, with no API and no key.
- The CVs may not leave your infrastructure. A compliance rule outranks convenience. Keep extraction in-house.
- You parse a handful of CVs in one known template. A regular expression does the job.
- You parse in batches after the fact. A script run from your own machine or a queue worker avoids both platform limits, and Bulk resume parsing: how to run a CV backlog covers that case.
For anything else, the route above is the integration. To decide what the form around the upload should collect, see Resume upload form: build one that collects CVs you can use.
Checklist before you deploy
maxDurationis set in the route file and your fetch timeout is shorter than it.- The key is in the project's environment variables, with no
NEXT_PUBLIC_prefix. - The browser checks file size against 4.5 MB before it sends, or you use Blob client uploads.
- Every reason above has its own message, and the last one still leaves the manual form working.
- A failed parse still lets the applicant apply. The parse only saves them typing.
- You have tested with a phone photo, not only with a PDF you exported yourself. That one test finds the size problem.
Platform limits are quoted from Vercel's documentation as of October 2026. Check the current figures before you rely on them, because plans and limits change.