ResumeJSON

Resume parser React: parse an uploaded CV in a Next.js app

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.

WhereWhat you getThe catch
In the browser, with a PDF readerPlain text from a text-based PDFNo fields, no .docx, nothing for a scanned page
In your server route, with a parsing APITyped fields from PDF, DOCX, plain text and scansA key to protect, one network hop
In a background job after uploadFields, laterThe 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 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 routeHTTP status upstreamWhat to tell the applicant
too_large413The file is over the size limit. Try a smaller export.
unsupported_type415Upload a PDF, a Word file, plain text or a photo of the page.
unreadable_file422We could not read this file. Try saving it as a PDF again.
not_a_resume422This does not look like a CV.
upstream_unavailable503Busy for a moment. Try again. This one is worth one automatic retry.
http_429429Your quota is used up. This is yours to fix, not the applicant's.
http_403403Your subscription is not active. Also yours to fix.
unreachablenoneCould 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:

PlanPriceParses
Basic$0100 a month, a hard cap
Pro$0Pay per use, $0.05 a parse
Ultra$29 a month1,000 a month
Mega$99 a month5,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:

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

  1. The key lives in a server-only variable. Search your built client bundle for it once.
  2. The route forwards the original file, not text you extracted.
  3. Every state has its own message, including the one where the parse worked and half the fields are empty.
  4. The input is disabled while parsing, so two uploads cannot race.
  5. A failed parse leaves the form usable by hand.
  6. 403 and 429 reach you, in a log you read, not the applicant.

All articles