AI Resume Reviewer

Build a Next.js app that uses Claude to analyze resumes against job descriptions.

Introduction

30 Second Summary

You have spent hours tailoring your resume to a job posting, but you never quite know if it will make it past the automated screening software. What if you could get instant, specific feedback on exactly what to fix before you hit submit?

In this project, you will build a full-stack AI Resume Reviewer using Next.js and the Claude API. You will upload a resume, paste a job description, and get back a structured analysis with scores, missing keywords, and optimized bullet points you can copy straight into your resume.

What You'll Build

You upload your resume, paste a target job description, and within seconds a polished dashboard appears showing your ATS score, skill gaps, and rewritten bullet points tailored to the role.

By the end of this project, you'll have:

  • A working web app where you drop in a PDF or DOCX resume with a job description and see a color-coded ATS compatibility score, matched and missing skill chips, and specific strengths and weaknesses.
  • Hands-on experience with prompt engineering that makes Claude return reliable structured JSON, powering every visual section of your results dashboard without manual parsing.
  • A polished results page with AI-generated optimized resume bullet points you can copy directly into your resume, plus a downloadable JSON file of the full analysis for future reference.
  • Secret Mission: A review history dashboard that saves past analyses so you can track how your resume improves over time.

Are there any prerequisites?

This project assumes you are comfortable with React, TypeScript, and Next.js App Router fundamentals. You do not need prior experience with the Anthropic API or document parsing libraries.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters to you.

Set Up the Project

To build an AI-powered resume reviewer, you need a Next.js project that can handle file uploads, parse documents, and communicate with Claude.

In this step, you will scaffold the project, install the libraries for PDF and DOCX parsing, and set up your Anthropic API key so you can call Claude from your server-side code.

In this step, get ready to:
  • Scaffold a Next.js 16 project with TypeScript and Tailwind CSS.
  • Install document parsing and AI SDK dependencies.
  • Create an Anthropic API key and configure it in your project.
Scaffold the Next.js app and install dependencies

The project runs on Next.js 16 with the App Router, TypeScript, and Tailwind CSS. You also need three packages for document processing and AI.

  • In VS Code, click Terminal in the top menu bar.
  • Click New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest resume-reviewer --ts --tailwind --eslint --app --use-npm

What does this command do?

The create-next-app tool scaffolds a complete Next.js project. The flags pre-select all the options so the command runs without any interactive prompts.

  • --ts enables TypeScript for type-safe code.
  • --tailwind adds Tailwind CSS for utility-first styling.
  • --app uses the App Router (the modern Next.js routing system).
  • --use-npm ensures npm is used as the package manager.

After the scaffolding finishes, you should see a resume-reviewer/ directory listed in the terminal output with files like package.json, app/, and tailwind.config.ts.

  • Move into your new project and install the three key packages by running these commands:
cd resume-reviewer
npm install @anthropic-ai/sdk@0.115.0 pdf-parse@2.4.5 mammoth@1.12.0

What are these packages?

  • @anthropic-ai/sdk is the official TypeScript SDK for calling Claude.
  • pdf-parse extracts text content from PDF files.
  • mammoth converts DOCX (Word) files into plain text.

Pinning exact versions prevents breaking changes. All three packages run server-side only.

  • Confirm the install succeeded by checking the terminal output. You should see a line like "added X packages" with no errors.
  • Verify all three packages appear in your project by running:
cat package.json

Under the dependencies section you should see @anthropic-ai/sdk, pdf-parse, and mammoth listed alongside the Next.js packages.

Install failed or permission errors?

  • Make sure you ran cd resume-reviewer first. Running npm install from the wrong directory will fail.
  • If you see permission errors on macOS, try prefixing the command with sudo.
  • Confirm your Node.js version is 20 or higher by running node --version. Next.js 16 requires Node.js 19+.

Help me troubleshoot my npm install failure.

Create your Anthropic API key

To call Claude from your app, you need an API key from Anthropic. New accounts receive $5 in free credits, which covers approximately 100+ resume reviews.

  • Open https://console.anthropic.com in your browser.
  • Sign up with Google or email (or sign in if you already have an account).
  • Once you are in the Console dashboard, click API Keys in the left sidebar.
  • Click Create Key.
  • Enter resume-reviewer as the key name.
  • Copy the key immediately. It starts with sk-ant- and you will not be able to see it again after closing this dialog.

Why save the key now?

Anthropic only shows the full API key once at creation. After you close the dialog, only a masked version is visible. Copy it before clicking away.

  • Back in your VS Code terminal, create the environment file by running this command (replace your-api-key-here with the key you just copied):
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env.local

What is .env.local?

Next.js automatically reads .env.local and makes its values available as process.env in server-side code. The Anthropic SDK reads ANTHROPIC_API_KEY automatically, so you never hardcode the key in your source files.

This file is already listed in .gitignore by default in Next.js projects, so it will never be committed to version control.

  • Verify the file was created correctly by running:
cat .env.local

You should see ANTHROPIC_API_KEY=sk-ant-... with your actual key value.

Key not working or seeing permission errors?

  • Make sure the file is named .env.local with the leading dot. Without the dot, Next.js will not find it.
  • Check there are no extra spaces around the = sign. The format must be exactly ANTHROPIC_API_KEY=sk-ant-....
  • If you see "Insufficient permissions" in the Anthropic Console, you may need to add a payment method first (even with free credits).

Help me troubleshoot my Anthropic API key setup.

Verify the setup

Everything is in place. Time to confirm the project runs.

Before you run the dev server, what do you expect to see in the browser when you open localhost:3000?

  • Start the development server by running:
npm run dev

You should see the default Next.js welcome page with the Vercel logo and the text "Get started by editing app/page.tsx". The terminal shows "Ready" with the localhost URL.

Page not loading or seeing errors?

  • If port 3000 is already in use, Next.js automatically picks the next available port (3001, 3002, etc.). Check the terminal output for the actual URL.
  • If you see a TypeScript build error, run npx tsc --noEmit to see the specific error message.
  • Make sure you are running the command from inside the resume-reviewer directory, not from your Desktop.

Help me get my Next.js dev server running.

Your project is scaffolded, your dependencies are installed, and your API key is ready. Next up, you will build the upload form that lets users submit their resume and a job description.

Build the Upload Form

You have a running dev server showing the default Next.js starter page. To analyze resumes, users first need a way to provide their resume file and a target job description.

In this step, you will build a client-side upload form that collects a resume file and job description, then submits them to an API route. The form will be interactive immediately, even though the API does not exist yet.

In this step, get ready to:
  • Replace the default page with a clean layout that mounts your form component.
  • Create a client component with file upload, job description textarea, and submit logic.
  • Verify the styled form renders and responds to interaction.
Create the main page layout

The App Router makes every file in the app/ directory a Server Component by default. Your page will render the heading and description on the server, then mount a Client Component for the interactive form.

  • Replace the contents of app/page.tsx with this code:
import ResumeReviewer from './components/ResumeReviewer';

export default function Home() {
  return (
    // Full-height gray background with centered content
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-center text-gray-900 mb-2">
          AI Resume Reviewer
        </h1>
        <p className="text-center text-gray-600 mb-8">
          Upload your resume and paste a job description to get AI-powered feedback
        </p>
        {/* Client component handles all interactive form logic */}
        <ResumeReviewer />
      </div>
    </main>
  );
}

What does this code do?

This Server Component renders static content (the heading and description) and imports ResumeReviewer as a Client Component. The server handles the HTML shell while the client handles interactivity.

The max-w-4xl mx-auto Tailwind classes center the content and cap its width for readability on large screens.

  • Save app/page.tsx.
  • Check your browser. You will see an error overlay because the ResumeReviewer component does not exist yet.

Why the error?

Next.js resolves imports at compile time. Since ./components/ResumeReviewer does not exist on disk, the dev server surfaces a Module not found error. You will fix this by creating the file next.

Build the ResumeReviewer client component

This component manages all the form interactivity: file selection, text input, loading state, and submitting data to the server. The 'use client' directive at the top tells Next.js to bundle it for the browser.

  • Create a new folder app/components/ inside your resume-reviewer project.
  • Create a new file app/components/ResumeReviewer.tsx and add the following code at the top:
'use client';

import { useState } from 'react';

// Defines the shape of Claude's structured analysis response
export interface ResumeAnalysis {
  atsScore: number;
  overallAssessment: string;
  missingKeywords: string[];
  skillsAlignment: {
    score: number;
    matched: string[];
    missing: string[];
  };
  strengths: string[];
  weaknesses: string[];
  improvements: string[];
  optimizedBullets: string[];
}

What is this interface for?

The ResumeAnalysis interface defines the exact shape of data you expect Claude to return in a later step. Declaring it now means TypeScript can type-check your state and components from the start.

  • Below the interface, add the component function with its state variables and submit handler:
export default function ResumeReviewer() {
  // Track the selected file, job description text, and UI states
  const [file, setFile] = useState<File | null>(null);
  const [jobDescription, setJobDescription] = useState('');
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<ResumeAnalysis | null>(null);
  const [error, setError] = useState<string | null>(null);

  // Submit the resume and job description to the API route
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);
    setResult(null);
    if (!file) { setError('Please select a resume file (PDF or DOCX).'); return; }
    setLoading(true);
    try {
      const formData = new FormData();
      formData.append('file', file);
      formData.append('jobDescription', jobDescription);
      const response = await fetch('/api/analyze', { method: 'POST', body: formData });
      if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Analysis failed.'); }
      const data = await response.json();
      setResult(data.analysis);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An unexpected error occurred.');
    } finally {
      setLoading(false);
    }
  };

What does the handler do?

The handleSubmit function packages the file and job description text into a FormData object. FormData is the standard browser API for sending files over HTTP.

It sends a POST request to /api/analyze (which does not exist yet). If the server responds with an error status, the handler parses the error message and displays it to the user.

  • Directly below the handler (still inside the function), add the return statement with the form JSX:
  return (
    <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-8">
      <div className="mb-6">
        <label className="block text-sm font-medium text-gray-700 mb-2">Resume File (PDF or DOCX)</label>
        <div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors">
          <input type="file" accept=".pdf,.docx" onChange={(e) => setFile(e.target.files?.[0] || null)} className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" />
          {file && <p className="mt-2 text-sm text-green-600">Selected: {file.name}</p>}
        </div>
      </div>
      <div className="mb-6">
        <label className="block text-sm font-medium text-gray-700 mb-2">Job Description</label>
        <textarea value={jobDescription} onChange={(e) => setJobDescription(e.target.value)} placeholder="Paste the full job description here..." rows={8} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y text-gray-900" />
      </div>
      {error && <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg"><p className="text-red-700 text-sm">{error}</p></div>}
      <button type="submit" disabled={loading} className="w-full py-3 px-6 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed transition-colors">
        {loading ? 'Analyzing Resume...' : 'Analyze Resume'}
      </button>
    </form>
  );
}

What does the form render?

The form has three sections. A dashed-border file input area that accepts .pdf and .docx files. A large textarea for the job description. A blue submit button that changes to "Analyzing Resume..." while loading.

The Tailwind utility classes handle all styling inline. The file: prefix classes style the native file input button, and the disabled: prefix dims the button during loading.

  • Save app/components/ResumeReviewer.tsx.
  • Switch to your browser and refresh the page.

You should see a centered white card on a gray background with a dashed-border file upload area, a large textarea, and a blue "Analyze Resume" button.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Scroll up and confirm your ResumeReviewer.tsx file contains these sections in order:

  • The 'use client' directive and useState import.
  • The ResumeAnalysis interface export.
  • The ResumeReviewer function with five useState calls.
  • The handleSubmit async function.
  • The return statement with the <form> JSX and closing }.

For app/page.tsx, confirm it matches this:

import ResumeReviewer from './components/ResumeReviewer';

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-center text-gray-900 mb-2">
          AI Resume Reviewer
        </h1>
        <p className="text-center text-gray-600 mb-8">
          Upload your resume and paste a job description to get AI-powered feedback
        </p>
        <ResumeReviewer />
      </div>
    </main>
  );
}

Page is blank or shows an error?

  • If you see a Module not found error, make sure the file is at app/components/ResumeReviewer.tsx (not app/component/ without the "s"). The import path in page.tsx must match the folder name exactly.
  • If Tailwind styles are not applying (you see unstyled HTML), check that app/globals.css includes the @tailwind directives. Next.js generates these by default.
  • If the browser console shows a React hydration error, confirm that 'use client' is the very first line in ResumeReviewer.tsx (before the import).

Still stuck? Help me debug why my ResumeReviewer form is not rendering.

Test the form interaction

The form is visible, but does it actually respond to input? Before you submit, think about what should happen when you click Analyze Resume without selecting a file.

  • Click the Analyze Resume button without selecting a file or entering a job description.

A red error message appears: "Please select a resume file (PDF or DOCX)." This is the client-side validation from your handleSubmit function catching the missing file.

  • Now select any PDF or DOCX file from your computer using the file picker.
  • Type or paste any text into the job description textarea.
  • Click Analyze Resume again.

What do you think will happen this time? The API route does not exist yet.

The button changes to "Analyzing Resume..." briefly, then a red error appears. If you open your browser's DevTools Network tab, you will see a 404 response for /api/analyze. This confirms the form submits correctly. The 404 is expected because you have not built the Route Handler yet.

Why is a 404 a good sign?

The 404 proves your form logic works end to end. The file was packaged into FormData, the fetch request fired, and your error handling caught the failed response. The only missing piece is the server-side route, which you will build in the next step.

Your upload form is wired up and ready to send data. Next up, you will build the server-side Route Handler that receives the file, extracts its text, and sends it to Claude for analysis.

Parse Documents and Call Claude

Your form looks great, but clicking Analyze Resume does nothing useful yet. The button sends a request to /api/analyze, but that endpoint does not exist.

In this step, you will build the server-side brain of your app. You will create a utility that extracts text from PDF and DOCX files, then send that text to Claude for analysis. The result comes back as plain text, which reveals an important limitation you will fix in the next step.

In this step, get ready to:
  • Create a document parsing utility that handles PDF and DOCX files.
  • Build a Route Handler that receives uploads and calls the Anthropic API.
  • Display Claude's raw text response below the upload form.
Create the document parsing utility

Resumes arrive as binary files. Before Claude can analyze them, you need to extract the readable text from inside those files. PDF and DOCX have completely different internal structures, so you need a dedicated library for each.

  • Create the lib/ directory inside your resume-reviewer project folder by running this command:
mkdir lib
  • Create a new file called parse-document.ts inside lib/ and paste the following code:
import { PDFParse } from 'pdf-parse';
import mammoth from 'mammoth';

export async function parseDocument(buffer: Buffer, filename: string): Promise<string> {
  // Detect file type from the filename extension
  const extension = filename.toLowerCase().slice(filename.lastIndexOf('.'));

  if (extension === '.pdf') {
    // pdf-parse v2 uses a class-based API
    const parser = new PDFParse({ data: new Uint8Array(buffer) });
    const result = await parser.getText();
    // Free memory after extraction
    await parser.destroy();
    return result.text;
  }

  if (extension === '.docx') {
    // mammoth unzips the DOCX and extracts plain text
    const result = await mammoth.extractRawText({ buffer });
    return result.value;
  }

  throw new Error(`Unsupported file type: ${extension}. Please upload a PDF or DOCX file.`);
}

What does this code do?

This function is the bridge between raw uploaded bytes and the text Claude needs.

  • For .pdf files: creates a PDFParse instance, calls getText() to extract text layers, then calls destroy() to free memory. Forgetting destroy() leaks memory on every request.
  • For .docx files: uses mammoth.extractRawText() which unzips the DOCX (a ZIP of XML files internally) and pulls out the plain text content.
  • For anything else: throws a descriptive error so the Route Handler can return a 400 response.
  • Save lib/parse-document.ts.
  • Confirm the file exists by running:
ls lib/

You should see parse-document.ts listed in the output.

Don't see the file?

  • Make sure you created the lib/ directory at the project root (inside resume-reviewer/), not inside app/.
  • Check the filename is exactly parse-document.ts (with a hyphen, not an underscore).

Still stuck? Help me find where my parse-document.ts file should go in my Next.js project

Create the Route Handler with a basic Claude call

Route Handlers in the Next.js App Router turn a file path into an API endpoint. The file app/api/analyze/route.ts automatically becomes POST /api/analyze, which is exactly what your form already submits to.

  • Create the nested directories for the route by running:
mkdir -p app/api/analyze
  • Create a new file called route.ts inside app/api/analyze/ and paste the following code:
import { NextRequest, NextResponse } from 'next/server';
import { parseDocument } from '@/lib/parse-document';
import Anthropic from '@anthropic-ai/sdk';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File | null;
    const jobDescription = formData.get('jobDescription') as string | null;

    // Validate inputs
    if (!file) return NextResponse.json({ error: 'No file provided.' }, { status: 400 });
    if (!jobDescription?.trim()) return NextResponse.json({ error: 'No job description provided.' }, { status: 400 });

    // Convert the uploaded file to a Buffer for parsing
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);
    const resumeText = await parseDocument(buffer, file.name);

    // Call Claude with a basic prompt
    const client = new Anthropic();
    const message = await client.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 4096,
      system: 'You are an expert resume reviewer. Analyze the resume against the job description and provide detailed feedback.',
      messages: [{ role: 'user', content: `## Resume\n\n${resumeText}\n\n## Job Description\n\n${jobDescription}` }],
    });

    // Extract the text content from Claude's response
    const rawText = message.content[0].type === 'text' ? message.content[0].text : '';
    return NextResponse.json({ rawText });
  } catch (error) {
    console.error('Analysis error:', error);
    return NextResponse.json({ error: error instanceof Error ? error.message : 'An unexpected error occurred.' }, { status: 500 });
  }
}

What does this code do?

  • Reads the uploaded file and job description from the FormData sent by your form.
  • Converts the file to a Buffer and passes it to parseDocument() to extract plain text.
  • Creates an Anthropic client which automatically reads your ANTHROPIC_API_KEY from .env.local.
  • Sends the resume text and job description to claude-sonnet-5 with a simple system prompt asking for feedback.
  • Returns Claude's raw text response as JSON.

The prompt here is deliberately simple. You will redesign it in the next step to return structured JSON instead.

  • Save app/api/analyze/route.ts.
  • Confirm the file exists by running:
ls app/api/analyze/

You should see route.ts in the output.

Seeing a module not found error?

  • If you see an error about @/lib/parse-document not being found, make sure lib/ is at the project root (same level as app/), not nested inside app/.
  • If you see an error about @anthropic-ai/sdk, confirm you ran npm install @anthropic-ai/sdk@0.115.0 in Step 1.

Need more help? Help me debug my Route Handler module imports in Next.js

Update the frontend to display raw text

Your Route Handler now returns rawText (a plain string) instead of a structured analysis object. You need to update ResumeReviewer.tsx to store and display this string.

  • In app/components/ResumeReviewer.tsx, find this line in the state declarations:
const [result, setResult] = useState<ResumeAnalysis | null>(null);
  • Change ResumeAnalysis | null to string | null so the line looks like this:
const [result, setResult] = useState<string | null>(null);
  • Next, find this line inside the try block of handleSubmit:
setResult(data.analysis);
  • Change data.analysis to data.rawText to match what the Route Handler returns:
setResult(data.rawText);
  • Finally, update the return statement. Wrap the existing <form> in a <div> and add a <pre> block after the form to display results. Replace the current return with:
return (
  <div>
    <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-8">
      {/* ... all your existing form content stays the same ... */}
    </form>
    {result && <pre className="mt-6 p-4 bg-gray-100 rounded-lg text-sm text-gray-800 whitespace-pre-wrap">{result}</pre>}
  </div>
);

What changed here?

  • The result state now holds a plain string instead of a typed object. This matches the unstructured text Claude returns.
  • The <pre> block renders only when result is not null. The whitespace-pre-wrap class preserves line breaks from Claude's response.
  • The form and result display live inside a shared <div> so React has a single root element.
  • Save app/components/ResumeReviewer.tsx.

Before you test, think about what Claude will send back. You asked for "detailed feedback" with no formatting instructions. What do you expect the response to look like in the browser?

  • Switch to your browser at localhost:3000.
  • Select a PDF resume using the file picker.
  • Paste a real job description into the textarea.
  • Click Analyze Resume and wait 5-15 seconds.

You should see Claude's analysis appear below the form as a gray block of plain text. The feedback is useful. It mentions things like keyword matches, gaps, and suggestions. But it is an unformatted wall of text with no visual structure, no scores, and no colored indicators.

Why does this look rough?

You gave Claude a simple prompt with no formatting instructions. Claude returned free-form paragraphs because you did not ask for structured data. Rendering this into a polished dashboard with score cards and keyword chips is impossible without parsing.

This is the exact problem you will solve in the next step by redesigning the prompt to return JSON with specific fields.

🙋‍♀️ Not seeing any response?

  • Check your terminal for errors. If you see "API key is invalid", restart your dev server (it only reads .env.local on startup). Press Ctrl+C then run npm run dev again.
  • If you see "Could not extract text", your PDF might be a scanned image with no text layer. Try a different PDF or a DOCX file.
  • If the request times out, your resume might be very large. Try a shorter document (under 3 pages).
  • If you see a 404 error, confirm app/api/analyze/route.ts exists at that exact path.

Still stuck? Help me debug why my resume analysis API call is failing

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file you created or edited in this step.

lib/parse-document.ts:

import { PDFParse } from 'pdf-parse';
import mammoth from 'mammoth';

export async function parseDocument(buffer: Buffer, filename: string): Promise<string> {
  const extension = filename.toLowerCase().slice(filename.lastIndexOf('.'));
  if (extension === '.pdf') {
    const parser = new PDFParse({ data: new Uint8Array(buffer) });
    const result = await parser.getText();
    await parser.destroy();
    return result.text;
  }
  if (extension === '.docx') {
    const result = await mammoth.extractRawText({ buffer });
    return result.value;
  }
  throw new Error(`Unsupported file type: ${extension}. Please upload a PDF or DOCX file.`);
}

app/api/analyze/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { parseDocument } from '@/lib/parse-document';
import Anthropic from '@anthropic-ai/sdk';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File | null;
    const jobDescription = formData.get('jobDescription') as string | null;
    if (!file) return NextResponse.json({ error: 'No file provided.' }, { status: 400 });
    if (!jobDescription?.trim()) return NextResponse.json({ error: 'No job description provided.' }, { status: 400 });
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);
    const resumeText = await parseDocument(buffer, file.name);
    const client = new Anthropic();
    const message = await client.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 4096,
      system: 'You are an expert resume reviewer. Analyze the resume against the job description and provide detailed feedback.',
      messages: [{ role: 'user', content: `## Resume\n\n${resumeText}\n\n## Job Description\n\n${jobDescription}` }],
    });
    const rawText = message.content[0].type === 'text' ? message.content[0].text : '';
    return NextResponse.json({ rawText });
  } catch (error) {
    console.error('Analysis error:', error);
    return NextResponse.json({ error: error instanceof Error ? error.message : 'An unexpected error occurred.' }, { status: 500 });
  }
}

app/components/ResumeReviewer.tsx (scroll up and confirm your component has these key changes: useState<string | null> for the result state, setResult(data.rawText) in handleSubmit, and the <pre> block after the form closing tag):

Your app now talks to Claude and returns real analysis. Next up, you will redesign the prompt so Claude returns structured JSON. That unlocks visual score cards, colored keyword chips, and a proper results dashboard.

Engineer Structured Output and Build Results UI

In Step 3, you saw Claude return useful analysis as a wall of plain text. The feedback was insightful, but you had no way to render it into score cards, colored chips, or formatted lists.

The fix is prompt engineering for structured output. You will design a system prompt that forces Claude to respond with a specific JSON schema. Once the data arrives in a predictable shape, building a polished UI becomes simple array.map() calls over well-typed arrays.

In this step, get ready to:
  • Create an analysis module with a structured JSON prompt that tells Claude exactly what shape to return.
  • Build a ResultsDisplay component that renders each JSON field as a visual card.
  • Wire the structured output into the frontend to replace the raw text blob.
Redesign the prompt for structured JSON output

Right now, the Route Handler calls Claude with a vague "analyze the resume" instruction. Claude responds however it wants. To render a polished UI, you need Claude to return a JSON object with exact field names your frontend can rely on.

  • Create a new file called lib/analyze-resume.ts by adding the interface and imports below:
import Anthropic from '@anthropic-ai/sdk';

export interface ResumeAnalysis {
  atsScore: number;
  overallAssessment: string;
  missingKeywords: string[];
  skillsAlignment: {
    score: number;
    matched: string[];
    missing: string[];
  };
  strengths: string[];
  weaknesses: string[];
  improvements: string[];
  optimizedBullets: string[];
}

What does this interface do?

The ResumeAnalysis interface is a contract between Claude's response and your UI. Each field maps directly to a visual section: atsScore drives a progress bar, missingKeywords renders as colored chips, and optimizedBullets becomes a copyable list.

  • Below the interface, add the system prompt constant that instructs Claude to respond only with valid JSON:
const SYSTEM_PROMPT = `You are an expert resume reviewer and career coach specializing in ATS (Applicant Tracking System) optimization. Analyze the provided resume against the job description and respond ONLY with a valid JSON object matching this exact schema (no markdown, no code fences, no additional text):

{
  "atsScore": <number 0-100 representing ATS compatibility>,
  "overallAssessment": "<1-2 sentence summary of the resume's fit for this role>",
  "missingKeywords": ["<important keywords from the job description not found in the resume>"],
  "skillsAlignment": {
    "score": <number 0-100>,
    "matched": ["<skills from the JD that appear in the resume>"],
    "missing": ["<skills from the JD that are absent from the resume>"]
  },
  "strengths": ["<specific strengths of this resume for this role>"],
  "weaknesses": ["<specific weaknesses or gaps>"],
  "improvements": ["<actionable suggestions to improve the resume for this role>"],
  "optimizedBullets": ["<5-7 rewritten or new bullet points tailored to the job description>"]
}

Scoring guidelines:
- atsScore: Based on keyword density, formatting friendliness, section completeness, and role relevance
- skillsAlignment.score: Percentage of required/preferred skills from the JD that are present in the resume
- Be specific and actionable in all feedback
- Optimized bullets should use strong action verbs, quantify achievements where possible, and incorporate missing keywords naturally`;

Why is the prompt so specific?

The Anthropic SDK does not have a built-in "JSON mode." Structured output is achieved entirely through prompt engineering. The more specific your instructions, the more reliably Claude follows the schema.

Key techniques: stating "respond ONLY with a valid JSON object," providing the full schema with placeholder descriptions for each field, and adding "no markdown, no code fences, no additional text" to prevent wrapping.

  • Below the SYSTEM_PROMPT, add the model constant and the analyzeResume function that calls Claude and parses the JSON response:
// Update this model ID if you have access to a different Claude model.
// Common options: 'claude-sonnet-5', 'claude-sonnet-4-6', 'claude-haiku-4'
const MODEL_ID = 'claude-sonnet-5';

export async function analyzeResume(resumeText: string, jobDescription: string): Promise<ResumeAnalysis> {
  const client = new Anthropic();
  const message = await client.messages.create({
    model: MODEL_ID,
    max_tokens: 4096,
    system: SYSTEM_PROMPT,
    messages: [{ role: 'user', content: `## Resume\n\n${resumeText}\n\n## Job Description\n\n${jobDescription}` }],
  });
  const responseText = message.content[0].type === 'text' ? message.content[0].text : '';
  try {
    const analysis: ResumeAnalysis = JSON.parse(responseText);
    return analysis;
  } catch {
    throw new Error('Failed to parse AI response. The model returned invalid JSON. Please try again.');
  }
}

What does this function do?

The analyzeResume function creates an Anthropic client (which reads ANTHROPIC_API_KEY from your environment automatically), sends the resume and job description to Claude with the structured prompt, then parses the response with JSON.parse().

If Claude ever returns invalid JSON (rare with a good prompt), the try/catch throws a human-readable error the frontend can display.

  • Save lib/analyze-resume.ts.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before continuing.

ⓧ I'd like to double check the full code

Scroll up and confirm your file contains these three sections in order:

  • The import statement and ResumeAnalysis interface.
  • The SYSTEM_PROMPT constant with the full JSON schema.
  • The MODEL_ID constant and analyzeResume function.

Now you need to update the Route Handler to use this new module instead of making an inline Claude call.

  • Replace the entire content of app/api/analyze/route.ts with the following:
import { NextRequest, NextResponse } from 'next/server';
import { parseDocument } from '@/lib/parse-document';
import { analyzeResume } from '@/lib/analyze-resume';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File | null;
    const jobDescription = formData.get('jobDescription') as string | null;
    if (!file) return NextResponse.json({ error: 'No file provided.' }, { status: 400 });
    if (!jobDescription?.trim()) return NextResponse.json({ error: 'No job description provided.' }, { status: 400 });
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);
    const resumeText = await parseDocument(buffer, file.name);
    if (!resumeText.trim()) return NextResponse.json({ error: 'Could not extract text from the file.' }, { status: 422 });
    const analysis = await analyzeResume(resumeText, jobDescription);
    return NextResponse.json({ analysis });
  } catch (error) {
    console.error('Analysis error:', error);
    if (error instanceof Error) {
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json({ error: 'An unexpected error occurred.' }, { status: 500 });
  }
}

What changed in the Route Handler?

The inline Anthropic call is gone. The route now imports and calls analyzeResume() which handles prompting and parsing internally. It also adds an empty-text check that catches scanned PDFs with no extractable text.

The response shape changes from { rawText } to { analysis }. This is what the frontend will consume next.

  • Save app/api/analyze/route.ts.
  • Submit a resume and job description in the browser. Open the browser's Network tab (DevTools) and inspect the response body for the /api/analyze request.

Before you check the response, what shape do you expect the JSON to be in?

You should see a JSON object with an analysis key containing all the fields from your interface: atsScore, missingKeywords, skillsAlignment, and so on. The page itself still shows the old <pre> block (or is blank) because the frontend still expects rawText. You will fix that shortly.

Not seeing structured JSON in the response?

  • If the response says "Failed to parse AI response," Claude may have wrapped the JSON in markdown code fences. Add extra emphasis in your prompt: "Do NOT wrap the JSON in backticks or code blocks."
  • If you see a 500 error about a missing module, make sure you saved lib/analyze-resume.ts and that the import path in route.ts uses @/lib/analyze-resume.
  • If you get an authentication error, restart the dev server. Next.js only reads .env.local on startup.

Help me debug my structured output from Claude.

Build the ResultsDisplay component

Each field in the ResumeAnalysis interface maps to a visual card. Numbers become progress bars. Arrays become colored chips. Strings become formatted paragraphs.

  • Create app/components/ResultsDisplay.tsx and add the component setup with the ATS score card:
'use client';

import { ResumeAnalysis } from './ResumeReviewer';

interface ResultsDisplayProps {
  analysis: ResumeAnalysis;
}

export default function ResultsDisplay({ analysis }: ResultsDisplayProps) {
  // Helper: returns a text color class based on score thresholds
  const getScoreColor = (score: number) => {
    if (score >= 80) return 'text-green-600';
    if (score >= 60) return 'text-yellow-600';
    return 'text-red-600';
  };
  // Helper: returns a background color class based on score thresholds
  const getScoreBg = (score: number) => {
    if (score >= 80) return 'bg-green-100';
    if (score >= 60) return 'bg-yellow-100';
    return 'bg-red-100';
  };
  return (
    <div className="space-y-6">
      <div className="bg-white rounded-lg shadow-md p-6">
        <h3 className="text-lg font-semibold text-gray-900 mb-4">ATS Compatibility Score</h3>
        <div className="flex items-center gap-4">
          <div className={`text-4xl font-bold ${getScoreColor(analysis.atsScore)}`}>{analysis.atsScore}%</div>
          <div className="flex-1"><div className="w-full bg-gray-200 rounded-full h-3"><div className={`h-3 rounded-full transition-all ${analysis.atsScore >= 80 ? 'bg-green-500' : analysis.atsScore >= 60 ? 'bg-yellow-500' : 'bg-red-500'}`} style={{ width: `${analysis.atsScore}%` }} /></div></div>
        </div>
        <p className="mt-3 text-gray-600 text-sm">{analysis.overallAssessment}</p>
      </div>

What does this code do?

The getScoreColor and getScoreBg helpers map numeric scores to Tailwind color classes. Scores 80+ are green, 60+ are yellow, below 60 are red.

The ATS score card renders the number, a progress bar whose width is driven by style={{ width: `${analysis.atsScore}%` }}, and the overall assessment paragraph.

  • Directly below the ATS score card (still inside the <div className="space-y-6">), add the remaining result sections and close the component:
      <div className="bg-white rounded-lg shadow-md p-6">
        <h3 className="text-lg font-semibold text-gray-900 mb-4">Skills Alignment <span className={`ml-2 px-2 py-1 text-sm rounded ${getScoreBg(analysis.skillsAlignment.score)} ${getScoreColor(analysis.skillsAlignment.score)}`}>{analysis.skillsAlignment.score}%</span></h3>
        <div className="grid md:grid-cols-2 gap-4">
          <div><h4 className="text-sm font-medium text-green-700 mb-2">Matched Skills</h4><div className="flex flex-wrap gap-2">{analysis.skillsAlignment.matched.map((skill, i) => <span key={i} className="px-3 py-1 bg-green-100 text-green-800 text-sm rounded-full">{skill}</span>)}</div></div>
          <div><h4 className="text-sm font-medium text-red-700 mb-2">Missing Skills</h4><div className="flex flex-wrap gap-2">{analysis.skillsAlignment.missing.map((skill, i) => <span key={i} className="px-3 py-1 bg-red-100 text-red-800 text-sm rounded-full">{skill}</span>)}</div></div>
        </div>
      </div>
      <div className="bg-white rounded-lg shadow-md p-6"><h3 className="text-lg font-semibold text-gray-900 mb-4">Missing Keywords</h3><div className="flex flex-wrap gap-2">{analysis.missingKeywords.map((keyword, i) => <span key={i} className="px-3 py-1 bg-orange-100 text-orange-800 text-sm rounded-full border border-orange-200">{keyword}</span>)}</div></div>
      <div className="grid md:grid-cols-2 gap-6">
        <div className="bg-white rounded-lg shadow-md p-6"><h3 className="text-lg font-semibold text-green-700 mb-4">Strengths</h3><ul className="space-y-2">{analysis.strengths.map((s, i) => <li key={i} className="flex items-start gap-2 text-sm text-gray-700"><span className="text-green-500 mt-0.5">+</span>{s}</li>)}</ul></div>
        <div className="bg-white rounded-lg shadow-md p-6"><h3 className="text-lg font-semibold text-red-700 mb-4">Weaknesses</h3><ul className="space-y-2">{analysis.weaknesses.map((w, i) => <li key={i} className="flex items-start gap-2 text-sm text-gray-700"><span className="text-red-500 mt-0.5">-</span>{w}</li>)}</ul></div>
      </div>
      <div className="bg-white rounded-lg shadow-md p-6"><h3 className="text-lg font-semibold text-gray-900 mb-4">Suggested Improvements</h3><ol className="space-y-3">{analysis.improvements.map((imp, i) => <li key={i} className="flex gap-3 text-sm text-gray-700"><span className="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-semibold">{i + 1}</span>{imp}</li>)}</ol></div>
      <div className="bg-white rounded-lg shadow-md p-6"><h3 className="text-lg font-semibold text-gray-900 mb-4">Optimized Resume Bullets</h3><p className="text-sm text-gray-500 mb-3">Copy these tailored bullet points into your resume:</p><ul className="space-y-2">{analysis.optimizedBullets.map((bullet, i) => <li key={i} className="flex items-start gap-2 text-sm text-gray-700 bg-gray-50 p-3 rounded-lg"><span className="text-gray-400">&#8226;</span>{bullet}</li>)}</ul></div>
    </div>
  );
}

What do these sections render?

  • Skills Alignment: a two-column grid with green chips for matched skills and red chips for missing skills.
  • Missing Keywords: orange chips showing job description terms absent from the resume.
  • Strengths/Weaknesses: side-by-side lists with + and - indicators.
  • Improvements: numbered cards with blue circles.
  • Optimized Bullets: gray blocks the learner can copy into their resume.
  • Save app/components/ResultsDisplay.tsx.

✔️ Awesome, I've got everything!

Great. The component will not render yet because nothing imports it. You will connect it in the next substep.

ⓧ I'd like to double check the full code

Scroll up and confirm your file has these sections in order:

  • The 'use client' directive, import, interface, function declaration, and two helper functions (getScoreColor and getScoreBg).
  • The return statement opening with the ATS Compatibility Score card.
  • Skills Alignment, Missing Keywords, Strengths, Weaknesses, Improvements, and Optimized Bullets sections, followed by closing </div>, );, and }.
Wire results into the main component

The API now returns structured JSON and the display component is ready. You need to update ResumeReviewer.tsx to import the display component, parse the structured response, and render the results panel instead of the raw text block.

  • In app/components/ResumeReviewer.tsx, find the imports at the top of the file:
import { useState } from 'react';
  • Add the ResultsDisplay import directly below it, so the imports look like this:
import { useState } from 'react';
import ResultsDisplay from './ResultsDisplay';
  • Find the state declaration for result:
const [result, setResult] = useState<string | null>(null);
  • Change the type from string | null to ResumeAnalysis | null so the line looks like this:
const [result, setResult] = useState<ResumeAnalysis | null>(null);
  • Inside handleSubmit, find the line that sets the result:
setResult(data.rawText);
  • Change it to read the analysis field from the response:
setResult(data.analysis);
  • Below the closing brace of handleSubmit, add a reset function:
  const handleReset = () => { setFile(null); setJobDescription(''); setResult(null); setError(null); };
  • Finally, replace the entire return block (from return ( to the final );) with this conditional rendering:
  return (
    <div>
      {!result ? (
        <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-8">
          <div className="mb-6"><label className="block text-sm font-medium text-gray-700 mb-2">Resume File (PDF or DOCX)</label><div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors"><input type="file" accept=".pdf,.docx" onChange={(e) => setFile(e.target.files?.[0] || null)} className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" />{file && <p className="mt-2 text-sm text-green-600">Selected: {file.name} ({(file.size / 1024).toFixed(1)} KB)</p>}</div></div>
          <div className="mb-6"><label className="block text-sm font-medium text-gray-700 mb-2">Job Description</label><textarea value={jobDescription} onChange={(e) => setJobDescription(e.target.value)} placeholder="Paste the full job description here..." rows={8} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y text-gray-900" /></div>
          {error && <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg"><p className="text-red-700 text-sm">{error}</p></div>}
          <button type="submit" disabled={loading} className="w-full py-3 px-6 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed transition-colors">{loading ? 'Analyzing Resume...' : 'Analyze Resume'}</button>
        </form>
      ) : (
        <div>
          <div className="flex justify-between items-center mb-6">
            <h2 className="text-xl font-semibold text-gray-900">Analysis Results</h2>
            <button onClick={handleReset} className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">New Review</button>
          </div>
          <ResultsDisplay analysis={result} />
        </div>
      )}
    </div>
  );

What changed in the rendering?

Instead of always showing the form with a <pre> block below it, the component now uses a ternary: when there is no result, show the form. When a result exists, show the results header (with a "New Review" button) and the ResultsDisplay component.

  • Save app/components/ResumeReviewer.tsx.

Before you test, what do you expect the results page to look like compared to the raw text you saw in Step 3?

  • Return to your browser at localhost:3000. Upload a PDF resume and paste a job description, then click Analyze Resume.

After 5-15 seconds, the form disappears and a polished results panel appears. You should see a numeric ATS score with a colored progress bar, green and red skill chips, orange keyword chips, strengths and weaknesses lists, numbered improvement cards, and gray copyable bullet points. Click New Review to confirm it resets back to the empty form.

Page is blank or showing errors?

  • If you see a React hydration error, check that ResultsDisplay.tsx has 'use client' as its first line.
  • If the form submits but nothing renders, open the browser console. A "Cannot read properties of undefined" error means the API response shape does not match what the component expects. Check that your route returns { analysis } and your component reads data.analysis.
  • If Tailwind styles are missing, confirm that app/globals.css still has the @tailwind directives at the top.

Help me debug my ResultsDisplay component.

The same AI analysis that was an unreadable text wall is now a polished, scannable dashboard. The difference is entirely in prompt engineering and structured output. Next up, you will add error handling, loading states, and a download button to make this production-ready.

Add Error Handling and Downloadable Feedback

Your app delivers polished results, but it assumes everything will go perfectly. Real users will upload invalid files, have network issues, or hit API rate limits.

In this step, you will add validation at both the client and server layers so errors surface clearly. You will also add a download button that lets users save their analysis as a JSON file for future reference.

In this step, get ready to:
  • Add client-side and server-side file validation (type, size, empty fields).
  • Create a download button that exports the full analysis as a JSON file.
  • Add an animated loading spinner and a model cost note.
Add comprehensive error handling

Error handling happens at two layers. Client-side validation catches obvious mistakes before making a network request. Server-side validation is the security boundary that re-checks everything because browser checks can be bypassed.

  • In app/components/ResumeReviewer.tsx, find the handleSubmit function. Locate this existing check:
if (!file) { setError('Please select a resume file (PDF or DOCX).'); return; }
setLoading(true);
  • Add these validation checks between the if (!file) check and setLoading(true):
// Validate file extension
const validTypes = ['.pdf', '.docx'];
const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (!validTypes.includes(extension)) {
  setError('Unsupported file type. Please upload a PDF or DOCX file.');
  return;
}

// Reject files larger than 5MB
if (file.size > 5 * 1024 * 1024) {
  setError('File too large. Maximum size is 5MB.');
  return;
}

// Require a job description
if (!jobDescription.trim()) {
  setError('Please paste a job description.');
  return;
}

What does this code do?

Each check catches a different user mistake before the request fires. The extension check prevents unsupported formats. The size check prevents server timeouts on huge files. The job description check prevents a wasted API call that cannot produce useful analysis.

  • Also in handleSubmit, find this line inside the try block:
const err = await response.json(); throw new Error(err.error || 'Analysis failed.');
  • Replace it with this more descriptive version:
const errorData = await response.json();
throw new Error(errorData.error || 'Analysis failed. Please try again.');
  • Save app/components/ResumeReviewer.tsx.
  • Return to your browser at localhost:3000 and test the validation. Try submitting with no file selected. Then try selecting a .txt file. Then try submitting with an empty job description.

Each case should display a red error box with a clear message. No network request fires for any of these.

Not seeing the error messages?

Make sure you saved the file. Check that the new checks are placed AFTER the if (!file) block and BEFORE setLoading(true). If validation passes and the form submits, the checks are in the wrong location.

Help me debug my client-side validation in ResumeReviewer.tsx

Now add matching server-side validation. The server must re-check everything because client-side validation can be bypassed.

  • Replace the entire contents of app/api/analyze/route.ts with this updated handler:
import { NextRequest, NextResponse } from 'next/server';
import { parseDocument } from '@/lib/parse-document';
import { analyzeResume } from '@/lib/analyze-resume';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File | null;
    const jobDescription = formData.get('jobDescription') as string | null;
    if (!file) return NextResponse.json({ error: 'No file provided.' }, { status: 400 });
    if (!jobDescription || !jobDescription.trim())
      return NextResponse.json({ error: 'No job description provided.' }, { status: 400 });

    // Server-side file type validation
    const validExtensions = ['.pdf', '.docx'];
    const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
    if (!validExtensions.includes(extension))
      return NextResponse.json(
        { error: 'Unsupported file type. Please upload a PDF or DOCX file.' }, { status: 400 }
      );

    // Server-side file size limit
    if (file.size > 5 * 1024 * 1024)
      return NextResponse.json({ error: 'File too large. Maximum size is 5MB.' }, { status: 400 });
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);
    const resumeText = await parseDocument(buffer, file.name);
    if (!resumeText.trim())
      return NextResponse.json(
        { error: 'Could not extract text from the file. The document may be scanned or image-based.' },
        { status: 422 }
      );
    const analysis = await analyzeResume(resumeText, jobDescription);
    return NextResponse.json({ analysis });
  } catch (error) {
    console.error('Analysis error:', error);
    if (error instanceof Error) {
      // Translate Anthropic SDK errors into user-friendly messages
      if (error.message.includes('authentication') || error.message.includes('api_key'))
        return NextResponse.json(
          { error: 'API key is invalid or missing. Check your ANTHROPIC_API_KEY in .env.local.' },
          { status: 401 }
        );
      if (error.message.includes('rate_limit'))
        return NextResponse.json(
          { error: 'Rate limit reached. Please wait a moment and try again.' },
          { status: 429 }
        );
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json({ error: 'An unexpected error occurred.' }, { status: 500 });
  }
}

What does this code do?

The try block validates in order: missing file, missing job description, wrong extension, oversized file, empty text after parsing. Each returns a specific HTTP status code (400 for bad input, 422 for unprocessable content).

The catch block translates Anthropic SDK error messages into human-readable responses. Authentication errors get a 401. Rate limit errors get a 429. Everything else falls through as a generic 500.

  • Save app/api/analyze/route.ts.
  • To test the authentication error handling, temporarily edit your .env.local to use a fake key like sk-ant-fake123. Restart the dev server, submit a valid resume, and confirm you see the "API key is invalid" error.
  • Restore your real key in .env.local and restart the dev server again.

Not seeing the authentication error?

Next.js only reads .env.local on startup. You must stop the dev server with Ctrl+C and run npm run dev again after editing the file.

Help me test my API error handling in route.ts

Seeing a JSON parsing error despite a successful API request?

If /api/analyze returns a 200 but you see Failed to parse the AI response as JSON or SyntaxError: Unexpected end of JSON input, there are two common causes.

Cause 1: your code reads only the first content block. Anthropic responses can contain multiple content blocks. Extract all text blocks instead of reading message.content[0]:

const responseText = message.content
  .filter(
    (block): block is Anthropic.TextBlock =>
      block.type === 'text'
  )
  .map((block) => block.text)
  .join('')
  .trim();

Cause 2: Claude wraps the JSON in markdown code fences or adds preamble text. Strip those wrappers before parsing:

function extractJSON(text: string): string {
  const codeFenceMatch =
    text.match(/```json\s*([\s\S]*?)\s*```/i) ||
    text.match(/```\s*([\s\S]*?)\s*```/);

  if (codeFenceMatch) {
    return codeFenceMatch[1].trim();
  }

  const start = text.indexOf('{');
  const end = text.lastIndexOf('}');

  if (start !== -1 && end !== -1 && end > start) {
    return text.slice(start, end + 1).trim();
  }

  return text.trim();
}

Then use it before JSON.parse:

const cleanedJSON = extractJSON(responseText);
const analysis = JSON.parse(cleanedJSON) as ResumeAnalysis;

If neither fix resolves the issue, temporarily log the full response to inspect what Claude returned:

console.log(JSON.stringify(message, null, 2));
console.log('Claude stop reason:', message.stop_reason);

Help me debug JSON parsing errors in my analyzeResume function

Add the download button

Users want to save their feedback for later. A download button that exports the full analysis as JSON gives them a portable record they can reference while editing their resume.

  • Create a new file app/components/DownloadButton.tsx and add the following code:
'use client';

import { ResumeAnalysis } from './ResumeReviewer';

interface DownloadButtonProps {
  analysis: ResumeAnalysis;
  filename: string;
  jobDescription: string;
}

export default function DownloadButton({ analysis, filename, jobDescription }: DownloadButtonProps) {
  const handleDownload = () => {
    // Build export object with metadata and full analysis
    const exportData = {
      metadata: {
        filename,
        jobDescriptionSnippet: jobDescription.slice(0, 200) + (jobDescription.length > 200 ? '...' : ''),
        analyzedAt: new Date().toISOString(),
        model: 'claude-sonnet-5',
      },
      analysis,
    };

    // Create a JSON blob and trigger a browser download
    const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `resume-review-${new Date().toISOString().split('T')[0]}.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  };
  return (
    <button onClick={handleDownload} className="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors">
      Download Feedback
    </button>
  );
}

What does this code do?

The download works entirely client-side with no server request. It serializes the analysis plus metadata (filename, date, model used) into a JSON string, wraps it in a Blob, generates a temporary URL with URL.createObjectURL(), then creates an invisible <a> tag and clicks it programmatically.

The URL.revokeObjectURL() call at the end frees the memory used by the temporary URL.

  • Save app/components/DownloadButton.tsx.

You cannot test the button yet because it is not wired into the main component. You will add the import in the next substep.

Seeing a TypeScript error about ResumeAnalysis?

Make sure the import path is './ResumeReviewer' (same directory). The ResumeAnalysis interface is exported from that file.

Help me fix the TypeScript import for DownloadButton

Wire the download button and add final polish

The last piece is connecting the DownloadButton to your results view and adding a loading spinner so users know the analysis is in progress.

  • In app/components/ResumeReviewer.tsx, find the imports at the top of the file:
import ResultsDisplay from './ResultsDisplay';
  • Add the DownloadButton import directly below it, so the imports look like this:
import ResultsDisplay from './ResultsDisplay';
import DownloadButton from './DownloadButton';
  • Now find the results header section. It currently looks like this:
<div className="flex justify-between items-center mb-6">
  <h2 className="text-xl font-semibold text-gray-900">Analysis Results</h2>
  <button onClick={handleReset} className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">New Review</button>
</div>
  • Replace it with this version that adds the DownloadButton alongside the New Review button:
<div className="flex justify-between items-center mb-6">
  <h2 className="text-xl font-semibold text-gray-900">Analysis Results</h2>
  <div className="flex gap-3">
    <DownloadButton
      analysis={result}
      filename={file?.name || 'resume'}
      jobDescription={jobDescription}
    />
    <button onClick={handleReset} className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
      New Review
    </button>
  </div>
</div>

What does this code do?

The DownloadButton receives the analysis object, the uploaded filename, and the job description text. It sits inside a flex container with gap-3 spacing next to the existing New Review button.

  • Next, find the submit button in the form. It currently renders a plain text string when loading:
<button type="submit" disabled={loading} className="w-full py-3 px-6 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed transition-colors">
  {loading ? 'Analyzing Resume...' : 'Analyze Resume'}
</button>
  • Replace the entire button element with this version that includes an animated SVG spinner and a cost note below it:
<button
  type="submit"
  disabled={loading}
  className="w-full py-3 px-6 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed transition-colors"
>
  {loading ? (
    <span className="flex items-center justify-center gap-2">
      <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
        <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
        <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
      </svg>
      Analyzing Resume...
    </span>
  ) : (
    'Analyze Resume'
  )}
</button>

<p className="mt-3 text-xs text-gray-500 text-center">
  Powered by Claude Sonnet 5. Each analysis costs approximately $0.03-0.05 in API usage.
</p>

What does this code do?

The SVG uses Tailwind's animate-spin class to rotate continuously while the API call is in progress. The cost note below the button sets user expectations about pricing.

  • Save app/components/ResumeReviewer.tsx.

Before you run the full end-to-end test, what do you expect to see when you click the Analyze Resume button with a valid file? Think about the spinner, the results panel, and the new Download Feedback button.

  • Refresh your browser and run a complete test. Upload a real PDF resume, paste a job description, and click Analyze Resume.
  • Confirm you see the spinning animation while waiting.
  • When results appear, click Download Feedback.
  • Open the downloaded resume-review-2026-07-28.json file and verify it contains both a metadata object and the full analysis object.
  • Click New Review and confirm the form resets cleanly.

You should see the full cycle: upload, spinner, polished results, download, and clean reset. The app now handles every common failure case gracefully.

Download not triggering?

Some browsers block programmatic downloads if the page has not received a user gesture first. Make sure you are clicking the Download Feedback button directly (not calling it from a script). If you see a popup-blocked warning, allow popups for localhost.

Help me fix the download button in my Resume Reviewer

✔️ Awesome, I've got everything!

Make sure you have saved all three files: app/api/analyze/route.ts, app/components/DownloadButton.tsx, and app/components/ResumeReviewer.tsx.

ⓧ I'd like to double check the full code

Here is the full app/api/analyze/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { parseDocument } from '@/lib/parse-document';
import { analyzeResume } from '@/lib/analyze-resume';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File | null;
    const jobDescription = formData.get('jobDescription') as string | null;
    if (!file) return NextResponse.json({ error: 'No file provided.' }, { status: 400 });
    if (!jobDescription || !jobDescription.trim())
      return NextResponse.json({ error: 'No job description provided.' }, { status: 400 });
    const validExtensions = ['.pdf', '.docx'];
    const extension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
    if (!validExtensions.includes(extension))
      return NextResponse.json(
        { error: 'Unsupported file type. Please upload a PDF or DOCX file.' }, { status: 400 }
      );
    if (file.size > 5 * 1024 * 1024)
      return NextResponse.json({ error: 'File too large. Maximum size is 5MB.' }, { status: 400 });
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);
    const resumeText = await parseDocument(buffer, file.name);
    if (!resumeText.trim())
      return NextResponse.json(
        { error: 'Could not extract text from the file. The document may be scanned or image-based.' },
        { status: 422 }
      );
    const analysis = await analyzeResume(resumeText, jobDescription);
    return NextResponse.json({ analysis });
  } catch (error) {
    console.error('Analysis error:', error);
    if (error instanceof Error) {
      if (error.message.includes('authentication') || error.message.includes('api_key'))
        return NextResponse.json(
          { error: 'API key is invalid or missing. Check your ANTHROPIC_API_KEY in .env.local.' },
          { status: 401 }
        );
      if (error.message.includes('rate_limit'))
        return NextResponse.json(
          { error: 'Rate limit reached. Please wait a moment and try again.' },
          { status: 429 }
        );
      return NextResponse.json({ error: error.message }, { status: 500 });
    }
    return NextResponse.json({ error: 'An unexpected error occurred.' }, { status: 500 });
  }
}

Here is the full app/components/DownloadButton.tsx:

'use client';

import { ResumeAnalysis } from './ResumeReviewer';

interface DownloadButtonProps {
  analysis: ResumeAnalysis;
  filename: string;
  jobDescription: string;
}

export default function DownloadButton({ analysis, filename, jobDescription }: DownloadButtonProps) {
  const handleDownload = () => {
    const exportData = {
      metadata: {
        filename,
        jobDescriptionSnippet: jobDescription.slice(0, 200) + (jobDescription.length > 200 ? '...' : ''),
        analyzedAt: new Date().toISOString(),
        model: 'claude-sonnet-5',
      },
      analysis,
    };
    const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `resume-review-${new Date().toISOString().split('T')[0]}.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  };
  return (
    <button onClick={handleDownload} className="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors">
      Download Feedback
    </button>
  );
}

For app/components/ResumeReviewer.tsx, scroll up and confirm you have all three edits in place: the DownloadButton import, the validation checks in handleSubmit, and the updated button/results header. The file is too long to show in one block, so verify each section matches what you added above.

Your app now handles every realistic error case and gives users a portable record of their feedback. Next up, you will tackle the Secret Mission and add a history dashboard.

Secret mission

Review History Dashboard

Right now, every resume review vanishes the moment you start a new one. What if you could track how your resume improves over time? In this secret mission, you will add a review history dashboard that saves past analyses to localStorage, letting users revisit and compare their results.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally with a pay-per-use API key, so there are no ongoing costs unless you actively run resume analyses.

Resources you used:

  • Local Next.js project folder (resume-reviewer) on your Desktop.
  • Anthropic API key with pay-as-you-go billing (~$0.03-0.05 per resume review).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep reviewing resumes.

  • Your dev server keeps running at localhost:3000. You can stop it anytime with Ctrl+C in the terminal and restart with npm run dev.
  • Your Anthropic API key stays active. You are only charged when the app makes API calls.
  • Monitor your usage and remaining credits at console.anthropic.com.

Pause - I'll come back to this later

Shut down the running process to free up your terminal. Your files and API key stay intact for next time.

  • Stop the dev server by pressing Ctrl+C in the terminal where it is running.
  • No other action needed. Your API key has no idle cost. You are only charged when you submit a resume for analysis.
  • When you return, open the resume-reviewer folder and run npm run dev to start again.

Delete - I don't want to use this again

Remove all project resources and revoke your API access. This is irreversible.

  • Stop the dev server by pressing Ctrl+C in the terminal.
  • Revoke your API key. Go to console.anthropic.com, click API Keys in the left sidebar, find the key you created, and click its delete button.
  • Delete the project folder. On macOS/Linux, run:
cd ~/Desktop
rm -rf resume-reviewer
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force resume-reviewer
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see resume-reviewer listed.

Nice Work!

Nice Work!

You just built a full-stack AI Resume Reviewer powered by Claude Sonnet 5. You can upload any resume, paste a job description, and get structured, actionable career feedback in seconds.

You've learned how to:

  • Built a full-stack Next.js 16 app with the App Router that handles file uploads via Route Handlers.
  • Parsed PDF and DOCX documents server-side using pdf-parse and mammoth.
  • Engineered a structured prompt that reliably returns JSON from Claude Sonnet 5, then rendered the AI analysis into a polished, responsive UI with Tailwind CSS.
  • Secret Mission: Implemented a localStorage-based review history dashboard to track resume improvements over time.

Ready to quiz yourself?