All posts
·#Case Study#Next.js#TanStack Query#TypeScript

Case Study: AI Job Search Copilot

A self-hosted dashboard for managing a job search — built to demonstrate production-grade React and Next.js engineering.

A self-hosted dashboard for managing a job search — built to demonstrate production-grade React and Next.js engineering.

Live demo · Source on GitHub

AI Job Search Copilot dashboard showing the Kanban board with job applications organized by stage


Overview

AI Job Search Copilot is a full-stack web app that helps job seekers track applications through a visual pipeline, tailor their CV and cover letter to specific roles using AI, and monitor their progress with live analytics — all without manual spreadsheet upkeep.

I built it to apply the same architectural patterns and tooling I'd use on a senior frontend team: a typed end-to-end data layer, accessible interactive components, optimistic UI, and a test suite covering unit, integration, and end-to-end layers.

Live demo: [https://ai-job-copilot-ochre.vercel.app/] Source code: github.com/vlahou89/ai-job-copilot


The Problem

Tracking job applications across spreadsheets, email threads, and browser bookmarks doesn't scale once you're applying to 5–10 roles a week. I wanted a single place to:

  • See every application at a glance, organized by stage
  • Know how long an application has sat in a stage, without digging through email timestamps
  • Generate a tailored CV summary and cover letter for a specific job description in seconds
  • Track conversion rates (saved → applied → interview → offer) without manual counting

Approach

Architecture

The app is built on Next.js 14 (App Router) with TypeScript throughout. A single data-access module (lib/db.ts) is the only part of the codebase that knows how data is stored — currently a JSON file, but swappable for Postgres without touching components, hooks, or API routes. This separation of concerns is a deliberate choice to keep the door open for a real database without a rewrite.

Zod schemas (job.schema.ts) act as the single source of truth for data shape. The same schema validates client-side form input (via react-hook-form + zodResolver) and server-side API requests — composed into createJobSchema and updateJobSchema using .partial(), .omit(), and .extend() rather than duplicated types.

State Management

TanStack Query manages all server state under a single ["jobs"] cache key. Every view — the Kanban board, the analytics page, the edit dialog — reads from and invalidates that same cache entry. The result: moving a card on the board instantly updates the analytics counts on another tab, with no manual refresh logic or prop-drilling between pages.

Status changes use optimistic updates: the UI reflects the new column immediately, and rolls back automatically if the server request fails — important for a drag-and-drop interaction where lag would feel broken.

Server-Authoritative Data

When a card moves to a new column, the timestamp (statusChangedAt) is set in the API route, never trusted from the client. This protects the integrity of pipeline-velocity metrics (e.g. "average days from Applied to Interview") — a client could otherwise send a fabricated date.

Accessibility

The board supports both drag-and-drop and a keyboard-accessible dropdown menu for moving cards between columns, with proper ARIA labeling — so the core workflow doesn't depend on a mouse.

AI-Assisted CV Tailoring

Users upload a CV as a PDF (parsed server-side with pdf-parse), paste a job description, and receive a tailored summary and cover letter draft — reusing the extracted CV text across multiple tailoring requests without re-uploading.

Tech Stack

LayerChoice
FrameworkNext.js 14 (App Router, Route Handlers)
LanguageTypeScript
StylingTailwind CSS v4, shadcn/ui
Server stateTanStack Query
Forms & validationReact Hook Form, Zod
File parsingpdf-parse
TestingVitest, React Testing Library, Playwright

Key Challenges & Solutions

Challenge: keeping multiple views in sync without real-time infrastructure. Rather than introducing WebSockets for what is fundamentally a single-user tool, I leaned on TanStack Query's cache invalidation — every mutation invalidates ["jobs"], and every subscribed component refetches and re-renders automatically. This gave a "real-time" feel with a fraction of the complexity.

Challenge: editing an existing card without duplicating the creation form. The same JobFormDialog component handles both create and edit flows. An optional job prop switches the Zod schema, the mutation (useCreateJob vs. useUpdateJob), and pre-fills the form via a useEffect that resets react-hook-form state when the prop changes — avoiding a near-identical second component.

Challenge: PDF parsing in a Next.js server environment. pdf-parse and pdfjs-dist ship code that Next.js's bundler doesn't handle well by default (worker file resolution, ESM/CJS export mismatches). Resolved by marking both packages as serverExternalPackages in next.config.ts and switching to pdf-parse's class-based API (new PDFParse({ data }).getText()).

Challenge: drag-and-drop worked locally but didn't persist after deploying to Vercel. This turned into a two-layer debugging problem, and both layers are worth knowing cold for an interview:

  1. Storage layerlib/db.ts originally read/wrote data/jobs.json on disk. That works in local dev, but Vercel's serverless functions run on a read-only, ephemeral filesystem — writes either fail or vanish on the next invocation. The PATCH request was returning a 500. Fix: migrated persistence to Upstash Redis (@upstash/redis, Redis.fromEnv()) behind the same db.ts interface — getJobs/saveJobs/updateJob/deleteJob signatures didn't change, so no component, hook, or API route needed touching. This is the single-data-access-seam decision from the architecture section paying off directly: a storage migration became a one-file change.

  2. Caching layer — after fixing storage, the PATCH started succeeding (200), but the board still visually reverted the card after the drag. Cause: Next.js Route Handlers are cached by default. The optimistic UI update was correct, but the invalidateQueries refetch to GET /api/jobs was served a stale cached response, so the UI "corrected" itself back to the old state — no error, just stale data masquerading as a revert. Fix: export const dynamic = "force-dynamic" on both /api/jobs and /api/jobs/[id] routes, forcing a live read on every request.

The debugging path itself is the talking point: a 500 with no console error initially, then a silent revert with no error at all once the first fix landed — two different failure modes that looked similar from the UI but required reasoning about serverless infrastructure (filesystem persistence) and framework defaults (route caching) rather than component code.

Outcomes

  • Full Kanban pipeline with color-coded stages, edit-in-place, and stage-change history
  • Live analytics with zero manual refresh
  • AI-assisted CV/cover letter tailoring from an uploaded PDF
  • Test coverage across unit (Vitest), integration (RTL), and end-to-end (Playwright) layers
  • Deployed to Vercel with serverless-appropriate persistence (Upstash Redis) and correct cache behavior on data-mutating routes

What I'd Build Next

  • Authentication for multi-user/cloud-hosted use
  • Migration from JSON file storage to Postgres via Prisma
  • Interview preparation question bank tied to each saved role
  • Recruiter/company notes and saved-company list