Resume parser React: parse an uploaded CV in a Next.js app
Published
If you want a resume parser in a React app, put the parser behind one server route and keep React for the upload and the result. The component sends the file to your own endpoint, the endpoint forwards it to a parsing API with your key, and the component renders typed fields: name, email, phone, work history, education, skills. There is no React library that turns a CV into fields on its own, and the browser is the wrong place to hold the key for one that does.
This article builds that in about sixty lines: a Next.js route handler, a component with a state for every way the upload can end, and a short list of the errors you will meet. We build ResumeJSON, the API the route calls, so read this as written by an interested party. The component works with any parser that returns JSON.
Where the parsing should run
There are three places a resume can be parsed in a React app, and they are not equal.
| Where | What you get | The catch |
|---|---|---|
| In the browser, with a PDF reader | Plain text from a text-based PDF | No fields, no .docx, nothing for a scanned page |
| In your server route, with a parsing API | Typed fields from PDF, DOCX, plain text and scans | A key to protect, one network hop |
| In a background job after upload | Fields, later | The user waits for an email instead of a form that fills |
For the first row, pdfjs-dist is the usual reader, at version 6.4.299 on the npm registry as of October 2026. It returns text with positions. Turning that into "this line is the employer, that one is the end date" is the part you would still be writing, and it is the part that breaks on two-column layouts. If plain text is all you need, a reader in the browser is a fine answer and you can stop here.
For fields, use the second row. The reasons are practical:
- The key stays on the server. Anything in a client component ships to the browser, including
NEXT_PUBLIC_variables. A parsing key in the bundle is a key anyone can copy. - One format in, one shape out. The route accepts whatever the applicant uploads and returns one JSON shape, so your component never branches on file type.
- You can log and rate-limit. A route you own can refuse a fifth upload from one session in a minute, which a direct browser call cannot.
The server route
In the App Router this is a route handler. It reads the uploaded file, forwards the bytes unchanged, and turns every failure into a small answer your component can switch on.
// app/api/parse-cv/route.ts
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(60_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 })
}
// Branch on the error code when there is one. A gateway refusal has none.
const code = body?.error?.code ?? `http_${upstream.status}`
return Response.json({ ok: false, reason: code }, { status: 200 })
}Three choices in there are deliberate. The key comes from process.env without the NEXT_PUBLIC_ prefix, so it never reaches the client. The upload is forwarded as the original bytes, because a scanned CV has no text layer for you to extract and the parser can read the page instead. And a parse that failed comes back as ok: false with a reason, rather than as an exception, so the component handles it as data.
Check your host's request-body limit before you promise applicants a file size. The parser accepts uploads up to 20 MB, and a hosting platform in front of your route may accept less.
The component
The part React developers get wrong is the state. A parse takes a couple of seconds, can fail in several ways, and a form that shows nothing during any of those looks broken. Model it as one value with one name per state, so a state you forgot is a type error rather than a blank screen.
'use client'
import { useState } from 'react'
type Resume = {
basics: { full_name: string | null; email: string | null; phone: string | null; location: string | null }
work: { company: string | null; title: string | null; is_current: boolean }[]
skills: string[]
}
type State =
| { status: 'idle' }
| { status: 'parsing'; fileName: string }
| { status: 'done'; resume: Resume }
| { status: 'failed'; reason: string }
export function CvUpload({ onParsed }: { onParsed: (r: Resume) => void }) {
const [state, setState] = useState<State>({ status: 'idle' })
async function pick(file: File) {
setState({ status: 'parsing', fileName: file.name })
const body = new FormData()
body.set('file', file)
try {
const res = await fetch('/api/parse-cv', { method: 'POST', body })
const out = await res.json()
if (out.ok) {
setState({ status: 'done', resume: out.resume })
onParsed(out.resume)
} else {
setState({ status: 'failed', reason: out.reason })
}
} catch {
setState({ status: 'failed', reason: 'unreachable' })
}
}
return (
<div>
<input
type="file"
accept=".pdf,.docx,.txt,image/*"
disabled={state.status === 'parsing'}
onChange={(e) => e.target.files?.[0] && pick(e.target.files[0])}
/>
{state.status === 'parsing' && <p>Reading {state.fileName}…</p>}
{state.status === 'done' && (
<p>Read {state.resume.basics.full_name ?? 'this CV'}. Check the fields below.</p>
)}
{state.status === 'failed' && <p role="alert">{messageFor(state.reason)}</p>}
</div>
)
}Two details matter more than they look. disabled while parsing stops a second upload racing the first and landing its result over the newer one. And onParsed hands the result up to the form that owns the inputs, which is where it belongs: the upload component should not know which fields your form has. The mapping from parsed fields to inputs is the same job described in job application autofill: prefill your form from an uploaded CV, including the rule to leave a field empty when the parse returned null.
Every way it can end
The failed state needs real messages. A single "something went wrong" sends the applicant to retry a file that will never parse. These are the reasons your route will see, as listed on the ResumeJSON docs as of October 2026:
| Reason from the route | HTTP status upstream | What to tell the applicant |
|---|---|---|
too_large | 413 | The file is over the size limit. Try a smaller export. |
unsupported_type | 415 | Upload a PDF, a Word file, plain text or a photo of the page. |
unreadable_file | 422 | We could not read this file. Try saving it as a PDF again. |
not_a_resume | 422 | This does not look like a CV. |
upstream_unavailable | 503 | Busy for a moment. Try again. This one is worth one automatic retry. |
http_429 | 429 | Your quota is used up. This is yours to fix, not the applicant's. |
http_403 | 403 | Your subscription is not active. Also yours to fix. |
unreachable | none | Could not reach the parser. Try again. |
function messageFor(reason: string): string {
switch (reason) {
case 'too_large': return 'That file is too large. Try a smaller export.'
case 'unsupported_type': return 'Upload a PDF, a Word file, plain text or a photo of the page.'
case 'unreadable_file': return 'We could not read that file. Try saving it as a PDF again.'
case 'not_a_resume': return 'That does not look like a CV.'
case 'upstream_unavailable':
case 'unreachable': return 'The parser is busy. Try again in a moment.'
default: return 'We could not parse that file. You can fill the form by hand.'
}
}Retry the transient ones and report the definitive ones. A 503 or an unreachable upstream is worth one retry with a short delay. A 422 will give the same answer again, only slower. The last line of messageFor matters as much as the rest: whatever happens, the form still works by hand, so a failed parse costs the applicant a minute and not the application.
Two of those rows are not the applicant's problem at all. A 403 and a 429 mean your own account needs attention, so log them somewhere you will see them. Showing them to the applicant as "try again" hides a fault that will repeat on every upload.
What it costs to run
The API is metered per parse on RapidAPI. As of October 2026 the plans are:
| Plan | Price | Parses |
|---|---|---|
| Basic | $0 | 100 a month, a hard cap |
| Pro | $0 | Pay per use, $0.05 a parse |
| Ultra | $29 a month | 1,000 a month |
| Mega | $99 a month | 5,000 a month |
A parse that fails still counts as a request at the gateway, so a component that retries a 422 automatically spends your quota for nothing. That is one more reason to retry only the 503.
You can try the parser with no key and no code on the free browser tool, which has smaller limits than the paid API.
When you do not need any of this
Be honest about which of these you are building:
- You only need text. A search box over uploaded CVs, or text you feed to your own model, needs a PDF reader and nothing else. Skip the API.
- The file may not leave your infrastructure. A compliance rule beats convenience. Keep extraction in-house and budget for the heuristics.
- You have a few CVs in one known template. A regular expression over the text will do. Do not build an upload pipeline for eleven documents.
- You are displaying a CV, not reading it. Viewer components such as
react-pdfshow a PDF page in your app. They display the document and do not parse it.
For everything else, the route and the component above are the whole integration. If your stack is Node without React, Resume parser Node.js: the npm packages that work, and the code covers the same ground on the server side, and Resume upload form: build one that collects CVs you can use covers what to collect around the upload itself.
Checklist before you ship
- The key lives in a server-only variable. Search your built client bundle for it once.
- The route forwards the original file, not text you extracted.
- Every state has its own message, including the one where the parse worked and half the fields are empty.
- The input is disabled while parsing, so two uploads cannot race.
- A failed parse leaves the form usable by hand.
- 403 and 429 reach you, in a log you read, not the applicant.