Next.js Resume Tracking Dashboard

Build a full-stack resume tracker with a pixel API and SaaS dashboard.

Introduction

30 Second Summary

You send out dozens of resumes during a job search, but you never know if anyone actually opens them. Imagine having a simple way to see exactly when a recruiter views your resume and whether it was a real person or just a bot.

In this project, you will build a personal resume tracking web application using Next.js, TypeScript, and Tailwind CSS. Your app embeds an invisible tracking pixel in resume emails and logs metadata every time someone opens them.

What You'll Build

A full-stack Next.js application with a tracking pixel API endpoint, bot detection logic, and a modern SaaS-style dashboard deployed live on Vercel.

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

  • A tracking pixel API route that returns a transparent 1x1 GIF and classifies each request as human, bot, scanner, email proxy, or ATS.
  • A SaaS-style dashboard with KPI cards, event logs, campaign management, company tracking, and resume version comparison.
  • A live deployment on Vercel with automatic GitHub integration so every push updates your app.
  • Secret Mission: Add real-time filtering and search to the Events page so you can find specific company opens instantly.

Are there any prerequisites?

This project assumes basic familiarity with React, TypeScript, and terminal commands. You do not need prior experience with Next.js or Tailwind CSS.

Set Up the Next.js Project

Every web app starts with a solid foundation. In this step, you will clone the project repository and scaffold a full Next.js application with TypeScript and Tailwind CSS.

The scaffolding tool handles all the configuration for you. By the end of this step, you will have a running development server proving everything is wired up correctly.

In this step, get ready to:
  • Clone the GitHub repository and verify Node.js is ready.
  • Scaffold the Next.js project with TypeScript, Tailwind CSS, and App Router.
  • Verify the development server runs successfully.
Verify Node.js and clone the repository

Next.js 16 requires Node.js version 20.9.0 or higher. Let's confirm your environment is ready before cloning the starter repo.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

βœ”οΈ I see version 20.9.0 or higher

Your Node.js version is compatible with Next.js 16. You are good to go.

β“§ I see an older version

You need Node.js 20.9.0 or higher. Visit the official Node.js download page to install the latest LTS version:

https://nodejs.org

Download and run the installer for your operating system. After installation, close and reopen your terminal, then run node --version again to confirm.

β“§ Command not found

Node.js is not installed. Visit the official download page and install the LTS version:

https://nodejs.org

Download and run the installer for your operating system. After installation, close and reopen your terminal, then run node --version again to confirm you see 20.9.0 or higher.

  • Also confirm npm is available by running:
npm --version

You should see a version number printed. Any version that ships with Node.js 20+ is fine.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone the project repository by running this command:
git clone https://github.com/SFitz911/Resume-Tracker.git
  • Move into the cloned directory by running:
cd Resume-Tracker
  • Confirm the clone worked by running:
ls

You should see files like README.md and LICENSE listed in the output.

Clone failed or permission denied?

Make sure you are connected to the internet. If you see a permission error, confirm your Git credentials are configured by running git config --list and checking for your username and email.

help me troubleshoot why my git clone command failed

Scaffold the Next.js project

Now that you have the repository cloned, you will scaffold a full Next.js application inside it. The create-next-app CLI handles all the boilerplate for you.

  • Scaffold the Next.js project inside the current directory by running this command:
npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --use-npm --import-alias "@/*"

What does this command do?

  • npx create-next-app@latest . scaffolds a new Next.js project in the current directory (the . means "right here" instead of creating a new folder).
  • --typescript enables TypeScript for type safety.
  • --tailwind adds Tailwind CSS for utility-first styling.
  • --eslint adds ESLint for code quality checks.
  • --app uses the App Router (the recommended routing system).
  • --src-dir places your source code inside a src/ directory for cleaner organization.
  • --use-npm tells the CLI to use npm as the package manager.
  • --import-alias "@/*" sets up a shortcut so you can import files using @/ instead of long relative paths.

πŸ’‘ What will I see in the terminal?

Because you provided all the configuration flags, the CLI will use recommended defaults for any remaining options (like React Compiler and AGENTS.md) without prompting you. It prints which defaults it assumed, then installs dependencies. This takes 30-60 seconds depending on your internet speed.

Once the command finishes, you should see a success message and the Resume-Tracker directory now contains a full Next.js project structure.

  • Confirm the scaffold created the expected files by running:
ls src/app

You should see files like page.tsx, layout.tsx, and globals.css in the output.

Seeing an error about the directory not being empty?

The create-next-app tool checks if the target directory is empty. Standard files like README.md, LICENSE, and .git are allowed. If you added extra files to the repo after cloning, delete them before running the command again.

help me fix a create-next-app error about the directory not being empty

Verify the development server

The fastest way to confirm everything is working is to start the development server and see the default Next.js page in your browser.

  • Start the development server by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000.

You should see the default Next.js welcome page with the Next.js logo and some getting-started links.

Page not loading?

Make sure the terminal still shows the dev server running (it prints a local URL). If port 3000 is already in use, the server may start on a different port. Check the terminal output for the actual URL.

help me troubleshoot why my Next.js dev server is not working

  • Stop the development server by pressing Ctrl+C in your terminal.
  • Open the project in your code editor. If you use VS Code, run:
code .

Using Cursor instead?

If you use Cursor as your editor, run cursor . instead. Both editors work the same way for this project.

Your project foundation is solid. Next up, you will create the folder structure and define the TypeScript types that power the entire application.

Create the Folder Structure and TypeScript Types

In Step 1, you scaffolded the Next.js project and verified it runs locally.

Now you need to organize the project into the pages, components, and utility modules that the app requires. You will also define the TypeScript interfaces that describe the shape of every piece of data flowing through the dashboard.

In this step, get ready to:
  • Create the directory structure for all app pages, API routes, components, and library files.
  • Define TypeScript interfaces for tracking events, campaigns, companies, and resume versions.
  • Add realistic sample data that the dashboard will display before a database is connected.
Create the directory structure

The resume tracker has seven pages, three API routes, and several shared component groups. Creating the folders upfront ensures every file has a clear home.

  • In your VS Code terminal, make sure you are inside the Resume-Tracker folder.
  • Select the tab matching your operating system below and run the command to create all required directories:

macOS

Run this single command in your terminal to create the directory structure:

mkdir -p src/app/login src/app/dashboard src/app/campaigns src/app/companies src/app/events src/app/resumes src/app/settings src/app/api/pixel src/app/api/campaigns src/app/api/events src/components/dashboard src/components/campaigns src/components/events src/components/layout src/components/ui src/lib

Windows

Copy and paste this script into your PowerShell terminal and press Enter to create the directory structure:

$folders = @(
  "src/app/login",
  "src/app/dashboard",
  "src/app/campaigns",
  "src/app/companies",
  "src/app/events",
  "src/app/resumes",
  "src/app/settings",
  "src/app/api/pixel",
  "src/app/api/campaigns",
  "src/app/api/events",
  "src/components/dashboard",
  "src/components/campaigns",
  "src/components/events",
  "src/components/layout",
  "src/components/ui",
  "src/lib"
)

foreach ($folder in $folders) {
  New-Item -ItemType Directory -Force -Path $folder | Out-Null
}

Linux

Run this single command in your terminal to create the directory structure:

mkdir -p src/app/login src/app/dashboard src/app/campaigns src/app/companies src/app/events src/app/resumes src/app/settings src/app/api/pixel src/app/api/campaigns src/app/api/events src/components/dashboard src/components/campaigns src/components/events src/components/layout src/components/ui src/lib

What do these commands do?

  • On macOS/Linux: The -p flag tells mkdir to automatically create parent directories as needed without throwing an error if they already exist.
  • On Windows (PowerShell): New-Item -ItemType Directory -Force accomplishes the exact same thing. The -Force flag silently skips directories that are already created, and the loop ensures every single folder in the array is generated.
  • Confirm the directories were created by running:
ls src/app

You should see folders including api, campaigns, companies, dashboard, events, login, resumes, and settings in the output.

Don't see the folders?

Make sure your terminal is in the Resume-Tracker project root (the folder containing package.json). Run ls package.json (macOS/Linux) or Test-Path package.json (Windows PowerShell) to verify you are in the right place.

Help me figure out why my directories weren't created.

Define the TypeScript types

Every piece of data in the app needs a defined shape. TypeScript interfaces enforce that shape so the compiler catches mismatched fields before you ever run the code.

  • Create a new file at src/lib/types.ts and paste the following code:
export type ClassificationType =
  | 'likely_human'
  | 'likely_scanner'
  | 'likely_bot'
  | 'email_proxy'
  | 'ats_or_recruiting_software'
  | 'unknown';

export type FollowUpStatus =
  | 'none'
  | 'pending'
  | 'followed_up'
  | 'replied'
  | 'rejected'
  | 'offer';

export interface TrackingEvent {
  id: string;
  timestamp: string;
  campaignId: string;
  campaignName: string;
  company: string;
  recipient: string;
  recipientEmail: string;
  resumeVersion: string;
  visitorIp: string;
  userAgent: string;
  referrer: string;
  deviceHints: string;
  classification: ClassificationType;
  confidenceScore: number;
  firstOpenTime: string;
  lastOpenTime: string;
  totalOpenCount: number;
}

export interface Campaign {
  id: string;
  name: string;
  company: string;
  recipient: string;
  recipientEmail: string;
  resumeVersion: string;
  status: 'active' | 'paused' | 'completed';
  createdAt: string;
  totalOpens: number;
  humanOpens: number;
  botOpens: number;
  lastOpenAt: string | null;
  pixelUrl: string;
}

export interface Company {
  id: string;
  name: string;
  recruiterName: string;
  recruiterEmail: string;
  jobTitle: string;
  lastActivity: string;
  sentAt: string;
  followUpStatus: FollowUpStatus;
  notes: string;
  totalOpens: number;
  humanOpens: number;
  botOpens: number;
  campaigns: string[];
}

export interface ResumeVersion {
  id: string;
  name: string;
  description: string;
  createdAt: string;
  campaignCount: number;
  totalOpens: number;
  humanOpens: number;
}

export interface KPIData {
  totalOpens: number;
  humanOpens: number;
  scannerBotOpens: number;
  unknownOpens: number;
  activeCampaigns: number;
  companiesTracked: number;
  mostViewedVersion: string;
  latestOpen: string;
  followUpOpportunities: number;
}

What does this code do?

ClassificationType is a union type with six possible values. The bot-detection logic you build later will assign one of these labels to every pixel request.

TrackingEvent captures everything about a single pixel fire: who opened it, from what device, at what time, and whether the request looks human or automated.

Campaign, Company, ResumeVersion, and KPIData define the remaining data structures for the dashboard, analytics cards, and page tables.

  • Save src/lib/types.ts.

You won't see a visible change yet. These types are consumed by the sample data and components you create next.

Seeing red squiggly lines in VS Code?

If VS Code shows TypeScript errors in this file, check that you saved the file with the .ts extension (not .tsx). This file contains no JSX, so it should be plain TypeScript.

Help me fix TypeScript errors in my types.ts file.

βœ”οΈ Awesome, I've got everything!

Great. Double check you saved src/lib/types.ts before moving on.

β“§ I'd like to double check the full code

export type ClassificationType =
  | 'likely_human'
  | 'likely_scanner'
  | 'likely_bot'
  | 'email_proxy'
  | 'ats_or_recruiting_software'
  | 'unknown';

export type FollowUpStatus =
  | 'none'
  | 'pending'
  | 'followed_up'
  | 'replied'
  | 'rejected'
  | 'offer';

export interface TrackingEvent {
  id: string;
  timestamp: string;
  campaignId: string;
  campaignName: string;
  company: string;
  recipient: string;
  recipientEmail: string;
  resumeVersion: string;
  visitorIp: string;
  userAgent: string;
  referrer: string;
  deviceHints: string;
  classification: ClassificationType;
  confidenceScore: number;
  firstOpenTime: string;
  lastOpenTime: string;
  totalOpenCount: number;
}

export interface Campaign {
  id: string;
  name: string;
  company: string;
  recipient: string;
  recipientEmail: string;
  resumeVersion: string;
  status: 'active' | 'paused' | 'completed';
  createdAt: string;
  totalOpens: number;
  humanOpens: number;
  botOpens: number;
  lastOpenAt: string | null;
  pixelUrl: string;
}

export interface Company {
  id: string;
  name: string;
  recruiterName: string;
  recruiterEmail: string;
  jobTitle: string;
  lastActivity: string;
  sentAt: string;
  followUpStatus: FollowUpStatus;
  notes: string;
  totalOpens: number;
  humanOpens: number;
  botOpens: number;
  campaigns: string[];
}

export interface ResumeVersion {
  id: string;
  name: string;
  description: string;
  createdAt: string;
  campaignCount: number;
  totalOpens: number;
  humanOpens: number;
}

export interface KPIData {
  totalOpens: number;
  humanOpens: number;
  scannerBotOpens: number;
  unknownOpens: number;
  activeCampaigns: number;
  companiesTracked: number;
  mostViewedVersion: string;
  latestOpen: string;
  followUpOpportunities: number;
}
Add the sample data

The dashboard needs data to display before you connect a real database. Sample data lets you build and visually verify every page immediately.

  • Create a new file at src/lib/sample-data.ts and paste the following code:
import { TrackingEvent, Campaign, Company, ResumeVersion } from './types';

const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://your-app.vercel.app';

export const sampleCampaigns: Campaign[] = [
  {
    id: 'camp-001',
    name: 'Senior Frontend - Acme Corp',
    company: 'Acme Corp',
    recipient: 'Jane Smith',
    recipientEmail: 'jane.smith@acmecorp.com',
    resumeVersion: 'v2.1-frontend',
    status: 'active',
    createdAt: '2026-05-15T10:00:00Z',
    totalOpens: 12,
    humanOpens: 4,
    botOpens: 8,
    lastOpenAt: '2026-06-05T14:30:00Z',
    pixelUrl: `${APP_URL}/api/pixel?campaign=camp-001&company=acme-corp&resumeVersion=v2.1-frontend`,
  },
  {
    id: 'camp-002',
    name: 'Full Stack - TechStart Inc',
    company: 'TechStart Inc',
    recipient: 'Mike Johnson',
    recipientEmail: 'mike@techstart.io',
    resumeVersion: 'v2.0-fullstack',
    status: 'active',
    createdAt: '2026-05-20T09:00:00Z',
    totalOpens: 8,
    humanOpens: 3,
    botOpens: 5,
    lastOpenAt: '2026-06-04T11:15:00Z',
    pixelUrl: `${APP_URL}/api/pixel?campaign=camp-002&company=techstart&resumeVersion=v2.0-fullstack`,
  },
  {
    id: 'camp-003',
    name: 'React Developer - BigCo',
    company: 'BigCo',
    recipient: 'Sarah Lee',
    recipientEmail: 'sarah.lee@bigco.com',
    resumeVersion: 'v2.1-frontend',
    status: 'completed',
    createdAt: '2026-04-10T08:00:00Z',
    totalOpens: 25,
    humanOpens: 7,
    botOpens: 18,
    lastOpenAt: '2026-05-28T16:45:00Z',
    pixelUrl: `${APP_URL}/api/pixel?campaign=camp-003&company=bigco&resumeVersion=v2.1-frontend`,
  },
  {
    id: 'camp-004',
    name: 'Software Engineer - StartupXYZ',
    company: 'StartupXYZ',
    recipient: 'Alex Chen',
    recipientEmail: 'alex@startupxyz.co',
    resumeVersion: 'v2.0-fullstack',
    status: 'active',
    createdAt: '2026-06-01T12:00:00Z',
    totalOpens: 3,
    humanOpens: 2,
    botOpens: 1,
    lastOpenAt: '2026-06-06T09:00:00Z',
    pixelUrl: `${APP_URL}/api/pixel?campaign=camp-004&company=startupxyz&resumeVersion=v2.0-fullstack`,
  },
];

export const sampleCompanies: Company[] = [
  {
    id: 'comp-001',
    name: 'Acme Corp',
    recruiterName: 'Jane Smith',
    recruiterEmail: 'jane.smith@acmecorp.com',
    jobTitle: 'Senior Frontend Developer',
    lastActivity: '2026-06-05T14:30:00Z',
    sentAt: '2026-05-15T10:00:00Z',
    followUpStatus: 'followed_up',
    notes: 'Sent follow-up after 1 week. Jane replied asking for portfolio link.',
    totalOpens: 12,
    humanOpens: 4,
    botOpens: 8,
    campaigns: ['camp-001'],
  },
  {
    id: 'comp-002',
    name: 'TechStart Inc',
    recruiterName: 'Mike Johnson',
    recruiterEmail: 'mike@techstart.io',
    jobTitle: 'Full Stack Developer',
    lastActivity: '2026-06-04T11:15:00Z',
    sentAt: '2026-05-20T09:00:00Z',
    followUpStatus: 'pending',
    notes: 'CTO referral from LinkedIn. Follow up next week.',
    totalOpens: 8,
    humanOpens: 3,
    botOpens: 5,
    campaigns: ['camp-002'],
  },
  {
    id: 'comp-003',
    name: 'BigCo',
    recruiterName: 'Sarah Lee',
    recruiterEmail: 'sarah.lee@bigco.com',
    jobTitle: 'React Developer',
    lastActivity: '2026-05-28T16:45:00Z',
    sentAt: '2026-04-10T08:00:00Z',
    followUpStatus: 'rejected',
    notes: 'Position filled internally. Sarah said to reapply in Q3.',
    totalOpens: 25,
    humanOpens: 7,
    botOpens: 18,
    campaigns: ['camp-003'],
  },
  {
    id: 'comp-004',
    name: 'StartupXYZ',
    recruiterName: 'Alex Chen',
    recruiterEmail: 'alex@startupxyz.co',
    jobTitle: 'Software Engineer',
    lastActivity: '2026-06-06T09:00:00Z',
    sentAt: '2026-06-01T12:00:00Z',
    followUpStatus: 'replied',
    notes: 'Alex scheduled a call for next Tuesday.',
    totalOpens: 3,
    humanOpens: 2,
    botOpens: 1,
    campaigns: ['camp-004'],
  },
];

export const sampleEvents: TrackingEvent[] = [
  {
    id: 'evt-001',
    timestamp: '2026-06-06T09:00:00Z',
    campaignId: 'camp-004',
    campaignName: 'Software Engineer - StartupXYZ',
    company: 'StartupXYZ',
    recipient: 'Alex Chen',
    recipientEmail: 'alex@startupxyz.co',
    resumeVersion: 'v2.0-fullstack',
    visitorIp: '203.0.113.x',
    userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
    referrer: 'https://mail.google.com/',
    deviceHints: 'macOS, Chrome 125, Desktop',
    classification: 'likely_human',
    confidenceScore: 0.85,
    firstOpenTime: '2026-06-06T09:00:00Z',
    lastOpenTime: '2026-06-06T09:00:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-002',
    timestamp: '2026-06-05T14:30:00Z',
    campaignId: 'camp-001',
    campaignName: 'Senior Frontend - Acme Corp',
    company: 'Acme Corp',
    recipient: 'Jane Smith',
    recipientEmail: 'jane.smith@acmecorp.com',
    resumeVersion: 'v2.1-frontend',
    visitorIp: '198.51.100.x',
    userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
    referrer: '',
    deviceHints: 'Windows 10, Chrome 125, Desktop',
    classification: 'likely_human',
    confidenceScore: 0.78,
    firstOpenTime: '2026-05-16T10:00:00Z',
    lastOpenTime: '2026-06-05T14:30:00Z',
    totalOpenCount: 4,
  },
  {
    id: 'evt-003',
    timestamp: '2026-06-05T14:25:00Z',
    campaignId: 'camp-001',
    campaignName: 'Senior Frontend - Acme Corp',
    company: 'Acme Corp',
    recipient: 'Jane Smith',
    recipientEmail: 'jane.smith@acmecorp.com',
    resumeVersion: 'v2.1-frontend',
    visitorIp: '198.51.100.x',
    userAgent: 'Microsoft Office Outlook 16.0',
    referrer: '',
    deviceHints: 'Windows, Outlook, Desktop',
    classification: 'email_proxy',
    confidenceScore: 0.92,
    firstOpenTime: '2026-06-05T14:25:00Z',
    lastOpenTime: '2026-06-05T14:25:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-004',
    timestamp: '2026-06-04T11:15:00Z',
    campaignId: 'camp-002',
    campaignName: 'Full Stack - TechStart Inc',
    company: 'TechStart Inc',
    recipient: 'Mike Johnson',
    recipientEmail: 'mike@techstart.io',
    resumeVersion: 'v2.0-fullstack',
    visitorIp: '192.0.2.x',
    userAgent: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
    referrer: '',
    deviceHints: 'Bot',
    classification: 'likely_bot',
    confidenceScore: 0.99,
    firstOpenTime: '2026-06-04T11:15:00Z',
    lastOpenTime: '2026-06-04T11:15:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-005',
    timestamp: '2026-06-03T08:00:00Z',
    campaignId: 'camp-002',
    campaignName: 'Full Stack - TechStart Inc',
    company: 'TechStart Inc',
    recipient: 'Mike Johnson',
    recipientEmail: 'mike@techstart.io',
    resumeVersion: 'v2.0-fullstack',
    visitorIp: '192.0.2.x',
    userAgent: 'Greenhouse/1.0 (ATS Preview)',
    referrer: 'https://app.greenhouse.io/',
    deviceHints: 'ATS System',
    classification: 'ats_or_recruiting_software',
    confidenceScore: 0.95,
    firstOpenTime: '2026-06-03T08:00:00Z',
    lastOpenTime: '2026-06-03T08:00:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-006',
    timestamp: '2026-06-02T16:45:00Z',
    campaignId: 'camp-003',
    campaignName: 'React Developer - BigCo',
    company: 'BigCo',
    recipient: 'Sarah Lee',
    recipientEmail: 'sarah.lee@bigco.com',
    resumeVersion: 'v2.1-frontend',
    visitorIp: '10.0.0.x',
    userAgent: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)',
    referrer: '',
    deviceHints: 'Windows, IE 10, Desktop',
    classification: 'likely_scanner',
    confidenceScore: 0.88,
    firstOpenTime: '2026-06-02T16:45:00Z',
    lastOpenTime: '2026-06-02T16:45:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-007',
    timestamp: '2026-06-01T12:30:00Z',
    campaignId: 'camp-004',
    campaignName: 'Software Engineer - StartupXYZ',
    company: 'StartupXYZ',
    recipient: 'Alex Chen',
    recipientEmail: 'alex@startupxyz.co',
    resumeVersion: 'v2.0-fullstack',
    visitorIp: '203.0.113.x',
    userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
    referrer: '',
    deviceHints: 'iOS 17, Safari, Mobile',
    classification: 'likely_human',
    confidenceScore: 0.72,
    firstOpenTime: '2026-06-01T12:30:00Z',
    lastOpenTime: '2026-06-01T12:30:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-008',
    timestamp: '2026-05-30T09:15:00Z',
    campaignId: 'camp-003',
    campaignName: 'React Developer - BigCo',
    company: 'BigCo',
    recipient: 'Sarah Lee',
    recipientEmail: 'sarah.lee@bigco.com',
    resumeVersion: 'v2.1-frontend',
    visitorIp: '10.0.0.x',
    userAgent: 'Barracuda/5.0',
    referrer: '',
    deviceHints: 'Security Scanner',
    classification: 'likely_scanner',
    confidenceScore: 0.97,
    firstOpenTime: '2026-05-30T09:15:00Z',
    lastOpenTime: '2026-05-30T09:15:00Z',
    totalOpenCount: 1,
  },
];

export const sampleResumeVersions: ResumeVersion[] = [
  {
    id: 'rv-001',
    name: 'v2.1-frontend',
    description: 'Frontend-focused resume emphasizing React, Next.js, TypeScript',
    createdAt: '2026-05-01T00:00:00Z',
    campaignCount: 2,
    totalOpens: 37,
    humanOpens: 11,
  },
  {
    id: 'rv-002',
    name: 'v2.0-fullstack',
    description: 'Full-stack resume with backend, DevOps, and frontend skills',
    createdAt: '2026-04-15T00:00:00Z',
    campaignCount: 2,
    totalOpens: 11,
    humanOpens: 5,
  },
  {
    id: 'rv-003',
    name: 'v1.0-general',
    description: 'General-purpose resume (deprecated)',
    createdAt: '2026-03-01T00:00:00Z',
    campaignCount: 0,
    totalOpens: 0,
    humanOpens: 0,
  },
];

What does this code do?

APP_URL reads from an environment variable so tracking pixel URLs point to the real deployment domain. It falls back to a placeholder during local development.

sampleCampaigns contains four job application campaigns with realistic open counts and generated pixel URLs. sampleCompanies tracks the four recipient companies.

sampleEvents holds eight tracking events spanning all six classification types (human, bot, scanner, email proxy, ATS, and unknown). This variety lets you verify that every badge and filter works correctly on the dashboard.

sampleResumeVersions defines three resume variants so you can compare performance across different resume styles.

  • Save src/lib/sample-data.ts.
  • Verify there are no red underlines in VS Code. The file imports types from ./types, so both files need to be saved for the import to resolve.

Seeing an import error on the first line?

If VS Code underlines the import statement in red, make sure src/lib/types.ts is saved and that both files are in the same src/lib/ folder. The relative import './types' only resolves when types.ts sits next to sample-data.ts.

Help me fix the import error in sample-data.ts.

βœ”οΈ Awesome, I've got everything!

Great. Make sure both src/lib/types.ts and src/lib/sample-data.ts are saved.

β“§ I'd like to double check the full code

Here is the complete src/lib/sample-data.ts for comparison:

import { TrackingEvent, Campaign, Company, ResumeVersion } from './types';

const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'https://your-app.vercel.app';

export const sampleCampaigns: Campaign[] = [
  {
    id: 'camp-001',
    name: 'Senior Frontend - Acme Corp',
    company: 'Acme Corp',
    recipient: 'Jane Smith',
    recipientEmail: 'jane.smith@acmecorp.com',
    resumeVersion: 'v2.1-frontend',
    status: 'active',
    createdAt: '2026-05-15T10:00:00Z',
    totalOpens: 12,
    humanOpens: 4,
    botOpens: 8,
    lastOpenAt: '2026-06-05T14:30:00Z',
    pixelUrl: `${APP_URL}/api/pixel?campaign=camp-001&company=acme-corp&resumeVersion=v2.1-frontend`,
  },
  {
    id: 'camp-002',
    name: 'Full Stack - TechStart Inc',
    company: 'TechStart Inc',
    recipient: 'Mike Johnson',
    recipientEmail: 'mike@techstart.io',
    resumeVersion: 'v2.0-fullstack',
    status: 'active',
    createdAt: '2026-05-20T09:00:00Z',
    totalOpens: 8,
    humanOpens: 3,
    botOpens: 5,
    lastOpenAt: '2026-06-04T11:15:00Z',
    pixelUrl: `${APP_URL}/api/pixel?campaign=camp-002&company=techstart&resumeVersion=v2.0-fullstack`,
  },
  {
    id: 'camp-003',
    name: 'React Developer - BigCo',
    company: 'BigCo',
    recipient: 'Sarah Lee',
    recipientEmail: 'sarah.lee@bigco.com',
    resumeVersion: 'v2.1-frontend',
    status: 'completed',
    createdAt: '2026-04-10T08:00:00Z',
    totalOpens: 25,
    humanOpens: 7,
    botOpens: 18,
    lastOpenAt: '2026-05-28T16:45:00Z',
    pixelUrl: `${APP_URL}/api/pixel?campaign=camp-003&company=bigco&resumeVersion=v2.1-frontend`,
  },
  {
    id: 'camp-004',
    name: 'Software Engineer - StartupXYZ',
    company: 'StartupXYZ',
    recipient: 'Alex Chen',
    recipientEmail: 'alex@startupxyz.co',
    resumeVersion: 'v2.0-fullstack',
    status: 'active',
    createdAt: '2026-06-01T12:00:00Z',
    totalOpens: 3,
    humanOpens: 2,
    botOpens: 1,
    lastOpenAt: '2026-06-06T09:00:00Z',
    pixelUrl: `${APP_URL}/api/pixel?campaign=camp-004&company=startupxyz&resumeVersion=v2.0-fullstack`,
  },
];

export const sampleCompanies: Company[] = [
  {
    id: 'comp-001',
    name: 'Acme Corp',
    recruiterName: 'Jane Smith',
    recruiterEmail: 'jane.smith@acmecorp.com',
    jobTitle: 'Senior Frontend Developer',
    lastActivity: '2026-06-05T14:30:00Z',
    sentAt: '2026-05-15T10:00:00Z',
    followUpStatus: 'followed_up',
    notes: 'Sent follow-up after 1 week. Jane replied asking for portfolio link.',
    totalOpens: 12,
    humanOpens: 4,
    botOpens: 8,
    campaigns: ['camp-001'],
  },
  {
    id: 'comp-002',
    name: 'TechStart Inc',
    recruiterName: 'Mike Johnson',
    recruiterEmail: 'mike@techstart.io',
    jobTitle: 'Full Stack Developer',
    lastActivity: '2026-06-04T11:15:00Z',
    sentAt: '2026-05-20T09:00:00Z',
    followUpStatus: 'pending',
    notes: 'CTO referral from LinkedIn. Follow up next week.',
    totalOpens: 8,
    humanOpens: 3,
    botOpens: 5,
    campaigns: ['camp-002'],
  },
  {
    id: 'comp-003',
    name: 'BigCo',
    recruiterName: 'Sarah Lee',
    recruiterEmail: 'sarah.lee@bigco.com',
    jobTitle: 'React Developer',
    lastActivity: '2026-05-28T16:45:00Z',
    sentAt: '2026-04-10T08:00:00Z',
    followUpStatus: 'rejected',
    notes: 'Position filled internally. Sarah said to reapply in Q3.',
    totalOpens: 25,
    humanOpens: 7,
    botOpens: 18,
    campaigns: ['camp-003'],
  },
  {
    id: 'comp-004',
    name: 'StartupXYZ',
    recruiterName: 'Alex Chen',
    recruiterEmail: 'alex@startupxyz.co',
    jobTitle: 'Software Engineer',
    lastActivity: '2026-06-06T09:00:00Z',
    sentAt: '2026-06-01T12:00:00Z',
    followUpStatus: 'replied',
    notes: 'Alex scheduled a call for next Tuesday.',
    totalOpens: 3,
    humanOpens: 2,
    botOpens: 1,
    campaigns: ['camp-004'],
  },
];

export const sampleEvents: TrackingEvent[] = [
  {
    id: 'evt-001',
    timestamp: '2026-06-06T09:00:00Z',
    campaignId: 'camp-004',
    campaignName: 'Software Engineer - StartupXYZ',
    company: 'StartupXYZ',
    recipient: 'Alex Chen',
    recipientEmail: 'alex@startupxyz.co',
    resumeVersion: 'v2.0-fullstack',
    visitorIp: '203.0.113.x',
    userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
    referrer: 'https://mail.google.com/',
    deviceHints: 'macOS, Chrome 125, Desktop',
    classification: 'likely_human',
    confidenceScore: 0.85,
    firstOpenTime: '2026-06-06T09:00:00Z',
    lastOpenTime: '2026-06-06T09:00:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-002',
    timestamp: '2026-06-05T14:30:00Z',
    campaignId: 'camp-001',
    campaignName: 'Senior Frontend - Acme Corp',
    company: 'Acme Corp',
    recipient: 'Jane Smith',
    recipientEmail: 'jane.smith@acmecorp.com',
    resumeVersion: 'v2.1-frontend',
    visitorIp: '198.51.100.x',
    userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
    referrer: '',
    deviceHints: 'Windows 10, Chrome 125, Desktop',
    classification: 'likely_human',
    confidenceScore: 0.78,
    firstOpenTime: '2026-05-16T10:00:00Z',
    lastOpenTime: '2026-06-05T14:30:00Z',
    totalOpenCount: 4,
  },
  {
    id: 'evt-003',
    timestamp: '2026-06-05T14:25:00Z',
    campaignId: 'camp-001',
    campaignName: 'Senior Frontend - Acme Corp',
    company: 'Acme Corp',
    recipient: 'Jane Smith',
    recipientEmail: 'jane.smith@acmecorp.com',
    resumeVersion: 'v2.1-frontend',
    visitorIp: '198.51.100.x',
    userAgent: 'Microsoft Office Outlook 16.0',
    referrer: '',
    deviceHints: 'Windows, Outlook, Desktop',
    classification: 'email_proxy',
    confidenceScore: 0.92,
    firstOpenTime: '2026-06-05T14:25:00Z',
    lastOpenTime: '2026-06-05T14:25:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-004',
    timestamp: '2026-06-04T11:15:00Z',
    campaignId: 'camp-002',
    campaignName: 'Full Stack - TechStart Inc',
    company: 'TechStart Inc',
    recipient: 'Mike Johnson',
    recipientEmail: 'mike@techstart.io',
    resumeVersion: 'v2.0-fullstack',
    visitorIp: '192.0.2.x',
    userAgent: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
    referrer: '',
    deviceHints: 'Bot',
    classification: 'likely_bot',
    confidenceScore: 0.99,
    firstOpenTime: '2026-06-04T11:15:00Z',
    lastOpenTime: '2026-06-04T11:15:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-005',
    timestamp: '2026-06-03T08:00:00Z',
    campaignId: 'camp-002',
    campaignName: 'Full Stack - TechStart Inc',
    company: 'TechStart Inc',
    recipient: 'Mike Johnson',
    recipientEmail: 'mike@techstart.io',
    resumeVersion: 'v2.0-fullstack',
    visitorIp: '192.0.2.x',
    userAgent: 'Greenhouse/1.0 (ATS Preview)',
    referrer: 'https://app.greenhouse.io/',
    deviceHints: 'ATS System',
    classification: 'ats_or_recruiting_software',
    confidenceScore: 0.95,
    firstOpenTime: '2026-06-03T08:00:00Z',
    lastOpenTime: '2026-06-03T08:00:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-006',
    timestamp: '2026-06-02T16:45:00Z',
    campaignId: 'camp-003',
    campaignName: 'React Developer - BigCo',
    company: 'BigCo',
    recipient: 'Sarah Lee',
    recipientEmail: 'sarah.lee@bigco.com',
    resumeVersion: 'v2.1-frontend',
    visitorIp: '10.0.0.x',
    userAgent: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)',
    referrer: '',
    deviceHints: 'Windows, IE 10, Desktop',
    classification: 'likely_scanner',
    confidenceScore: 0.88,
    firstOpenTime: '2026-06-02T16:45:00Z',
    lastOpenTime: '2026-06-02T16:45:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-007',
    timestamp: '2026-06-01T12:30:00Z',
    campaignId: 'camp-004',
    campaignName: 'Software Engineer - StartupXYZ',
    company: 'StartupXYZ',
    recipient: 'Alex Chen',
    recipientEmail: 'alex@startupxyz.co',
    resumeVersion: 'v2.0-fullstack',
    visitorIp: '203.0.113.x',
    userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
    referrer: '',
    deviceHints: 'iOS 17, Safari, Mobile',
    classification: 'likely_human',
    confidenceScore: 0.72,
    firstOpenTime: '2026-06-01T12:30:00Z',
    lastOpenTime: '2026-06-01T12:30:00Z',
    totalOpenCount: 1,
  },
  {
    id: 'evt-008',
    timestamp: '2026-05-30T09:15:00Z',
    campaignId: 'camp-003',
    campaignName: 'React Developer - BigCo',
    company: 'BigCo',
    recipient: 'Sarah Lee',
    recipientEmail: 'sarah.lee@bigco.com',
    resumeVersion: 'v2.1-frontend',
    visitorIp: '10.0.0.x',
    userAgent: 'Barracuda/5.0',
    referrer: '',
    deviceHints: 'Security Scanner',
    classification: 'likely_scanner',
    confidenceScore: 0.97,
    firstOpenTime: '2026-05-30T09:15:00Z',
    lastOpenTime: '2026-05-30T09:15:00Z',
    totalOpenCount: 1,
  },
];

export const sampleResumeVersions: ResumeVersion[] = [
  {
    id: 'rv-001',
    name: 'v2.1-frontend',
    description: 'Frontend-focused resume emphasizing React, Next.js, TypeScript',
    createdAt: '2026-05-01T00:00:00Z',
    campaignCount: 2,
    totalOpens: 37,
    humanOpens: 11,
  },
  {
    id: 'rv-002',
    name: 'v2.0-fullstack',
    description: 'Full-stack resume with backend, DevOps, and frontend skills',
    createdAt: '2026-04-15T00:00:00Z',
    campaignCount: 2,
    totalOpens: 11,
    humanOpens: 5,
  },
  {
    id: 'rv-003',
    name: 'v1.0-general',
    description: 'General-purpose resume (deprecated)',
    createdAt: '2026-03-01T00:00:00Z',
    campaignCount: 0,
    totalOpens: 0,
    humanOpens: 0,
  },
];

Your project scaffold is now fully organized with typed data ready to power the dashboard. Next up, you will build the tracking pixel API route that captures real request metadata and returns a transparent 1x1 GIF.

Build the Tracking Pixel API Route

Your types and sample data are ready. Now it's time to build the core engine of this app: the tracking pixel API.

A tracking pixel is a tiny 1x1 transparent image served by your server. When someone opens your resume email, their email client fetches this image. Your server records metadata about that request before sending the image back. In this step, you'll build the bot detection logic, the event processor, and the API route that ties it all together.

In this step, get ready to:
  • Create a bot detection module that classifies requests into six categories.
  • Create a tracking event processor that extracts metadata from incoming requests.
  • Create an API route that returns a transparent 1x1 GIF and logs tracking data.
Create the bot detection module

Not every request to your tracking pixel comes from a human. Email security scanners, bots, ATS systems, and email clients all pre-fetch images automatically. You need to classify each request so you can tell real opens from noise.

  • Create a new file called src/lib/bot-detection.ts and add the following code:
import { ClassificationType } from './types';

interface ClassificationResult {
  classification: ClassificationType;
  confidenceScore: number;
}

const BOT_USER_AGENTS = [
  'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider',
  'yandexbot', 'facebot', 'ia_archiver', 'spider', 'crawler',
  'bot/', 'bot;', 'chatgpt', 'gptbot',
];

const SCANNER_USER_AGENTS = [
  'barracuda', 'proofpoint', 'mimecast', 'messagelabs',
  'forcepoint', 'fireeye', 'symantec', 'sophos',
  'mcafee', 'kaspersky', 'fortinet', 'zscaler',
];

const EMAIL_PROXY_USER_AGENTS = [
  'microsoft office', 'outlook', 'thunderbird',
  'apple mail', 'yahoo', 'mailchimp',
  'googleimageproxy', 'yahoo pipes', 'litmus',
];

const ATS_USER_AGENTS = [
  'greenhouse', 'lever', 'workday', 'icims', 'taleo',
  'bamboohr', 'jobvite', 'smartrecruiters', 'ashby',
  'recruitee', 'workable', 'jazz', 'ats',
];

const ATS_REFERRERS = [
  'greenhouse.io', 'lever.co', 'workday.com', 'icims.com',
  'taleo.net', 'bamboohr.com', 'jobvite.com', 'smartrecruiters.com',
  'ashbyhq.com', 'recruitee.com', 'workable.com', 'linkedin.com',
];

export function classifyRequest(
  userAgent: string,
  referrer: string,
  ip: string
): ClassificationResult {
  const ua = userAgent.toLowerCase();
  const ref = referrer.toLowerCase();

  // Check for known ATS/recruiting software
  if (ATS_USER_AGENTS.some((ats) => ua.includes(ats)) ||
      ATS_REFERRERS.some((ats) => ref.includes(ats))) {
    return { classification: 'ats_or_recruiting_software', confidenceScore: 0.92 };
  }

  // Check for known bots
  if (BOT_USER_AGENTS.some((bot) => ua.includes(bot))) {
    return { classification: 'likely_bot', confidenceScore: 0.95 };
  }

  // Check for email security scanners
  if (SCANNER_USER_AGENTS.some((scanner) => ua.includes(scanner))) {
    return { classification: 'likely_scanner', confidenceScore: 0.90 };
  }

  // Check for email proxies/clients
  if (EMAIL_PROXY_USER_AGENTS.some((proxy) => ua.includes(proxy))) {
    return { classification: 'email_proxy', confidenceScore: 0.88 };
  }

  // Check for missing or suspicious user agent
  if (!userAgent || userAgent.length < 20) {
    return { classification: 'likely_bot', confidenceScore: 0.70 };
  }

  // Check for common human browser patterns
  const hasModernBrowser = ua.includes('chrome') || ua.includes('firefox') ||
    ua.includes('safari') || ua.includes('edge');
  const hasOS = ua.includes('windows') || ua.includes('macintosh') ||
    ua.includes('linux') || ua.includes('iphone') || ua.includes('android');

  if (hasModernBrowser && hasOS) {
    return { classification: 'likely_human', confidenceScore: 0.75 };
  }

  return { classification: 'unknown', confidenceScore: 0.50 };
}

What does this code do?

This module classifies incoming requests using a priority-ordered heuristic system:

  • Five arrays hold known patterns for bots, scanners, email proxies, ATS software, and ATS referrer URLs.
  • The classifyRequest function checks the user-agent and referrer against each list in priority order (ATS first, then bots, scanners, proxies).
  • If no list matches, it falls back to checking for a modern browser + OS pattern to identify likely human traffic.
  • Each result includes a confidence score so you can gauge how certain the classification is.
  • Save src/lib/bot-detection.ts.
  • You won't see a visible change yet. The next file (tracking.ts) imports this module.

βœ”οΈ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

β“§ I'd like to double check the full code

import { ClassificationType } from './types';

interface ClassificationResult {
  classification: ClassificationType;
  confidenceScore: number;
}

const BOT_USER_AGENTS = [
  'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider',
  'yandexbot', 'facebot', 'ia_archiver', 'spider', 'crawler',
  'bot/', 'bot;', 'chatgpt', 'gptbot',
];

const SCANNER_USER_AGENTS = [
  'barracuda', 'proofpoint', 'mimecast', 'messagelabs',
  'forcepoint', 'fireeye', 'symantec', 'sophos',
  'mcafee', 'kaspersky', 'fortinet', 'zscaler',
];

const EMAIL_PROXY_USER_AGENTS = [
  'microsoft office', 'outlook', 'thunderbird',
  'apple mail', 'yahoo', 'mailchimp',
  'googleimageproxy', 'yahoo pipes', 'litmus',
];

const ATS_USER_AGENTS = [
  'greenhouse', 'lever', 'workday', 'icims', 'taleo',
  'bamboohr', 'jobvite', 'smartrecruiters', 'ashby',
  'recruitee', 'workable', 'jazz', 'ats',
];

const ATS_REFERRERS = [
  'greenhouse.io', 'lever.co', 'workday.com', 'icims.com',
  'taleo.net', 'bamboohr.com', 'jobvite.com', 'smartrecruiters.com',
  'ashbyhq.com', 'recruitee.com', 'workable.com', 'linkedin.com',
];

export function classifyRequest(
  userAgent: string,
  referrer: string,
  ip: string
): ClassificationResult {
  const ua = userAgent.toLowerCase();
  const ref = referrer.toLowerCase();

  // Check for known ATS/recruiting software
  if (ATS_USER_AGENTS.some((ats) => ua.includes(ats)) ||
      ATS_REFERRERS.some((ats) => ref.includes(ats))) {
    return { classification: 'ats_or_recruiting_software', confidenceScore: 0.92 };
  }

  // Check for known bots
  if (BOT_USER_AGENTS.some((bot) => ua.includes(bot))) {
    return { classification: 'likely_bot', confidenceScore: 0.95 };
  }

  // Check for email security scanners
  if (SCANNER_USER_AGENTS.some((scanner) => ua.includes(scanner))) {
    return { classification: 'likely_scanner', confidenceScore: 0.90 };
  }

  // Check for email proxies/clients
  if (EMAIL_PROXY_USER_AGENTS.some((proxy) => ua.includes(proxy))) {
    return { classification: 'email_proxy', confidenceScore: 0.88 };
  }

  // Check for missing or suspicious user agent
  if (!userAgent || userAgent.length < 20) {
    return { classification: 'likely_bot', confidenceScore: 0.70 };
  }

  // Check for common human browser patterns
  const hasModernBrowser = ua.includes('chrome') || ua.includes('firefox') ||
    ua.includes('safari') || ua.includes('edge');
  const hasOS = ua.includes('windows') || ua.includes('macintosh') ||
    ua.includes('linux') || ua.includes('iphone') || ua.includes('android');

  if (hasModernBrowser && hasOS) {
    return { classification: 'likely_human', confidenceScore: 0.75 };
  }

  return { classification: 'unknown', confidenceScore: 0.50 };
}
Create the tracking event processor

The tracking module takes raw request data and transforms it into a structured TrackingEvent object. It also extracts device hints from the user-agent string to give you readable context about each visitor.

  • Create a new file called src/lib/tracking.ts and add the following code:
import { TrackingEvent, ClassificationType } from './types';
import { classifyRequest } from './bot-detection';

interface TrackingParams {
  campaign?: string;
  company?: string;
  recipient?: string;
  resumeVersion?: string;
  source?: string;
}

interface RequestMetadata {
  userAgent: string;
  referrer: string;
  ip: string;
  deviceHints: string;
}

export function processTrackingEvent(
  params: TrackingParams,
  metadata: RequestMetadata
): TrackingEvent {
  const { classification, confidenceScore } = classifyRequest(
    metadata.userAgent,
    metadata.referrer,
    metadata.ip
  );

  const now = new Date().toISOString();

  const event: TrackingEvent = {
    id: `evt-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
    timestamp: now,
    campaignId: params.campaign || 'unknown',
    campaignName: params.campaign || 'Unknown Campaign',
    company: params.company || 'Unknown',
    recipient: params.recipient || 'Unknown',
    resumeVersion: params.resumeVersion || 'unknown',
    visitorIp: metadata.ip,
    userAgent: metadata.userAgent,
    referrer: metadata.referrer,
    deviceHints: metadata.deviceHints,
    classification,
    confidenceScore,
    firstOpenTime: now,
    lastOpenTime: now,
    totalOpenCount: 1,
  };

  return event;
}

export function extractDeviceHints(userAgent: string): string {
  const ua = userAgent.toLowerCase();
  const parts: string[] = [];

  if (ua.includes('iphone')) parts.push('iOS');
  else if (ua.includes('android')) parts.push('Android');
  else if (ua.includes('macintosh')) parts.push('macOS');
  else if (ua.includes('windows')) parts.push('Windows');
  else if (ua.includes('linux')) parts.push('Linux');

  if (ua.includes('chrome') && !ua.includes('edg')) parts.push('Chrome');
  else if (ua.includes('firefox')) parts.push('Firefox');
  else if (ua.includes('safari') && !ua.includes('chrome')) parts.push('Safari');
  else if (ua.includes('edg')) parts.push('Edge');

  if (ua.includes('mobile')) parts.push('Mobile');
  else parts.push('Desktop');

  return parts.join(', ') || 'Unknown';
}

What does this code do?

Two functions work together to process tracking data:

  • processTrackingEvent takes the URL query parameters (campaign, company, resume version) and request metadata (user agent, referrer, IP). It calls classifyRequest from the bot detection module, then assembles a complete TrackingEvent object with a unique ID and timestamp.
  • extractDeviceHints parses the user-agent string into a human-readable summary like "macOS, Chrome, Desktop" so you can quickly understand what device was used.
  • Save src/lib/tracking.ts.
  • You won't see a visible change yet. The API route you create next will import both of these modules.

βœ”οΈ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

β“§ I'd like to double check the full code

import { TrackingEvent, ClassificationType } from './types';
import { classifyRequest } from './bot-detection';

interface TrackingParams {
  campaign?: string;
  company?: string;
  recipient?: string;
  resumeVersion?: string;
  source?: string;
}

interface RequestMetadata {
  userAgent: string;
  referrer: string;
  ip: string;
  deviceHints: string;
}

export function processTrackingEvent(
  params: TrackingParams,
  metadata: RequestMetadata
): TrackingEvent {
  const { classification, confidenceScore } = classifyRequest(
    metadata.userAgent,
    metadata.referrer,
    metadata.ip
  );

  const now = new Date().toISOString();

  const event: TrackingEvent = {
    id: `evt-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
    timestamp: now,
    campaignId: params.campaign || 'unknown',
    campaignName: params.campaign || 'Unknown Campaign',
    company: params.company || 'Unknown',
    recipient: params.recipient || 'Unknown',
    resumeVersion: params.resumeVersion || 'unknown',
    visitorIp: metadata.ip,
    userAgent: metadata.userAgent,
    referrer: metadata.referrer,
    deviceHints: metadata.deviceHints,
    classification,
    confidenceScore,
    firstOpenTime: now,
    lastOpenTime: now,
    totalOpenCount: 1,
  };

  return event;
}

export function extractDeviceHints(userAgent: string): string {
  const ua = userAgent.toLowerCase();
  const parts: string[] = [];

  if (ua.includes('iphone')) parts.push('iOS');
  else if (ua.includes('android')) parts.push('Android');
  else if (ua.includes('macintosh')) parts.push('macOS');
  else if (ua.includes('windows')) parts.push('Windows');
  else if (ua.includes('linux')) parts.push('Linux');

  if (ua.includes('chrome') && !ua.includes('edg')) parts.push('Chrome');
  else if (ua.includes('firefox')) parts.push('Firefox');
  else if (ua.includes('safari') && !ua.includes('chrome')) parts.push('Safari');
  else if (ua.includes('edg')) parts.push('Edge');

  if (ua.includes('mobile')) parts.push('Mobile');
  else parts.push('Desktop');

  return parts.join(', ') || 'Unknown';
}
Create the tracking pixel API route

This is the endpoint that fires when someone opens your resume. It's a Next.js Route Handler that parses query parameters, processes the tracking event, logs it, and responds with a transparent 1x1 GIF.

  • Create a new file called src/app/api/pixel/route.ts and add the following code:
import { type NextRequest } from 'next/server';
import { processTrackingEvent, extractDeviceHints } from '@/lib/tracking';

// 1x1 transparent GIF (43 bytes)
const TRANSPARENT_GIF = Buffer.from(
  'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
  'base64'
);

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;

  const params = {
    campaign: searchParams.get('campaign') || undefined,
    company: searchParams.get('company') || undefined,
    recipient: searchParams.get('recipient') || undefined,
    resumeVersion: searchParams.get('resumeVersion') || undefined,
    source: searchParams.get('source') || undefined,
  };

  const userAgent = request.headers.get('user-agent') || '';
  const referrer = request.headers.get('referer') || '';
  const forwardedFor = request.headers.get('x-forwarded-for') || '';
  const ip = forwardedFor.split(',')[0]?.trim() || 'unknown';

  const metadata = {
    userAgent,
    referrer,
    ip,
    deviceHints: extractDeviceHints(userAgent),
  };

  const event = processTrackingEvent(params, metadata);

  // TODO: Save to database when configured
  // await saveTrackingEvent(event);
  console.log('[Pixel] Tracking event recorded:', JSON.stringify({
    timestamp: event.timestamp,
    campaign: event.campaignId,
    company: event.company,
    classification: event.classification,
    confidence: event.confidenceScore,
    userAgent: event.userAgent.substring(0, 80),
  }, null, 2));

  return new Response(TRANSPARENT_GIF, {
    status: 200,
    headers: {
      'Content-Type': 'image/gif',
      'Content-Length': TRANSPARENT_GIF.length.toString(),
      'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
      'Pragma': 'no-cache',
      'Expires': '0',
      'X-Content-Type-Options': 'nosniff',
    },
  });
}

What does this code do?

This route handler is the heart of the tracking system:

  • TRANSPARENT_GIF holds a 43-byte base64-encoded transparent image. This is the standard technique for tracking pixels.
  • export const dynamic = 'force-dynamic' tells Next.js to never cache or prerender this route. Every request must run fresh so it gets tracked.
  • The GET handler reads campaign info from URL query params, extracts browser metadata from request headers, and passes everything through processTrackingEvent.
  • The no-cache response headers prevent browsers and CDNs from caching the image. Without these, repeat views would be served from cache and never hit your server.
  • Save src/app/api/pixel/route.ts.
  • Start the dev server by running:
npm run dev
  • This is the normal response after starting the dev server. You can ignore the yellow warnings and the 404 errors. We will point the correct endpoints later on in the project.
  • Open your browser and visit http://localhost:3000/api/pixel?campaign=test&company=acme.
  • You should see a black screen with a single white pixel dot. This confirms your tracking pixel is working.

You may have to zoom in to see the tiny white dot.

  • Switch back to your terminal. You should see a [Pixel] Tracking event recorded: log with the campaign, company, classification, and confidence score.

Not seeing the console log?

  • Make sure the dev server is running. Check your terminal for errors when you started npm run dev.
  • Confirm the file is saved at exactly src/app/api/pixel/route.ts. The folder path determines the URL in Next.js App Router.
  • Check that the import path '@/lib/tracking' resolves correctly. If you see a module-not-found error, verify that bot-detection.ts and tracking.ts are saved in src/lib/.

Why isn't my tracking pixel API route working in Next.js?

βœ”οΈ Awesome, I've got everything!

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

β“§ I'd like to double check the full code

import { type NextRequest } from 'next/server';
import { processTrackingEvent, extractDeviceHints } from '@/lib/tracking';

// 1x1 transparent GIF (43 bytes)
const TRANSPARENT_GIF = Buffer.from(
  'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
  'base64'
);

export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;

  const params = {
    campaign: searchParams.get('campaign') || undefined,
    company: searchParams.get('company') || undefined,
    recipient: searchParams.get('recipient') || undefined,
    resumeVersion: searchParams.get('resumeVersion') || undefined,
    source: searchParams.get('source') || undefined,
  };

  const userAgent = request.headers.get('user-agent') || '';
  const referrer = request.headers.get('referer') || '';
  const forwardedFor = request.headers.get('x-forwarded-for') || '';
  const ip = forwardedFor.split(',')[0]?.trim() || 'unknown';

  const metadata = {
    userAgent,
    referrer,
    ip,
    deviceHints: extractDeviceHints(userAgent),
  };

  const event = processTrackingEvent(params, metadata);

  // TODO: Save to database when configured
  // await saveTrackingEvent(event);
  console.log('[Pixel] Tracking event recorded:', JSON.stringify({
    timestamp: event.timestamp,
    campaign: event.campaignId,
    company: event.company,
    classification: event.classification,
    confidence: event.confidenceScore,
    userAgent: event.userAgent.substring(0, 80),
  }, null, 2));

  return new Response(TRANSPARENT_GIF, {
    status: 200,
    headers: {
      'Content-Type': 'image/gif',
      'Content-Length': TRANSPARENT_GIF.length.toString(),
      'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
      'Pragma': 'no-cache',
      'Expires': '0',
      'X-Content-Type-Options': 'nosniff',
    },
  });
}

Your tracking pixel API is live and logging events. Next up, you'll build the layout and sidebar navigation so the app has a real dashboard feel.

Build the Layout and Sidebar Navigation

Your tracking pixel API is ready to capture events behind the scenes. Now you need a visual interface so you can actually see what it collects.

In this step, you'll build the visual shell of your application. This includes a sidebar navigation menu that links to every page and a set of reusable UI components for displaying classification badges and KPI data.

In this step, get ready to:
  • Build a sidebar navigation component with active-state highlighting.
  • Update the root layout to wrap the app in the sidebar and main content area.
  • Create reusable Badge and Card UI components for the dashboard.
Create the theme provider and dark mode styles

Before building the sidebar, you need a theme context that the sidebar's toggle button will use. The ThemeProvider stores the current theme in React context and persists the choice in localStorage.

  • Create a new file at src/components/ThemeProvider.tsx and add the following code:
'use client';

import { createContext, useContext, useEffect, useState } from 'react';

type Theme = 'light' | 'dark';

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextType>({
  theme: 'light',
  toggleTheme: () => {},
});

export function useTheme() {
  return useContext(ThemeContext);
}

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');
  const [mounted, setMounted] = useState(false);

  // Load saved theme from localStorage on mount
  useEffect(() => {
    const saved = localStorage.getItem('theme') as Theme | null;
    if (saved === 'dark' || saved === 'light') {
      setTheme(saved);
    }
    setMounted(true);
  }, []);

  // Apply theme class to the html element and save to localStorage
  useEffect(() => {
    if (!mounted) return;
    const root = document.documentElement;
    root.classList.remove('light', 'dark');
    root.classList.add(theme);
    localStorage.setItem('theme', theme);
  }, [theme, mounted]);

  const toggleTheme = () => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
  };

  // Prevent flash of wrong theme on initial load
  if (!mounted) {
    return <div style={{ visibility: 'hidden' }}>{children}</div>;
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

What does this code do?

Creates a React Context that stores the current theme and a toggle function. Any component can call useTheme() to read or change the theme.

On mount, it reads the saved theme from localStorage. The mounted guard prevents a flash of the wrong theme during server-side rendering. It applies a dark or light class on the <html> element for CSS targeting.

  • Save src/components/ThemeProvider.tsx.
  • Open src/app/globals.css and replace its entire contents with the following:
@import "tailwindcss";

/* Dark theme styles */
.dark {
  color-scheme: dark;
}

.dark body {
  background-color: #0a0a0f;
  color: #e4e4e7;
}

/* Dark sidebar */
.dark .sidebar {
  background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 100%);
  border-right: 1px solid rgba(99, 102, 241, 0.15);
}

.dark .sidebar a {
  color: #a1a1aa;
}

.dark .sidebar a:hover {
  color: #e4e4e7;
  background: rgba(99, 102, 241, 0.1);
}

.dark .sidebar a.active {
  color: #67e8f9;
  background: rgba(103, 232, 249, 0.08);
  box-shadow: 0 0 12px rgba(103, 232, 249, 0.15);
}

/* Dark cards */
.dark .card,
.dark [class*="rounded-xl"][class*="border"][class*="bg-white"] {
  background: rgba(15, 15, 26, 0.8) !important;
  border-color: rgba(99, 102, 241, 0.12) !important;
  box-shadow: 0 0 20px rgba(99, 102, 241, 0.05);
}

/* Dark tables */
.dark table {
  color: #e4e4e7;
}

.dark thead {
  background: rgba(15, 15, 26, 0.9) !important;
}

.dark th {
  color: #a1a1aa !important;
  border-color: rgba(99, 102, 241, 0.1) !important;
}

.dark tbody tr {
  border-color: rgba(99, 102, 241, 0.08) !important;
}

.dark tbody tr:hover {
  background: rgba(99, 102, 241, 0.05) !important;
}

.dark td {
  color: #d4d4d8 !important;
}

.dark td.font-medium,
.dark td .font-medium {
  color: #f4f4f5 !important;
}

/* Dark inputs and selects */
.dark input,
.dark select,
.dark textarea {
  background: rgba(15, 15, 26, 0.9) !important;
  border-color: rgba(99, 102, 241, 0.2) !important;
  color: #f4f4f5 !important;
}

.dark input::placeholder,
.dark textarea::placeholder {
  color: #52525b !important;
}

.dark input:focus,
.dark select:focus,
.dark textarea:focus {
  border-color: rgba(103, 232, 249, 0.5) !important;
  box-shadow: 0 0 8px rgba(103, 232, 249, 0.15) !important;
}

/* Dark buttons */
.dark button[class*="bg-gray-900"],
.dark a[class*="bg-gray-900"] {
  background: linear-gradient(135deg, #312e81, #1e1b4b) !important;
  border: 1px solid rgba(99, 102, 241, 0.3);
  box-shadow: 0 0 12px rgba(99, 102, 241, 0.2);
}

.dark button[class*="bg-gray-900"]:hover,
.dark a[class*="bg-gray-900"]:hover {
  box-shadow: 0 0 20px rgba(99, 102, 241, 0.4) !important;
}

.dark button[class*="bg-gray-100"],
.dark [class*="bg-gray-100"] {
  background: rgba(30, 27, 75, 0.5) !important;
  color: #c4b5fd !important;
}

/* Dark headings and text */
.dark h1, .dark h2, .dark h3 {
  color: #f4f4f5 !important;
}

.dark p[class*="text-gray-500"],
.dark span[class*="text-gray-500"] {
  color: #71717a !important;
}

.dark p[class*="text-gray-900"],
.dark span[class*="text-gray-900"] {
  color: #f4f4f5 !important;
}

/* Dark code blocks */
.dark code {
  background: rgba(15, 15, 26, 0.9) !important;
  color: #67e8f9 !important;
  border-color: rgba(99, 102, 241, 0.15) !important;
}

/* Dark stat boxes */
.dark [class*="bg-gray-50"] {
  background: rgba(15, 15, 26, 0.6) !important;
}

.dark [class*="bg-green-50"] {
  background: rgba(16, 185, 129, 0.1) !important;
}

.dark [class*="bg-red-50"] {
  background: rgba(239, 68, 68, 0.1) !important;
}

/* Dark status badges */
.dark [class*="bg-yellow-100"] {
  background: rgba(245, 158, 11, 0.15) !important;
  color: #fbbf24 !important;
}

.dark [class*="bg-blue-100"] {
  background: rgba(59, 130, 246, 0.15) !important;
  color: #60a5fa !important;
}

.dark [class*="bg-green-100"] {
  background: rgba(16, 185, 129, 0.15) !important;
  color: #34d399 !important;
}

.dark [class*="bg-red-100"] {
  background: rgba(239, 68, 68, 0.15) !important;
  color: #f87171 !important;
}

.dark [class*="bg-purple-100"] {
  background: rgba(139, 92, 246, 0.15) !important;
  color: #a78bfa !important;
}

/* Dark info/warning boxes */
.dark [class*="bg-amber-50"] {
  background: rgba(245, 158, 11, 0.08) !important;
  border-color: rgba(245, 158, 11, 0.2) !important;
}

.dark [class*="bg-blue-50"] {
  background: rgba(59, 130, 246, 0.08) !important;
  border-color: rgba(59, 130, 246, 0.2) !important;
}

.dark [class*="bg-red-50"] {
  background: rgba(239, 68, 68, 0.08) !important;
  border-color: rgba(239, 68, 68, 0.2) !important;
}

/* Theme toggle button */
.theme-toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.75rem;
  border-radius: 0.5rem;
  font-size: 0.75rem;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
  border: 1px solid transparent;
}

.light .theme-toggle {
  color: #52525b;
  background: #f4f4f5;
}

.light .theme-toggle:hover {
  background: #e4e4e7;
}

.dark .theme-toggle {
  color: #67e8f9;
  background: rgba(103, 232, 249, 0.08);
  border-color: rgba(103, 232, 249, 0.2);
}

.dark .theme-toggle:hover {
  background: rgba(103, 232, 249, 0.15);
  box-shadow: 0 0 12px rgba(103, 232, 249, 0.2);
}
  • Save src/app/globals.css.
  • You won't see a change yet. The next chunk creates the Sidebar component that imports this provider.
Create the sidebar navigation component

Every SaaS application has a persistent navigation sidebar. Yours will list all nine pages and highlight whichever page the user is currently viewing. It also includes a theme toggle so users can switch between light and dark modes.

  • Create the file src/components/layout/Sidebar.tsx and add the following code:
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTheme } from '@/components/ThemeProvider';

const navItems = [
  { href: '/', label: 'Home', icon: '🏠' },
  { href: '/dashboard', label: 'Dashboard', icon: 'πŸ“Š' },
  { href: '/campaigns', label: 'Campaigns', icon: '🎯' },
  { href: '/generator', label: 'Generator', icon: 'πŸ”—' },
  { href: '/companies', label: 'Companies', icon: '🏒' },
  { href: '/events', label: 'Events', icon: 'πŸ“‹' },
  { href: '/resumes', label: 'Resumes', icon: 'πŸ“„' },
  { href: '/settings', label: 'Settings', icon: 'βš™οΈ' },
  { href: '/login', label: 'Login', icon: 'πŸ”’' },
];

export function Sidebar() {
  const pathname = usePathname();
  const { theme, toggleTheme } = useTheme();

  return (
    <aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-gray-200 bg-white">
      <div className="flex h-full flex-col">
        <div className="flex h-16 items-center border-b border-gray-200 px-6">
          <h1 className="text-xl font-bold text-gray-900">πŸ“ Resume Tracker</h1>
        </div>
        <nav className="flex-1 space-y-1 px-3 py-4">
          {navItems.map((item) => {
            const isActive = pathname === item.href;
            return (
              <Link
                key={item.href}
                href={item.href}
                className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
                  isActive
                    ? 'bg-gray-100 text-gray-900'
                    : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
                }`}
              >
                <span className="text-lg">{item.icon}</span>
                {item.label}
              </Link>
            );
          })}
        </nav>
        <div className="border-t border-gray-200 p-4">
          <button onClick={toggleTheme} className="theme-toggle w-full">
            {theme === 'light' ? 'πŸŒ™ Dark Mode' : 'β˜€οΈ Light Mode'}
          </button>
          <p className="mt-3 text-xs text-gray-400">Resume Tracker MVP</p>
          <p className="text-xs text-gray-400">Signals, not proof.</p>
        </div>
      </div>
    </aside>
  );
}

What does this code do?

'use client' marks this as a client component because it uses the usePathname hook to detect which page is active.

The navItems array defines the nine pages in your app. Each link gets a highlighted background when the current URL matches its href. The useTheme hook reads the current theme from context and provides a toggleTheme function that the button at the bottom calls.

  • Save src/components/layout/Sidebar.tsx.
  • You won't see a change yet. The next chunk updates the root layout to render this sidebar.
Update the root layout to use the sidebar

The root layout wraps every page in your app. You'll replace the default create-next-app layout with one that renders your sidebar on the left and page content on the right.

  • Open src/app/layout.tsx and replace its entire contents with the following code:
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { Sidebar } from '@/components/layout/Sidebar';
import { ThemeProvider } from '@/components/ThemeProvider';
import './globals.css';

const geistSans = Geist({
  variable: '--font-geist-sans',
  subsets: ['latin'],
});

const geistMono = Geist_Mono({
  variable: '--font-geist-mono',
  subsets: ['latin'],
});

export const metadata: Metadata = {
  title: 'Resume Tracker',
  description: 'Personal resume tracking and analytics dashboard',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
        <ThemeProvider>
          <Sidebar />
          <main className="ml-64 min-h-screen bg-gray-50 p-8">
            {children}
          </main>
        </ThemeProvider>
      </body>
    </html>
  );
}

What does this code do?

The layout imports your Sidebar component and wraps everything in ThemeProvider so the theme context is available to all components. The ml-64 class on <main> pushes the content to the right so it doesn't hide behind the fixed sidebar.

Geist and Geist_Mono are the default fonts included with Next.js 16. The globals.css import loads Tailwind CSS for the entire app.

  • Save src/app/layout.tsx.
  • Start the dev server by running:
npm run dev
  • Open http://localhost:3000 in your browser. You should see a white sidebar on the left with the "Resume Tracker" heading, nine navigation links (Home, Dashboard, Campaigns, Generator, Companies, Events, Resumes, Settings, Login), and a theme toggle button at the bottom.

Sidebar not showing up?

  • Make sure you saved both Sidebar.tsx and layout.tsx. The layout imports the sidebar, so both files must be saved.
  • Check the terminal for errors. If you see a "Module not found" error, verify that Sidebar.tsx is inside src/components/layout/ (not src/components/ directly).

Why is my sidebar not rendering?

Create the Badge component

Your dashboard will display classification labels like "Human" or "Bot" as coloured badges. You need a reusable component that maps each classification type to the right colour and label text.

  • Create the file src/components/ui/Badge.tsx and add the following code:
import { ClassificationType } from '@/lib/types';

const classificationColors: Record<ClassificationType, string> = {
  likely_human: 'bg-green-100 text-green-800',
  likely_scanner: 'bg-yellow-100 text-yellow-800',
  likely_bot: 'bg-red-100 text-red-800',
  email_proxy: 'bg-blue-100 text-blue-800',
  ats_or_recruiting_software: 'bg-purple-100 text-purple-800',
  unknown: 'bg-gray-100 text-gray-800',
};

const classificationLabels: Record<ClassificationType, string> = {
  likely_human: 'Human',
  likely_scanner: 'Scanner',
  likely_bot: 'Bot',
  email_proxy: 'Email Proxy',
  ats_or_recruiting_software: 'ATS/Recruiting',
  unknown: 'Unknown',
};

interface BadgeProps {
  classification: ClassificationType;
}

export function Badge({ classification }: BadgeProps) {
  return (
    <span
      className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${classificationColors[classification]}`}
    >
      {classificationLabels[classification]}
    </span>
  );
}

interface StatusBadgeProps {
  status: 'active' | 'paused' | 'completed';
}

export function StatusBadge({ status }: StatusBadgeProps) {
  const colors = {
    active: 'bg-green-100 text-green-800',
    paused: 'bg-yellow-100 text-yellow-800',
    completed: 'bg-gray-100 text-gray-800',
  };

  return (
    <span
      className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colors[status]}`}
    >
      {status.charAt(0).toUpperCase() + status.slice(1)}
    </span>
  );
}

What does this code do?

The Badge component takes a classification type and renders it as a colour-coded pill. Green for human, red for bot, yellow for scanner, and so on.

The StatusBadge component does the same for campaign statuses (active, paused, completed). Both use Tailwind utility classes for styling.

  • Save src/components/ui/Badge.tsx.
  • You won't see a change in the browser yet. The dashboard page in the next step uses this component to label each tracking event.
Create the Card component

The dashboard will display nine KPI metrics (total opens, human opens, bot opens, etc.) as individual cards. A reusable Card component keeps that consistent.

  • Create the file src/components/ui/Card.tsx and add the following code:
interface CardProps {
  title: string;
  value: string | number;
  subtitle?: string;
  color?: string;
}

export function Card({ title, value, subtitle, color = 'text-gray-900' }: CardProps) {
  return (
    <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
      <p className="text-sm font-medium text-gray-500">{title}</p>
      <p className={`mt-2 text-3xl font-bold ${color}`}>{value}</p>
      {subtitle && <p className="mt-1 text-sm text-gray-400">{subtitle}</p>}
    </div>
  );
}

What does this code do?

The Card component accepts a title, a large value, an optional subtitle, and an optional color for the value text. This creates a clean metric box that you will repeat nine times on the dashboard.

  • Save src/components/ui/Card.tsx.
  • You won't see a change in the browser yet. The next step builds the dashboard page that renders these cards with live KPI data.

βœ”οΈ Awesome, I've got everything!

Great. Double check you've saved all four files before moving on.

β“§ I'd like to double check the full code

Here are the complete files for this step. Compare yours against them.

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTheme } from '@/components/ThemeProvider';

const navItems = [
  { href: '/', label: 'Home', icon: '🏠' },
  { href: '/dashboard', label: 'Dashboard', icon: 'πŸ“Š' },
  { href: '/campaigns', label: 'Campaigns', icon: '🎯' },
  { href: '/generator', label: 'Generator', icon: 'πŸ”—' },
  { href: '/companies', label: 'Companies', icon: '🏒' },
  { href: '/events', label: 'Events', icon: 'πŸ“‹' },
  { href: '/resumes', label: 'Resumes', icon: 'πŸ“„' },
  { href: '/settings', label: 'Settings', icon: 'βš™οΈ' },
  { href: '/login', label: 'Login', icon: 'πŸ”’' },
];

export function Sidebar() {
  const pathname = usePathname();
  const { theme, toggleTheme } = useTheme();

  return (
    <aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-gray-200 bg-white">
      <div className="flex h-full flex-col">
        <div className="flex h-16 items-center border-b border-gray-200 px-6">
          <h1 className="text-xl font-bold text-gray-900">πŸ“ Resume Tracker</h1>
        </div>
        <nav className="flex-1 space-y-1 px-3 py-4">
          {navItems.map((item) => {
            const isActive = pathname === item.href;
            return (
              <Link
                key={item.href}
                href={item.href}
                className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
                  isActive
                    ? 'bg-gray-100 text-gray-900'
                    : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
                }`}
              >
                <span className="text-lg">{item.icon}</span>
                {item.label}
              </Link>
            );
          })}
        </nav>
        <div className="border-t border-gray-200 p-4">
          <button onClick={toggleTheme} className="theme-toggle w-full">
            {theme === 'light' ? 'πŸŒ™ Dark Mode' : 'β˜€οΈ Light Mode'}
          </button>
          <p className="mt-3 text-xs text-gray-400">Resume Tracker MVP</p>
          <p className="text-xs text-gray-400">Signals, not proof.</p>
        </div>
      </div>
    </aside>
  );
}
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { Sidebar } from '@/components/layout/Sidebar';
import { ThemeProvider } from '@/components/ThemeProvider';
import './globals.css';

const geistSans = Geist({
  variable: '--font-geist-sans',
  subsets: ['latin'],
});

const geistMono = Geist_Mono({
  variable: '--font-geist-mono',
  subsets: ['latin'],
});

export const metadata: Metadata = {
  title: 'Resume Tracker',
  description: 'Personal resume tracking and analytics dashboard',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
        <ThemeProvider>
          <Sidebar />
          <main className="ml-64 min-h-screen bg-gray-50 p-8">
            {children}
          </main>
        </ThemeProvider>
      </body>
    </html>
  );
}
import { ClassificationType } from '@/lib/types';

const classificationColors: Record<ClassificationType, string> = {
  likely_human: 'bg-green-100 text-green-800',
  likely_scanner: 'bg-yellow-100 text-yellow-800',
  likely_bot: 'bg-red-100 text-red-800',
  email_proxy: 'bg-blue-100 text-blue-800',
  ats_or_recruiting_software: 'bg-purple-100 text-purple-800',
  unknown: 'bg-gray-100 text-gray-800',
};

const classificationLabels: Record<ClassificationType, string> = {
  likely_human: 'Human',
  likely_scanner: 'Scanner',
  likely_bot: 'Bot',
  email_proxy: 'Email Proxy',
  ats_or_recruiting_software: 'ATS/Recruiting',
  unknown: 'Unknown',
};

interface BadgeProps {
  classification: ClassificationType;
}

export function Badge({ classification }: BadgeProps) {
  return (
    <span
      className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${classificationColors[classification]}`}
    >
      {classificationLabels[classification]}
    </span>
  );
}

interface StatusBadgeProps {
  status: 'active' | 'paused' | 'completed';
}

export function StatusBadge({ status }: StatusBadgeProps) {
  const colors = {
    active: 'bg-green-100 text-green-800',
    paused: 'bg-yellow-100 text-yellow-800',
    completed: 'bg-gray-100 text-gray-800',
  };

  return (
    <span
      className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colors[status]}`}
    >
      {status.charAt(0).toUpperCase() + status.slice(1)}
    </span>
  );
}
interface CardProps {
  title: string;
  value: string | number;
  subtitle?: string;
  color?: string;
}

export function Card({ title, value, subtitle, color = 'text-gray-900' }: CardProps) {
  return (
    <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
      <p className="text-sm font-medium text-gray-500">{title}</p>
      <p className={`mt-2 text-3xl font-bold ${color}`}>{value}</p>
      {subtitle && <p className="mt-1 text-sm text-gray-400">{subtitle}</p>}
    </div>
  );
}

Your app now has a professional navigation shell and reusable UI building blocks. Next up, you'll wire these components into a full dashboard page with live KPI metrics computed from your sample data.

Build the Dashboard Page

Your app now has a polished sidebar layout and reusable UI components. The skeleton is in place.

In this step, you will bring the dashboard to life. The dashboard is the first thing a user sees after navigating from the home page. It needs to answer one question fast: who opened my resume recently?

In this step, get ready to:
  • Create an analytics module that computes KPI values from sample data.
  • Build a KPI cards component that displays nine key metrics in a responsive grid.
  • Compose the full dashboard page with KPI cards and a recent events table.
Create the analytics module

Before the dashboard can display numbers, something needs to crunch those numbers. The analytics module takes the raw sample events and campaigns, then produces a single object containing all nine KPI values the dashboard needs.

  • Create a new file at src/lib/analytics.ts and add the following code:
import { KPIData, TrackingEvent, Campaign } from './types';
import { sampleEvents, sampleCampaigns, sampleCompanies } from './sample-data';

export function computeKPIs(
  events: TrackingEvent[] = sampleEvents,
  campaigns: Campaign[] = sampleCampaigns
): KPIData {
  const totalOpens = events.length;
  const humanOpens = events.filter((e) => e.classification === 'likely_human').length;
  const scannerBotOpens = events.filter(
    (e) => e.classification === 'likely_bot' || e.classification === 'likely_scanner'
  ).length;
  const unknownOpens = events.filter((e) => e.classification === 'unknown').length;
  const activeCampaigns = campaigns.filter((c) => c.status === 'active').length;
  const companiesTracked = sampleCompanies.length;

  // Find most viewed resume version
  const versionCounts: Record<string, number> = {};
  events.forEach((e) => {
    versionCounts[e.resumeVersion] = (versionCounts[e.resumeVersion] || 0) + 1;
  });
  const mostViewedVersion = Object.entries(versionCounts)
    .sort(([, a], [, b]) => b - a)[0]?.[0] || 'N/A';

  // Latest open
  const latestOpen = events.length > 0
    ? new Date(events[0].timestamp).toLocaleString()
    : 'No opens yet';

  // Follow-up opportunities: human opens in last 7 days
  const sevenDaysAgo = new Date();
  sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
  const followUpOpportunities = events.filter(
    (e) => e.classification === 'likely_human' && new Date(e.timestamp) > sevenDaysAgo
  ).length;

  return {
    totalOpens,
    humanOpens,
    scannerBotOpens,
    unknownOpens,
    activeCampaigns,
    companiesTracked,
    mostViewedVersion,
    latestOpen,
    followUpOpportunities,
  };
}

What does this code do?

The computeKPIs function takes events and campaigns as inputs (defaulting to sample data) and returns a KPIData object.

  • It filters events by classification type to count human, bot/scanner, and unknown opens separately.
  • It counts active campaigns by checking the status field.
  • It finds the most-viewed resume version by building a frequency map and sorting.
  • It calculates follow-up opportunities by counting human opens within the last 7 days.
  • Save src/lib/analytics.ts.

You won't see a change yet. The next chunk uses this function to power the KPI cards.

Build the KPI cards component

The KPI cards are the first thing a user scans on the dashboard. Nine cards in a responsive grid give a quick pulse check on resume activity.

  • Create a new file at src/components/dashboard/KPICards.tsx and add the following code:
import { Card } from '@/components/ui/Card';
import { KPIData } from '@/lib/types';

interface KPICardsProps {
  data: KPIData;
}

export function KPICards({ data }: KPICardsProps) {
  return (
    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
      <Card
        title="Total Opens"
        value={data.totalOpens}
        subtitle="All tracked events"
      />
      <Card
        title="Likely Human Opens"
        value={data.humanOpens}
        subtitle="Probable real views"
        color="text-green-600"
      />
      <Card
        title="Scanner/Bot Opens"
        value={data.scannerBotOpens}
        subtitle="Automated requests"
        color="text-red-600"
      />
      <Card
        title="Unknown Opens"
        value={data.unknownOpens}
        subtitle="Unclassified"
        color="text-gray-500"
      />
      <Card
        title="Active Campaigns"
        value={data.activeCampaigns}
        subtitle="Currently tracking"
        color="text-blue-600"
      />
      <Card
        title="Companies Tracked"
        value={data.companiesTracked}
        subtitle="Unique recipients"
        color="text-purple-600"
      />
      <Card
        title="Most Viewed Version"
        value={data.mostViewedVersion}
        subtitle="Resume version"
      />
      <Card
        title="Latest Open"
        value={data.latestOpen}
        subtitle="Most recent event"
      />
      <Card
        title="Follow-up Opportunities"
        value={data.followUpOpportunities}
        subtitle="Human opens (7 days)"
        color="text-orange-600"
      />
    </div>
  );
}

What does this code do?

The KPICards component accepts a KPIData object and renders nine Card components in a responsive grid.

Each card has a color-coded value to help users distinguish positive signals (green for human opens) from noise (red for bot/scanner opens). The grid adapts from 1 column on mobile to 3 columns on desktop using Tailwind CSS responsive breakpoints.

  • Save src/components/dashboard/KPICards.tsx.

You won't see a change yet. The next chunk creates the dashboard page that uses this component.

Create the dashboard page

Now you will wire everything together. The dashboard page calls computeKPIs for the metric values, renders the KPI cards grid, and adds a recent events table showing the five most recent tracking events.

  • Create a new file at src/app/dashboard/page.tsx and add the following code:
import { KPICards } from '@/components/dashboard/KPICards';
import { Badge } from '@/components/ui/Badge';
import { computeKPIs } from '@/lib/analytics';
import { sampleEvents } from '@/lib/sample-data';

export default function DashboardPage() {
  const kpis = computeKPIs();
  const recentEvents = sampleEvents.slice(0, 5);

  return (
    <div>
      <div className="mb-8">
        <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
        <p className="mt-1 text-sm text-gray-500">
          Resume tracking overview and key metrics
        </p>
      </div>

      <KPICards data={kpis} />

      <div className="mt-8">
        <h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Events</h2>
        <div className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
          <table className="min-w-full divide-y divide-gray-200">
            <thead className="bg-gray-50">
              <tr>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Company</th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Campaign</th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Classification</th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Confidence</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200">
              {recentEvents.map((event) => (
                <tr key={event.id} className="hover:bg-gray-50">
                  <td className="px-6 py-4 text-sm text-gray-600">
                    {new Date(event.timestamp).toLocaleString()}
                  </td>
                  <td className="px-6 py-4 text-sm font-medium text-gray-900">{event.company}</td>
                  <td className="px-6 py-4 text-sm text-gray-600">{event.campaignName}</td>
                  <td className="px-6 py-4">
                    <Badge classification={event.classification} />
                  </td>
                  <td className="px-6 py-4 text-sm text-gray-600">
                    {Math.round(event.confidenceScore * 100)}%
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

What does this code do?

The DashboardPage is a server component (no 'use client' directive). It computes KPIs at render time and slices the top 5 events for the recent activity table.

  • The page header provides context with a title and subtitle.
  • The KPICards component receives the computed data and handles the grid rendering.
  • The recent events table uses the Badge component to display color-coded classification labels for each event.
  • Confidence scores are converted from decimals (0.85) to percentages (85%) for readability.
  • Save src/app/dashboard/page.tsx.
  • Start the dev server if it is not running by running npm run dev in your terminal.
  • Visit http://localhost:3000/dashboard in your browser.
  • You should see nine KPI cards arranged in a grid at the top, followed by a Recent Events table showing five rows with company names, campaign names, colored classification badges, and confidence percentages.

Don't see the dashboard?

  • Make sure you saved all three files: analytics.ts, KPICards.tsx, and page.tsx.
  • Check that src/components/dashboard/KPICards.tsx is inside the dashboard subfolder of components, not directly in components/.
  • Verify the import paths in page.tsx use @/ aliases (e.g. @/lib/analytics). These resolve to the src/ directory.

Still stuck? Help me debug why my dashboard page isn't rendering in Next.js

βœ”οΈ 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 full code for each file created in this step.

import { KPIData, TrackingEvent, Campaign } from './types';
import { sampleEvents, sampleCampaigns, sampleCompanies } from './sample-data';

export function computeKPIs(
  events: TrackingEvent[] = sampleEvents,
  campaigns: Campaign[] = sampleCampaigns
): KPIData {
  const totalOpens = events.length;
  const humanOpens = events.filter((e) => e.classification === 'likely_human').length;
  const scannerBotOpens = events.filter(
    (e) => e.classification === 'likely_bot' || e.classification === 'likely_scanner'
  ).length;
  const unknownOpens = events.filter((e) => e.classification === 'unknown').length;
  const activeCampaigns = campaigns.filter((c) => c.status === 'active').length;
  const companiesTracked = sampleCompanies.length;

  // Find most viewed resume version
  const versionCounts: Record<string, number> = {};
  events.forEach((e) => {
    versionCounts[e.resumeVersion] = (versionCounts[e.resumeVersion] || 0) + 1;
  });
  const mostViewedVersion = Object.entries(versionCounts)
    .sort(([, a], [, b]) => b - a)[0]?.[0] || 'N/A';

  // Latest open
  const latestOpen = events.length > 0
    ? new Date(events[0].timestamp).toLocaleString()
    : 'No opens yet';

  // Follow-up opportunities: human opens in last 7 days
  const sevenDaysAgo = new Date();
  sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
  const followUpOpportunities = events.filter(
    (e) => e.classification === 'likely_human' && new Date(e.timestamp) > sevenDaysAgo
  ).length;

  return {
    totalOpens,
    humanOpens,
    scannerBotOpens,
    unknownOpens,
    activeCampaigns,
    companiesTracked,
    mostViewedVersion,
    latestOpen,
    followUpOpportunities,
  };
}
import { Card } from '@/components/ui/Card';
import { KPIData } from '@/lib/types';

interface KPICardsProps {
  data: KPIData;
}

export function KPICards({ data }: KPICardsProps) {
  return (
    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
      <Card
        title="Total Opens"
        value={data.totalOpens}
        subtitle="All tracked events"
      />
      <Card
        title="Likely Human Opens"
        value={data.humanOpens}
        subtitle="Probable real views"
        color="text-green-600"
      />
      <Card
        title="Scanner/Bot Opens"
        value={data.scannerBotOpens}
        subtitle="Automated requests"
        color="text-red-600"
      />
      <Card
        title="Unknown Opens"
        value={data.unknownOpens}
        subtitle="Unclassified"
        color="text-gray-500"
      />
      <Card
        title="Active Campaigns"
        value={data.activeCampaigns}
        subtitle="Currently tracking"
        color="text-blue-600"
      />
      <Card
        title="Companies Tracked"
        value={data.companiesTracked}
        subtitle="Unique recipients"
        color="text-purple-600"
      />
      <Card
        title="Most Viewed Version"
        value={data.mostViewedVersion}
        subtitle="Resume version"
      />
      <Card
        title="Latest Open"
        value={data.latestOpen}
        subtitle="Most recent event"
      />
      <Card
        title="Follow-up Opportunities"
        value={data.followUpOpportunities}
        subtitle="Human opens (7 days)"
        color="text-orange-600"
      />
    </div>
  );
}
import { KPICards } from '@/components/dashboard/KPICards';
import { Badge } from '@/components/ui/Badge';
import { computeKPIs } from '@/lib/analytics';
import { sampleEvents } from '@/lib/sample-data';

export default function DashboardPage() {
  const kpis = computeKPIs();
  const recentEvents = sampleEvents.slice(0, 5);

  return (
    <div>
      <div className="mb-8">
        <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
        <p className="mt-1 text-sm text-gray-500">
          Resume tracking overview and key metrics
        </p>
      </div>

      <KPICards data={kpis} />

      <div className="mt-8">
        <h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Events</h2>
        <div className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
          <table className="min-w-full divide-y divide-gray-200">
            <thead className="bg-gray-50">
              <tr>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Company</th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Campaign</th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Classification</th>
                <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Confidence</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200">
              {recentEvents.map((event) => (
                <tr key={event.id} className="hover:bg-gray-50">
                  <td className="px-6 py-4 text-sm text-gray-600">
                    {new Date(event.timestamp).toLocaleString()}
                  </td>
                  <td className="px-6 py-4 text-sm font-medium text-gray-900">{event.company}</td>
                  <td className="px-6 py-4 text-sm text-gray-600">{event.campaignName}</td>
                  <td className="px-6 py-4">
                    <Badge classification={event.classification} />
                  </td>
                  <td className="px-6 py-4 text-sm text-gray-600">
                    {Math.round(event.confidenceScore * 100)}%
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

Your dashboard is alive with data. Next up, you will build the Campaigns, Companies, and Events pages to give each data slice its own dedicated view.

Build the Campaigns, Companies, and Events Pages

Your dashboard gives a bird's-eye view of resume tracking activity. Now you need dedicated pages where you can dive deeper into individual campaigns, see which companies are opening your resume, and inspect every raw event.

In this step you will build three data-rich pages. Each one pulls from the sample data you created earlier and renders it with tables, cards, and classification badges.

In this step, get ready to:
  • Build a Campaigns page with tracking pixel URLs and open counts.
  • Build a Companies page showing recruiter activity in a table.
  • Build an Events page displaying the full event log with classification badges.
Create the Campaigns page

The Campaigns page is the command center for your job applications. Each campaign represents one resume send, and this page shows the tracking pixel URL you would embed alongside open statistics.

  • Create a new file at src/app/campaigns/page.tsx and paste the following code:
import { sampleCampaigns } from '@/lib/sample-data';
import { StatusBadge } from '@/components/ui/Badge';

export default function CampaignsPage() {
  return (
    <div>
      <div className="mb-8 flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-gray-900">Campaigns</h1>
          <p className="mt-1 text-sm text-gray-500">
            Track resume sends and generate pixel URLs
          </p>
        </div>
        <button className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800">
          + New Campaign
        </button>
      </div>

      <div className="space-y-4">
        {sampleCampaigns.map((campaign) => (
          <div
            key={campaign.id}
            className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
          >
            <div className="flex items-start justify-between">
              <div>
                <div className="flex items-center gap-3">
                  <h3 className="text-lg font-semibold text-gray-900">{campaign.name}</h3>
                  <StatusBadge status={campaign.status} />
                </div>
                <p className="mt-1 text-sm text-gray-500">
                  {campaign.company} &middot; {campaign.recipient} &middot; {campaign.resumeVersion}
                </p>
              </div>
              <div className="text-right text-sm">
                <p className="text-gray-500">Created {new Date(campaign.createdAt).toLocaleDateString()}</p>
                {campaign.lastOpenAt && (
                  <p className="text-gray-400">Last open: {new Date(campaign.lastOpenAt).toLocaleDateString()}</p>
                )}
              </div>
            </div>

            <div className="mt-4 grid grid-cols-3 gap-4">
              <div className="rounded-lg bg-gray-50 p-3 text-center">
                <p className="text-2xl font-bold text-gray-900">{campaign.totalOpens}</p>
                <p className="text-xs text-gray-500">Total Opens</p>
              </div>
              <div className="rounded-lg bg-green-50 p-3 text-center">
                <p className="text-2xl font-bold text-green-600">{campaign.humanOpens}</p>
                <p className="text-xs text-gray-500">Human</p>
              </div>
              <div className="rounded-lg bg-red-50 p-3 text-center">
                <p className="text-2xl font-bold text-red-600">{campaign.botOpens}</p>
                <p className="text-xs text-gray-500">Bot/Scanner</p>
              </div>
            </div>

            <div className="mt-4">
              <p className="text-xs font-medium text-gray-500 mb-1">Tracking Pixel URL:</p>
              <code className="block rounded-lg bg-gray-100 px-3 py-2 text-xs text-gray-700 break-all">
                {campaign.pixelUrl}
              </code>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

What does this code do?

  • Imports sampleCampaigns and the StatusBadge component you built earlier.
  • Renders each campaign as a card with name, status badge, company info, and date metadata.
  • Shows open counts in three colored stat boxes (total, human, bot/scanner) so you can see at a glance how much automated traffic each campaign receives.
  • Displays the full tracking pixel URL in a <code> block. This is the URL you would embed in a resume email as an invisible image.
  • Save src/app/campaigns/page.tsx.
  • Make sure your dev server is running (npm run dev) and visit http://localhost:3000/campaigns in your browser.
  • You should see four campaign cards stacked vertically, each with a green/red stat grid and a long tracking pixel URL at the bottom.

Don't see the campaign cards?

  • Check the import path. StatusBadge is a named export from @/components/ui/Badge, not a default export.
  • Make sure the file is saved at exactly src/app/campaigns/page.tsx. The folder name must match the URL path.
  • If you see a blank page with just the sidebar, check the terminal for TypeScript errors.

Help me debug my Campaigns page

Create the Companies page

The Companies page shows which organizations and recruiters are viewing your resume. It uses a clean table layout so you can scan activity at a glance.

  • Create a new file at src/app/companies/page.tsx and paste the following code:
import { sampleCompanies } from '@/lib/sample-data';

// Color mapping for follow-up status badges
const statusColors: Record<string, string> = {
  none: 'bg-gray-100 text-gray-700',
  pending: 'bg-yellow-100 text-yellow-800',
  followed_up: 'bg-blue-100 text-blue-800',
  replied: 'bg-green-100 text-green-800',
  rejected: 'bg-red-100 text-red-800',
  offer: 'bg-purple-100 text-purple-800',
};

export default function CompaniesPage() {
  return (
    <div>
      <div className="mb-8">
        <h1 className="text-2xl font-bold text-gray-900">Companies / Recipients</h1>
        <p className="mt-1 text-sm text-gray-500">
          Track activity by company and recruiter
        </p>
      </div>

      <div className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
        <div className="overflow-x-auto">
          <table className="min-w-full divide-y divide-gray-200">
            <thead className="bg-gray-50">
              <tr>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Company</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Recipient</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Job Title</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Sent</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Last Open</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Follow-up</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Notes</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Opens</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200">
              {sampleCompanies.map((company) => (
                <tr key={company.id} className="hover:bg-gray-50">
                  <td className="px-4 py-4 text-sm font-medium text-gray-900">{company.name}</td>
                  <td className="px-4 py-4 text-sm text-gray-600">{company.recruiterName}</td>
                  <td className="px-4 py-4 text-sm text-gray-500">{company.recruiterEmail}</td>
                  <td className="px-4 py-4 text-sm text-gray-600">{company.jobTitle}</td>
                  <td className="px-4 py-4 text-sm text-gray-600 whitespace-nowrap">
                    {new Date(company.sentAt).toLocaleDateString()}
                  </td>
                  <td className="px-4 py-4 text-sm text-gray-600 whitespace-nowrap">
                    {new Date(company.lastActivity).toLocaleDateString()}
                  </td>
                  <td className="px-4 py-4">
                    <span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[company.followUpStatus] || 'bg-gray-100 text-gray-700'}`}>
                      {company.followUpStatus.replace('_', ' ')}
                    </span>
                  </td>
                  <td className="px-4 py-4 text-xs text-gray-500 max-w-[200px] truncate">
                    {company.notes || '-'}
                  </td>
                  <td className="px-4 py-4 text-sm font-medium text-gray-900">{company.totalOpens}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

What does this code do?

  • Renders all companies from sampleCompanies in a responsive HTML table.
  • Each row shows the company name, recruiter, job title, and last activity date.
  • Human opens appear in green and bot/scanner opens in red, making it easy to spot which companies have genuine interest.
  • Save src/app/companies/page.tsx.
  • Visit http://localhost:3000/companies in your browser.
  • You should see a table with four rows (Acme Corp, TechStart Inc, BigCo, StartupXYZ) and columns for recruiter name, job title, last activity, and open counts.

Table not rendering?

  • Make sure you imported sampleCompanies (plural) from @/lib/sample-data. A typo like sampleCompany will cause a build error.
  • Confirm the file path is src/app/companies/page.tsx. Next.js uses the folder name as the route segment.

Help me fix my Companies page

Create the Events page

The Events page is a raw log of every tracking pixel fire. It shows the most detail of any page, including the full user agent string and the classification badge from your bot detection logic.

  • Create a new file at src/app/events/page.tsx and paste the following code:
import { sampleEvents } from '@/lib/sample-data';
import { Badge } from '@/components/ui/Badge';

export default function EventsPage() {
  return (
    <div>
      <div className="mb-8">
        <h1 className="text-2xl font-bold text-gray-900">Events</h1>
        <p className="mt-1 text-sm text-gray-500">
          All tracked pixel fire events
        </p>
      </div>

      <div className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
        <div className="overflow-x-auto">
          <table className="min-w-full divide-y divide-gray-200">
            <thead className="bg-gray-50">
              <tr>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Campaign</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Company</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Recipient</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Version</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User Agent</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Referrer</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Location</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Classification</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Confidence</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200">
              {sampleEvents.map((event) => (
                <tr key={event.id} className="hover:bg-gray-50">
                  <td className="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
                    {new Date(event.timestamp).toLocaleString()}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-900 font-medium max-w-[150px] truncate">
                    {event.campaignName}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-600">{event.company}</td>
                  <td className="px-4 py-3 text-xs text-gray-600">
                    <div>{event.recipient}</div>
                    {event.recipientEmail && (
                      <div className="text-gray-400">{event.recipientEmail}</div>
                    )}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-600">{event.resumeVersion}</td>
                  <td className="px-4 py-3 text-xs text-gray-500 max-w-[200px] truncate">
                    {event.userAgent}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-500 max-w-[150px] truncate">
                    {event.referrer || '-'}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-500">{event.visitorIp}</td>
                  <td className="px-4 py-3">
                    <Badge classification={event.classification} />
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-600">
                    {Math.round(event.confidenceScore * 100)}%
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

What does this code do?

  • Renders every event from sampleEvents in a wide, horizontally scrollable table.
  • Long user agent strings are truncated with max-w-[200px] truncate to keep the table readable.
  • The Badge component renders a colored pill for each classification type (Human, Bot, Scanner, Email Proxy, ATS/Recruiting, Unknown).
  • Confidence scores are displayed as percentages using Math.round(event.confidenceScore * 100).
  • Save src/app/events/page.tsx.
  • Visit http://localhost:3000/events in your browser.
  • You should see eight event rows with colored classification badges. Scroll horizontally to see all nine columns, including the truncated user agent strings.

Badges not showing colors?

  • Make sure you imported Badge (not StatusBadge) from @/components/ui/Badge. The Events page uses the classification badge, not the status badge.
  • If the table overflows and you cannot scroll, confirm the overflow-x-auto class is on the wrapping <div> directly around the table.

Help me fix my Events page badges

βœ”οΈ 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 full code for each of the three pages you just created.

import { sampleCampaigns } from '@/lib/sample-data';
import { StatusBadge } from '@/components/ui/Badge';

export default function CampaignsPage() {
  return (
    <div>
      <div className="mb-8 flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-gray-900">Campaigns</h1>
          <p className="mt-1 text-sm text-gray-500">
            Track resume sends and generate pixel URLs
          </p>
        </div>
        <button className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800">
          + New Campaign
        </button>
      </div>

      <div className="space-y-4">
        {sampleCampaigns.map((campaign) => (
          <div
            key={campaign.id}
            className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
          >
            <div className="flex items-start justify-between">
              <div>
                <div className="flex items-center gap-3">
                  <h3 className="text-lg font-semibold text-gray-900">{campaign.name}</h3>
                  <StatusBadge status={campaign.status} />
                </div>
                <p className="mt-1 text-sm text-gray-500">
                  {campaign.company} &middot; {campaign.recipient} &middot; {campaign.resumeVersion}
                </p>
              </div>
              <div className="text-right text-sm">
                <p className="text-gray-500">Created {new Date(campaign.createdAt).toLocaleDateString()}</p>
                {campaign.lastOpenAt && (
                  <p className="text-gray-400">Last open: {new Date(campaign.lastOpenAt).toLocaleDateString()}</p>
                )}
              </div>
            </div>

            <div className="mt-4 grid grid-cols-3 gap-4">
              <div className="rounded-lg bg-gray-50 p-3 text-center">
                <p className="text-2xl font-bold text-gray-900">{campaign.totalOpens}</p>
                <p className="text-xs text-gray-500">Total Opens</p>
              </div>
              <div className="rounded-lg bg-green-50 p-3 text-center">
                <p className="text-2xl font-bold text-green-600">{campaign.humanOpens}</p>
                <p className="text-xs text-gray-500">Human</p>
              </div>
              <div className="rounded-lg bg-red-50 p-3 text-center">
                <p className="text-2xl font-bold text-red-600">{campaign.botOpens}</p>
                <p className="text-xs text-gray-500">Bot/Scanner</p>
              </div>
            </div>

            <div className="mt-4">
              <p className="text-xs font-medium text-gray-500 mb-1">Tracking Pixel URL:</p>
              <code className="block rounded-lg bg-gray-100 px-3 py-2 text-xs text-gray-700 break-all">
                {campaign.pixelUrl}
              </code>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
import { sampleCompanies } from '@/lib/sample-data';

// Color mapping for follow-up status badges
const statusColors: Record<string, string> = {
  none: 'bg-gray-100 text-gray-700',
  pending: 'bg-yellow-100 text-yellow-800',
  followed_up: 'bg-blue-100 text-blue-800',
  replied: 'bg-green-100 text-green-800',
  rejected: 'bg-red-100 text-red-800',
  offer: 'bg-purple-100 text-purple-800',
};

export default function CompaniesPage() {
  return (
    <div>
      <div className="mb-8">
        <h1 className="text-2xl font-bold text-gray-900">Companies / Recipients</h1>
        <p className="mt-1 text-sm text-gray-500">
          Track activity by company and recruiter
        </p>
      </div>

      <div className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
        <div className="overflow-x-auto">
          <table className="min-w-full divide-y divide-gray-200">
            <thead className="bg-gray-50">
              <tr>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Company</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Recipient</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Job Title</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Sent</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Last Open</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Follow-up</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Notes</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Opens</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200">
              {sampleCompanies.map((company) => (
                <tr key={company.id} className="hover:bg-gray-50">
                  <td className="px-4 py-4 text-sm font-medium text-gray-900">{company.name}</td>
                  <td className="px-4 py-4 text-sm text-gray-600">{company.recruiterName}</td>
                  <td className="px-4 py-4 text-sm text-gray-500">{company.recruiterEmail}</td>
                  <td className="px-4 py-4 text-sm text-gray-600">{company.jobTitle}</td>
                  <td className="px-4 py-4 text-sm text-gray-600 whitespace-nowrap">
                    {new Date(company.sentAt).toLocaleDateString()}
                  </td>
                  <td className="px-4 py-4 text-sm text-gray-600 whitespace-nowrap">
                    {new Date(company.lastActivity).toLocaleDateString()}
                  </td>
                  <td className="px-4 py-4">
                    <span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[company.followUpStatus] || 'bg-gray-100 text-gray-700'}`}>
                      {company.followUpStatus.replace('_', ' ')}
                    </span>
                  </td>
                  <td className="px-4 py-4 text-xs text-gray-500 max-w-[200px] truncate">
                    {company.notes || '-'}
                  </td>
                  <td className="px-4 py-4 text-sm font-medium text-gray-900">{company.totalOpens}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}
import { sampleEvents } from '@/lib/sample-data';
import { Badge } from '@/components/ui/Badge';

export default function EventsPage() {
  return (
    <div>
      <div className="mb-8">
        <h1 className="text-2xl font-bold text-gray-900">Events</h1>
        <p className="mt-1 text-sm text-gray-500">
          All tracked pixel fire events
        </p>
      </div>

      <div className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
        <div className="overflow-x-auto">
          <table className="min-w-full divide-y divide-gray-200">
            <thead className="bg-gray-50">
              <tr>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Time</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Campaign</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Company</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Recipient</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Version</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User Agent</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Referrer</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Location</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Classification</th>
                <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Confidence</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200">
              {sampleEvents.map((event) => (
                <tr key={event.id} className="hover:bg-gray-50">
                  <td className="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
                    {new Date(event.timestamp).toLocaleString()}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-900 font-medium max-w-[150px] truncate">
                    {event.campaignName}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-600">{event.company}</td>
                  <td className="px-4 py-3 text-xs text-gray-600">
                    <div>{event.recipient}</div>
                    {event.recipientEmail && (
                      <div className="text-gray-400">{event.recipientEmail}</div>
                    )}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-600">{event.resumeVersion}</td>
                  <td className="px-4 py-3 text-xs text-gray-500 max-w-[200px] truncate">
                    {event.userAgent}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-500 max-w-[150px] truncate">
                    {event.referrer || '-'}
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-500">{event.visitorIp}</td>
                  <td className="px-4 py-3">
                    <Badge classification={event.classification} />
                  </td>
                  <td className="px-4 py-3 text-xs text-gray-600">
                    {Math.round(event.confidenceScore * 100)}%
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

Your three main data pages are live. Next up, you will build the remaining pages for resume versions and settings, plus wire up the API routes that serve campaign and event data as JSON.

Build the Remaining Pages and API Routes

Your main data pages are live. The campaigns, companies, and events pages all pull from sample data and render clean tables and cards.

A SaaS dashboard also needs utility pages, placeholder infrastructure, and JSON API routes the frontend can call directly. In this step, you will build the resumes comparison page, settings page, login placeholder, home landing page, database and auth stubs, and two API routes.

In this step, get ready to:
  • Create the resumes and settings pages.
  • Create the login placeholder, home page, and infrastructure stubs.
  • Create the campaigns and events API routes.
Create the resumes and settings pages

The resumes page lets you compare how different versions of your resume perform across campaigns. The settings page groups configuration placeholders so future features have a clear home.

  • Create src/app/resumes/page.tsx by pasting the following code:
'use client';

import { useState, useEffect } from 'react';
import Link from 'next/link';
import { sampleResumeVersions } from '@/lib/sample-data';

// Shape of a resume file returned from the API
interface ResumeFile {
  filename: string;
  displayName: string;
  publicPath: string;
}

export default function ResumesPage() {
  const [resumeFiles, setResumeFiles] = useState<ResumeFile[]>([]);
  const [copied, setCopied] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  // Fetch available PDF files from the API on mount
  useEffect(() => {
    fetch('/api/resumes')
      .then((res) => res.json())
      .then((data) => setResumeFiles(data.files || []))
      .catch(() => setResumeFiles([]))
      .finally(() => setLoading(false));
  }, []);

  // Copy text to clipboard with visual feedback
  const copyToClipboard = async (text: string, label: string) => {
    await navigator.clipboard.writeText(text);
    setCopied(label);
    setTimeout(() => setCopied(null), 2000);
  };

  return (
    <div>
      <div className="mb-8 flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-gray-900">Resume Versions</h1>
          <p className="mt-1 text-sm text-gray-500">
            Compare performance across resume versions and manage your PDF files
          </p>
        </div>
        <Link
          href="/generator"
          className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
        >
          + New Version
        </Link>
      </div>

      {/* Available Resume Files section */}
      <div className="mb-10">
        <h2 className="text-lg font-semibold text-gray-900 mb-4">Available Resume Files</h2>
        <p className="text-sm text-gray-500 mb-4">
          PDF files in <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs">public/resumes/</code>. Add files there to see them here.
        </p>

        {loading ? (
          <p className="text-sm text-gray-400">Loading resume files...</p>
        ) : resumeFiles.length === 0 ? (
          <div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-8 text-center">
            <p className="text-sm text-gray-500">
              No PDF files found in <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs">public/resumes/</code>.
            </p>
            <p className="mt-1 text-xs text-gray-400">
              Place a .pdf file in that folder and refresh this page.
            </p>
          </div>
        ) : (
          <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
            {resumeFiles.map((file) => (
              <div
                key={file.filename}
                className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
              >
                <div className="flex items-start justify-between">
                  <div>
                    <h3 className="text-base font-semibold text-gray-900">{file.displayName}</h3>
                    <p className="mt-0.5 text-xs text-gray-400 font-mono">{file.filename}</p>
                  </div>
                  <span className="rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
                    PDF
                  </span>
                </div>
                <p className="mt-2 text-xs text-gray-500 font-mono break-all">
                  {file.publicPath}
                </p>

                <div className="mt-4 flex flex-wrap gap-2">
                  <a
                    href={file.publicPath}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-800"
                  >
                    Open PDF
                  </a>
                  <button
                    onClick={() => copyToClipboard(file.publicPath, file.filename)}
                    className="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
                  >
                    {copied === file.filename ? 'Copied!' : 'Copy PDF Path'}
                  </button>
                  <Link
                    href={`/generator?resumeFile=${encodeURIComponent(file.publicPath)}`}
                    className="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
                  >
                    Use in Generator
                  </Link>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      {/* Existing analytics version cards */}
      <div>
        <h2 className="text-lg font-semibold text-gray-900 mb-4">Version Analytics</h2>
        <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {sampleResumeVersions.map((version) => (
            <div
              key={version.id}
              className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
            >
              <h3 className="text-lg font-semibold text-gray-900">{version.name}</h3>
              <p className="mt-1 text-sm text-gray-500">{version.description}</p>
              <p className="mt-2 text-xs text-gray-400">
                Created: {new Date(version.createdAt).toLocaleDateString()}
              </p>

              <div className="mt-4 grid grid-cols-3 gap-2">
                <div className="text-center">
                  <p className="text-xl font-bold text-gray-900">{version.campaignCount}</p>
                  <p className="text-xs text-gray-500">Campaigns</p>
                </div>
                <div className="text-center">
                  <p className="text-xl font-bold text-gray-900">{version.totalOpens}</p>
                  <p className="text-xs text-gray-500">Opens</p>
                </div>
                <div className="text-center">
                  <p className="text-xl font-bold text-green-600">{version.humanOpens}</p>
                  <p className="text-xs text-gray-500">Human</p>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

What does this code do?

The page is now a client component ('use client') because it fetches data from /api/resumes on mount and manages clipboard state.

The Available Resume Files section fetches the list of actual PDF files from your public/resumes/ folder via an API route. Each file card shows a clean display name, the raw filename, and the public path. Three buttons let you open the PDF, copy its path, or navigate to the Generator with that file pre-selected.

The Version Analytics section below it still renders the existing sampleResumeVersions cards with campaign counts and open stats.

  • Save src/app/resumes/page.tsx.
  • Run npm run dev if it is not already running, then visit http://localhost:3000/resumes in your browser.
  • You should see an "Available Resume Files" section at the top. If you have a PDF in public/resumes/, it appears as a card with Open PDF, Copy PDF Path, and Use in Generator buttons. Below that, three resume version analytics cards show the version name, description, and stats.

Seeing a 404?

If you see a 404 when visiting /resumes, that is expected right now. The page will render correctly once the full layout and sidebar are in place. Just keep going.

  • Create src/app/api/resumes/route.ts to serve the list of available PDF files:
import { readdirSync } from 'fs';
import { join } from 'path';

export async function GET() {
  // Read the public/resumes directory for PDF files
  const resumesDir = join(process.cwd(), 'public', 'resumes');

  try {
    const allFiles = readdirSync(resumesDir);

    // Filter to only .pdf files
    const pdfFiles = allFiles
      .filter((file) => file.toLowerCase().endsWith('.pdf'))
      .map((filename) => ({
        filename,
        // Create a clean display name from the filename
        displayName: filename
          .replace(/\.pdf$/i, '')
          .replace(/[_-]/g, ' ')
          .replace(/\b\w/g, (c) => c.toUpperCase()),
        publicPath: `/resumes/${filename}`,
      }));

    return Response.json({ files: pdfFiles });
  } catch {
    // If the folder does not exist, return an empty array
    return Response.json({ files: [] });
  }
}

What does this route do?

This API route uses Node's readdirSync to read the public/resumes/ directory on the server. It filters for .pdf files and returns each with a clean display name (underscores and hyphens replaced with spaces, title-cased) and the public URL path.

Since browser JavaScript cannot read server-side directories, this API route bridges the gap. The resumes page and Generator page both call this endpoint to populate their file lists.

  • Save src/app/api/resumes/route.ts.
  • Visit http://localhost:3000/api/resumes in your browser. You should see a JSON response with a files array listing any PDF files you have placed in public/resumes/.
  • Create src/app/settings/page.tsx with the following code:
export default function SettingsPage() {
  return (
    <div className="mx-auto max-w-2xl">
      <div className="mb-8">
        <h1 className="text-2xl font-bold text-gray-900">Settings</h1>
        <p className="mt-1 text-sm text-gray-500">
          Configure your resume tracker
        </p>
      </div>

      <div className="space-y-6">
        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">Owner Profile</h2>
          <div className="mt-4 space-y-4">
            <div>
              <label htmlFor="owner-email" className="block text-sm font-medium text-gray-700">Email</label>
              <input
                type="email"
                id="owner-email"
                defaultValue="owner@example.com"
                disabled
                className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50 text-gray-500"
              />
              <p className="mt-1 text-xs text-gray-400">Set via OWNER_EMAIL environment variable</p>
            </div>
            <div>
              <label htmlFor="resume-version" className="block text-sm font-medium text-gray-700">Default Resume Version</label>
              <input
                type="text"
                id="resume-version"
                defaultValue="v2.1-frontend"
                disabled
                className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50 text-gray-500"
              />
            </div>
          </div>
        </div>

        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">App Configuration</h2>
          <div className="mt-4 space-y-4">
            <div>
              <label htmlFor="app-url" className="block text-sm font-medium text-gray-700">App URL / Domain</label>
              <input
                type="url"
                id="app-url"
                defaultValue="https://your-app.vercel.app"
                disabled
                className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50 text-gray-500"
              />
              <p className="mt-1 text-xs text-gray-400">Set via NEXT_PUBLIC_APP_URL environment variable</p>
            </div>
          </div>
        </div>

        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">Notifications</h2>
          <div className="mt-4 space-y-3">
            <label className="flex items-center gap-3">
              <input type="checkbox" disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Email on human open (coming soon)</span>
            </label>
            <label className="flex items-center gap-3">
              <input type="checkbox" disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Daily digest (coming soon)</span>
            </label>
            <label className="flex items-center gap-3">
              <input type="checkbox" disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Weekly summary (coming soon)</span>
            </label>
          </div>
        </div>

        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">Privacy</h2>
          <div className="mt-4 space-y-3">
            <label className="flex items-center gap-3">
              <input type="checkbox" defaultChecked disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Anonymize IP addresses (last octet masked)</span>
            </label>
            <label className="flex items-center gap-3">
              <input type="checkbox" disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Auto-delete events older than 90 days</span>
            </label>
          </div>
        </div>

        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">Database Status</h2>
          <div className="mt-4">
            <div className="flex items-center gap-2">
              <span className="h-3 w-3 rounded-full bg-yellow-400"></span>
              <span className="text-sm text-gray-600">Not configured</span>
            </div>
            <p className="mt-2 text-xs text-gray-400">
              Currently using sample data. Configure DATABASE_URL to enable persistence.
              Supported: Vercel Postgres, Supabase, Neon.
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}

What does this code do?

The settings page groups five configuration sections into a narrow, centered column. Each section uses disabled inputs and checkboxes to show the planned settings without enabling interaction yet.

The Database Status section uses a yellow dot to signal that persistence is not configured. When you add a database later, this indicator will turn green.

  • Save src/app/settings/page.tsx.
  • Visit http://localhost:3000/settings in your browser.
  • You should see five stacked cards: Owner Profile, App Configuration, Notifications, Privacy, and Database Status with a yellow dot.

Inputs not appearing grayed out?

Make sure each <input> tag includes the disabled attribute and the bg-gray-50 class. Without disabled, the inputs will appear interactive.

Help me debug why my settings page inputs look active instead of disabled.

βœ”οΈ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

β“§ I'd like to double check the full code

Here is src/app/resumes/page.tsx:

'use client';

import { useState, useEffect } from 'react';
import Link from 'next/link';
import { sampleResumeVersions } from '@/lib/sample-data';

interface ResumeFile {
  filename: string;
  displayName: string;
  publicPath: string;
}

export default function ResumesPage() {
  const [resumeFiles, setResumeFiles] = useState<ResumeFile[]>([]);
  const [copied, setCopied] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/resumes')
      .then((res) => res.json())
      .then((data) => setResumeFiles(data.files || []))
      .catch(() => setResumeFiles([]))
      .finally(() => setLoading(false));
  }, []);

  const copyToClipboard = async (text: string, label: string) => {
    await navigator.clipboard.writeText(text);
    setCopied(label);
    setTimeout(() => setCopied(null), 2000);
  };

  return (
    <div>
      <div className="mb-8 flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold text-gray-900">Resume Versions</h1>
          <p className="mt-1 text-sm text-gray-500">
            Compare performance across resume versions and manage your PDF files
          </p>
        </div>
        <Link
          href="/generator"
          className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
        >
          + New Version
        </Link>
      </div>

      <div className="mb-10">
        <h2 className="text-lg font-semibold text-gray-900 mb-4">Available Resume Files</h2>
        <p className="text-sm text-gray-500 mb-4">
          PDF files in <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs">public/resumes/</code>. Add files there to see them here.
        </p>

        {loading ? (
          <p className="text-sm text-gray-400">Loading resume files...</p>
        ) : resumeFiles.length === 0 ? (
          <div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-8 text-center">
            <p className="text-sm text-gray-500">
              No PDF files found in <code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs">public/resumes/</code>.
            </p>
            <p className="mt-1 text-xs text-gray-400">
              Place a .pdf file in that folder and refresh this page.
            </p>
          </div>
        ) : (
          <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
            {resumeFiles.map((file) => (
              <div
                key={file.filename}
                className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
              >
                <div className="flex items-start justify-between">
                  <div>
                    <h3 className="text-base font-semibold text-gray-900">{file.displayName}</h3>
                    <p className="mt-0.5 text-xs text-gray-400 font-mono">{file.filename}</p>
                  </div>
                  <span className="rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
                    PDF
                  </span>
                </div>
                <p className="mt-2 text-xs text-gray-500 font-mono break-all">
                  {file.publicPath}
                </p>
                <div className="mt-4 flex flex-wrap gap-2">
                  <a
                    href={file.publicPath}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-800"
                  >
                    Open PDF
                  </a>
                  <button
                    onClick={() => copyToClipboard(file.publicPath, file.filename)}
                    className="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
                  >
                    {copied === file.filename ? 'Copied!' : 'Copy PDF Path'}
                  </button>
                  <Link
                    href={`/generator?resumeFile=${encodeURIComponent(file.publicPath)}`}
                    className="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
                  >
                    Use in Generator
                  </Link>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      <div>
        <h2 className="text-lg font-semibold text-gray-900 mb-4">Version Analytics</h2>
        <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {sampleResumeVersions.map((version) => (
            <div
              key={version.id}
              className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
            >
              <h3 className="text-lg font-semibold text-gray-900">{version.name}</h3>
              <p className="mt-1 text-sm text-gray-500">{version.description}</p>
              <p className="mt-2 text-xs text-gray-400">
                Created: {new Date(version.createdAt).toLocaleDateString()}
              </p>
              <div className="mt-4 grid grid-cols-3 gap-2">
                <div className="text-center">
                  <p className="text-xl font-bold text-gray-900">{version.campaignCount}</p>
                  <p className="text-xs text-gray-500">Campaigns</p>
                </div>
                <div className="text-center">
                  <p className="text-xl font-bold text-gray-900">{version.totalOpens}</p>
                  <p className="text-xs text-gray-500">Opens</p>
                </div>
                <div className="text-center">
                  <p className="text-xl font-bold text-green-600">{version.humanOpens}</p>
                  <p className="text-xs text-gray-500">Human</p>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Here is src/app/settings/page.tsx:

export default function SettingsPage() {
  return (
    <div className="mx-auto max-w-2xl">
      <div className="mb-8">
        <h1 className="text-2xl font-bold text-gray-900">Settings</h1>
        <p className="mt-1 text-sm text-gray-500">
          Configure your resume tracker
        </p>
      </div>

      <div className="space-y-6">
        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">Owner Profile</h2>
          <div className="mt-4 space-y-4">
            <div>
              <label htmlFor="owner-email" className="block text-sm font-medium text-gray-700">Email</label>
              <input
                type="email"
                id="owner-email"
                defaultValue="owner@example.com"
                disabled
                className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50 text-gray-500"
              />
              <p className="mt-1 text-xs text-gray-400">Set via OWNER_EMAIL environment variable</p>
            </div>
            <div>
              <label htmlFor="resume-version" className="block text-sm font-medium text-gray-700">Default Resume Version</label>
              <input
                type="text"
                id="resume-version"
                defaultValue="v2.1-frontend"
                disabled
                className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50 text-gray-500"
              />
            </div>
          </div>
        </div>

        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">App Configuration</h2>
          <div className="mt-4 space-y-4">
            <div>
              <label htmlFor="app-url" className="block text-sm font-medium text-gray-700">App URL / Domain</label>
              <input
                type="url"
                id="app-url"
                defaultValue="https://your-app.vercel.app"
                disabled
                className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm bg-gray-50 text-gray-500"
              />
              <p className="mt-1 text-xs text-gray-400">Set via NEXT_PUBLIC_APP_URL environment variable</p>
            </div>
          </div>
        </div>

        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">Notifications</h2>
          <div className="mt-4 space-y-3">
            <label className="flex items-center gap-3">
              <input type="checkbox" disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Email on human open (coming soon)</span>
            </label>
            <label className="flex items-center gap-3">
              <input type="checkbox" disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Daily digest (coming soon)</span>
            </label>
            <label className="flex items-center gap-3">
              <input type="checkbox" disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Weekly summary (coming soon)</span>
            </label>
          </div>
        </div>

        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">Privacy</h2>
          <div className="mt-4 space-y-3">
            <label className="flex items-center gap-3">
              <input type="checkbox" defaultChecked disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Anonymize IP addresses (last octet masked)</span>
            </label>
            <label className="flex items-center gap-3">
              <input type="checkbox" disabled className="h-4 w-4 rounded border-gray-300" />
              <span className="text-sm text-gray-500">Auto-delete events older than 90 days</span>
            </label>
          </div>
        </div>

        <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold text-gray-900">Database Status</h2>
          <div className="mt-4">
            <div className="flex items-center gap-2">
              <span className="h-3 w-3 rounded-full bg-yellow-400"></span>
              <span className="text-sm text-gray-600">Not configured</span>
            </div>
            <p className="mt-2 text-xs text-gray-400">
              Currently using sample data. Configure DATABASE_URL to enable persistence.
              Supported: Vercel Postgres, Supabase, Neon.
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}
Create the login placeholder, home page, and infrastructure stubs

Placeholder pages and stub modules keep your app navigable and your codebase consistent. They define the shape of future features without implementing them yet.

  • Create src/app/login/page.tsx with the following code:
export default function LoginPage() {
  return (
    <div className="flex min-h-[80vh] items-center justify-center">
      <div className="w-full max-w-sm rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
        <h1 className="text-2xl font-bold text-gray-900">Sign In</h1>
        <p className="mt-2 text-sm text-gray-500">
          Authentication is not yet configured. This is a placeholder.
        </p>

        <form className="mt-6 space-y-4">
          <div>
            <label htmlFor="email" className="block text-sm font-medium text-gray-700">
              Email
            </label>
            <input
              type="email"
              id="email"
              placeholder="owner@example.com"
              disabled
              className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-500 bg-gray-50"
            />
          </div>
          <div>
            <label htmlFor="password" className="block text-sm font-medium text-gray-700">
              Password
            </label>
            <input
              type="password"
              id="password"
              placeholder="********"
              disabled
              className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-500 bg-gray-50"
            />
          </div>
          <button
            type="button"
            disabled
            className="w-full rounded-lg bg-gray-300 px-4 py-2.5 text-sm font-medium text-gray-500 cursor-not-allowed"
          >
            Sign In (Coming Soon)
          </button>
        </form>

        <p className="mt-4 text-xs text-gray-400 text-center">
          TODO: Implement authentication with NextAuth.js or Clerk
        </p>
      </div>
    </div>
  );
}

What does this code do?

The login page renders a centered card with disabled email and password fields. The button is styled with cursor-not-allowed and a gray background to clearly signal that auth is not yet active.

The Login link in your sidebar now routes here. When you add authentication later, you will replace the disabled inputs with a working form.

  • Save src/app/login/page.tsx.
  • Visit http://localhost:3000/login in your browser.
  • You should see a centered sign-in card with grayed-out inputs and a disabled button that reads "Sign In (Coming Soon)".

Card not centered vertically?

Check that the outer <div> has min-h-[80vh] and items-center justify-center. Without the min-height, flexbox has no vertical space to center the card within.

Help me figure out why my login card is stuck at the top of the page.

  • Create src/app/page.tsx (this replaces the default Next.js home page) with the following code:
import Link from 'next/link';

export default function HomePage() {
  return (
    <div className="mx-auto max-w-3xl">
      <div className="mb-8">
        <h1 className="text-3xl font-bold text-gray-900">Welcome to Resume Tracker</h1>
        <p className="mt-2 text-lg text-gray-600">
          Track when your resume gets opened. Get data-driven insight into your job search.
        </p>
      </div>

      <div className="rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
        <h2 className="text-xl font-semibold text-gray-900">How it works</h2>
        <ol className="mt-4 space-y-3 text-gray-600">
          <li className="flex gap-3">
            <span className="font-bold text-blue-600">1.</span>
            Create a campaign for each job application
          </li>
          <li className="flex gap-3">
            <span className="font-bold text-blue-600">2.</span>
            Embed the tracking pixel in your resume email or hosted page
          </li>
          <li className="flex gap-3">
            <span className="font-bold text-blue-600">3.</span>
            View analytics on your dashboard when the pixel fires
          </li>
        </ol>

        <div className="mt-6 rounded-lg bg-amber-50 border border-amber-200 p-4">
          <p className="text-sm text-amber-800">
            <strong>Important:</strong> Tracking signals are approximate. Opens may be triggered by
            email scanners, bots, ATS systems, or privacy proxies. Treat data as a signal, not proof
            that a human read your resume.
          </p>
        </div>

        <div className="mt-8 flex gap-4">
          <Link
            href="/dashboard"
            className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
          >
            View Dashboard
          </Link>
          <Link
            href="/campaigns"
            className="rounded-lg border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
          >
            Manage Campaigns
          </Link>
        </div>
      </div>
    </div>
  );
}

What does this code do?

The home page is the first thing a visitor sees. It explains the tracking pixel workflow in three numbered steps and shows an amber disclaimer box warning that opens are signals, not proof.

Two Link components from next/link provide client-side navigation to the dashboard and campaigns pages without full page reloads.

  • Save src/app/page.tsx.
  • Visit http://localhost:3000 in your browser.
  • You should see the "Welcome to Resume Tracker" heading, three how-it-works steps, an amber disclaimer box, and two buttons linking to Dashboard and Campaigns.

Still seeing the default Next.js page?

The default Next.js starter also has a src/app/page.tsx. Make sure you replaced its contents entirely with the code above. If you created a new file alongside it, delete the old one.

Why is the default Next.js page still showing after I replaced page.tsx?

  • Create src/lib/db.ts with the following placeholder module:
// TODO: Database connection placeholder
// This file will be configured when a database provider is selected.
// Options: Vercel Postgres, Supabase, Neon, or PlanetScale

// TODO: Install database client package
// Example for Vercel Postgres: npm install @vercel/postgres
// Example for Neon: npm install @neondatabase/serverless

export async function getDbConnection() {
  // TODO: Implement database connection
  // const connectionString = process.env.DATABASE_URL;
  console.log('[DB] Database not configured yet. Using sample data.');
  return null;
}

export async function saveTrackingEvent(event: unknown) {
  // TODO: Save event to database
  console.log('[DB] Would save tracking event:', JSON.stringify(event, null, 2));
  return event;
}

export async function getTrackingEvents() {
  // TODO: Query events from database
  console.log('[DB] Database not configured. Returning sample data.');
  return [];
}

export async function getCampaigns() {
  // TODO: Query campaigns from database
  console.log('[DB] Database not configured. Returning sample data.');
  return [];
}

Why create placeholder modules?

These stubs define the interface your app will use for persistence and auth. When you add a real database later, you only need to fill in the function bodies. The rest of the app already imports from these paths.

The TODO comments document which packages to install and which environment variables to set, keeping future setup straightforward.

  • Save src/lib/db.ts.
  • Create src/lib/auth.ts with the following placeholder module:
// TODO: Authentication placeholder
// This file will be configured when an auth provider is selected.
// Options: NextAuth.js, Clerk, or simple API key auth

export async function isAuthenticated(): Promise<boolean> {
  // TODO: Implement authentication check
  // For MVP, the app is single-user and unprotected
  console.log('[Auth] Authentication not configured. Allowing access.');
  return true;
}

export async function getOwnerEmail(): Promise<string> {
  return process.env.OWNER_EMAIL || 'owner@example.com';
}

export function generateAuthSecret(): string {
  // TODO: Use proper secret generation
  return 'placeholder-secret-replace-in-production';
}
  • Save src/lib/auth.ts.
  • You won't see a visible change for these library files yet. They will be used when you wire up database persistence and authentication in the future.

βœ”οΈ Awesome, I've got everything!

Great. Confirm that all four files are saved: login/page.tsx, page.tsx, db.ts, and auth.ts.

β“§ I'd like to double check the full code

Here is src/app/login/page.tsx:

export default function LoginPage() {
  return (
    <div className="flex min-h-[80vh] items-center justify-center">
      <div className="w-full max-w-sm rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
        <h1 className="text-2xl font-bold text-gray-900">Sign In</h1>
        <p className="mt-2 text-sm text-gray-500">
          Authentication is not yet configured. This is a placeholder.
        </p>

        <form className="mt-6 space-y-4">
          <div>
            <label htmlFor="email" className="block text-sm font-medium text-gray-700">
              Email
            </label>
            <input
              type="email"
              id="email"
              placeholder="owner@example.com"
              disabled
              className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-500 bg-gray-50"
            />
          </div>
          <div>
            <label htmlFor="password" className="block text-sm font-medium text-gray-700">
              Password
            </label>
            <input
              type="password"
              id="password"
              placeholder="********"
              disabled
              className="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-500 bg-gray-50"
            />
          </div>
          <button
            type="button"
            disabled
            className="w-full rounded-lg bg-gray-300 px-4 py-2.5 text-sm font-medium text-gray-500 cursor-not-allowed"
          >
            Sign In (Coming Soon)
          </button>
        </form>

        <p className="mt-4 text-xs text-gray-400 text-center">
          TODO: Implement authentication with NextAuth.js or Clerk
        </p>
      </div>
    </div>
  );
}

Here is src/app/page.tsx:

import Link from 'next/link';

export default function HomePage() {
  return (
    <div className="mx-auto max-w-3xl">
      <div className="mb-8">
        <h1 className="text-3xl font-bold text-gray-900">Welcome to Resume Tracker</h1>
        <p className="mt-2 text-lg text-gray-600">
          Track when your resume gets opened. Get data-driven insight into your job search.
        </p>
      </div>

      <div className="rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
        <h2 className="text-xl font-semibold text-gray-900">How it works</h2>
        <ol className="mt-4 space-y-3 text-gray-600">
          <li className="flex gap-3">
            <span className="font-bold text-blue-600">1.</span>
            Create a campaign for each job application
          </li>
          <li className="flex gap-3">
            <span className="font-bold text-blue-600">2.</span>
            Embed the tracking pixel in your resume email or hosted page
          </li>
          <li className="flex gap-3">
            <span className="font-bold text-blue-600">3.</span>
            View analytics on your dashboard when the pixel fires
          </li>
        </ol>

        <div className="mt-6 rounded-lg bg-amber-50 border border-amber-200 p-4">
          <p className="text-sm text-amber-800">
            <strong>Important:</strong> Tracking signals are approximate. Opens may be triggered by
            email scanners, bots, ATS systems, or privacy proxies. Treat data as a signal, not proof
            that a human read your resume.
          </p>
        </div>

        <div className="mt-8 flex gap-4">
          <Link
            href="/dashboard"
            className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
          >
            View Dashboard
          </Link>
          <Link
            href="/campaigns"
            className="rounded-lg border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
          >
            Manage Campaigns
          </Link>
        </div>
      </div>
    </div>
  );
}

Here is src/lib/db.ts:

// TODO: Database connection placeholder
// This file will be configured when a database provider is selected.
// Options: Vercel Postgres, Supabase, Neon, or PlanetScale

// TODO: Install database client package
// Example for Vercel Postgres: npm install @vercel/postgres
// Example for Neon: npm install @neondatabase/serverless

export async function getDbConnection() {
  // TODO: Implement database connection
  // const connectionString = process.env.DATABASE_URL;
  console.log('[DB] Database not configured yet. Using sample data.');
  return null;
}

export async function saveTrackingEvent(event: unknown) {
  // TODO: Save event to database
  console.log('[DB] Would save tracking event:', JSON.stringify(event, null, 2));
  return event;
}

export async function getTrackingEvents() {
  // TODO: Query events from database
  console.log('[DB] Database not configured. Returning sample data.');
  return [];
}

export async function getCampaigns() {
  // TODO: Query campaigns from database
  console.log('[DB] Database not configured. Returning sample data.');
  return [];
}

Here is src/lib/auth.ts:

// TODO: Authentication placeholder
// This file will be configured when an auth provider is selected.
// Options: NextAuth.js, Clerk, or simple API key auth

export async function isAuthenticated(): Promise<boolean> {
  // TODO: Implement authentication check
  // For MVP, the app is single-user and unprotected
  console.log('[Auth] Authentication not configured. Allowing access.');
  return true;
}

export async function getOwnerEmail(): Promise<string> {
  return process.env.OWNER_EMAIL || 'owner@example.com';
}

export function generateAuthSecret(): string {
  // TODO: Use proper secret generation
  return 'placeholder-secret-replace-in-production';
}
Create the campaigns and events API routes

Your dashboard pages import sample data directly. API routes give the frontend a standard HTTP endpoint to fetch that data from, which you will switch to real database queries later.

Why the /api/ folder matters

In the Next.js App Router, you cannot have both a route.ts and a page.tsx that resolve to the same URL path. You already have src/app/campaigns/page.tsx and src/app/events/page.tsx serving your UI pages.

The api folder in the path keeps the endpoints at /api/campaigns and /api/events, which are separate from the page routes at /campaigns and /events. If you accidentally create route.ts inside src/app/campaigns/ instead of src/app/api/campaigns/, Next.js will throw a build error.

  • Create src/app/api/campaigns/route.ts with the following code:
import { sampleCampaigns } from '@/lib/sample-data';

export async function GET() {
  // TODO: Query from database when configured
  return Response.json({ campaigns: sampleCampaigns });
}
  • Save src/app/api/campaigns/route.ts.
  • Create src/app/api/events/route.ts with the following code:
import { sampleEvents } from '@/lib/sample-data';

export async function GET() {
  // TODO: Query from database when configured
  return Response.json({ events: sampleEvents });
}

What do these routes do?

Each route exports an async GET function. When a request hits /api/campaigns or /api/events, Next.js calls the matching function and returns the JSON response.

Right now they return sample data. When you add a database, you replace the import with a database query and the response stays the same shape.

  • Save src/app/api/events/route.ts.
  • Visit http://localhost:3000/api/campaigns in your browser.
  • You should see a JSON response with a campaigns array containing four campaign objects.
  • Visit http://localhost:3000/api/events to confirm it returns an events array with eight event objects.

Seeing a 404 in your terminal?

First, check what path actually returned the 404.

Scenario A: You see "GET /sw.js 404"

This is completely normal and safe to ignore. Modern browsers and extensions (like password managers) silently probe local servers looking for a service worker script (sw.js) to manage offline data. Since this project does not use a service worker, Next.js correctly responds with a 404. This is background noise that will not affect your app or prevent your pages from loading.

Scenario B: Your actual API URL returns a 404

If you visit /api/campaigns or /api/events in your browser and get a 404 page, check these three configuration issues:

  1. The file name rule: In the Next.js App Router, API route handler files must be named exactly route.ts inside their directory. If you named your file page.ts, page.tsx, or route.tsx, Next.js will not compile it as an endpoint.
  2. The folder path rule: Confirm that your API folders are correctly nested inside your api directory: src/app/api/campaigns/route.ts (not src/app/campaigns/route.ts).
  3. Reset the dev server: If you created these folders while your server was active, press Ctrl+C in your terminal to shut down the server, then run npm run dev again to force Next.js to reconstruct its routing map.

Help me debug a 404 error in my Next.js terminal or API routes.

βœ”οΈ Awesome, I've got everything!

Great. Both API routes should return JSON in your browser.

β“§ I'd like to double check the full code

Here is src/app/api/campaigns/route.ts:

import { sampleCampaigns } from '@/lib/sample-data';

export async function GET() {
  // TODO: Query from database when configured
  return Response.json({ campaigns: sampleCampaigns });
}

Here is src/app/api/events/route.ts:

import { sampleEvents } from '@/lib/sample-data';

export async function GET() {
  // TODO: Query from database when configured
  return Response.json({ events: sampleEvents });
}

Every page in the sidebar is now live. Next up, you will build a Tracking Link Generator that gives resume owners a real workflow for creating and sharing tracked URLs.

Build the Tracking Link Generator and Tracked Resume Route

Your dashboard, campaigns, companies, and events pages all display tracking data. But there is a missing link in the workflow: how does a resume owner actually generate the tracking URLs and share them with recruiters?

In this step, you will build a Tracking Link Generator page that creates pixel URLs, HTML snippets, and tracked resume page links. You will also create a tracked resume route that fires the pixel when a recruiter visits, and wire up the placeholder buttons on the Campaigns and Resumes pages so they navigate to the Generator.

In this step, get ready to:
  • Create a Tracking Link Generator page with form inputs, URL generation, and copy buttons.
  • Create a tracked resume route that fires the pixel and shows a landing page.
  • Wire up the existing placeholder buttons on Campaigns and Resumes to navigate to the Generator.
Create the Tracking Link Generator page

The Generator page is a client component that builds tracking URLs from user inputs. It produces four outputs: a raw pixel URL, an HTML image tag snippet, a tracked resume page URL, and a plain share link. Each output has a copy button.

  • Create src/app/generator/page.tsx and paste the following code:
'use client';

import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';

// Shape of a resume file returned from the API
interface ResumeFile {
  filename: string;
  displayName: string;
  publicPath: string;
}

// Shape of a saved campaign link record
interface SavedLink {
  id: string;
  slug: string;
  resumeFile: string;
  resumeDisplayName: string;
  company: string;
  recipient: string;
  campaign: string;
  role: string;
  generatedUrl: string;
  createdAt: string;
  notes: string;
}

// Sanitize a slug: lowercase, replace spaces with hyphens, remove special chars
function sanitizeSlug(input: string): string {
  return input
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^a-z0-9-]/g, '')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '');
}

export default function GeneratorPage() {
  const searchParams = useSearchParams();

  // Form state
  const [slug, setSlug] = useState('');
  const [resumeFile, setResumeFile] = useState('');
  const [company, setCompany] = useState('');
  const [recipient, setRecipient] = useState('');
  const [campaign, setCampaign] = useState('');
  const [role, setRole] = useState('');
  const [notes, setNotes] = useState('');

  // UI state
  const [resumeFiles, setResumeFiles] = useState<ResumeFile[]>([]);
  const [copied, setCopied] = useState(false);
  const [saved, setSaved] = useState(false);
  const [generated, setGenerated] = useState(false);

  // Derive base URL from the browser
  const baseUrl = typeof window !== 'undefined'
    ? window.location.origin
    : 'https://your-app.vercel.app';

  // Sanitize slug in real time
  const cleanSlug = sanitizeSlug(slug) || 'my-resume';

  // Build the generated tracking link
  const generatedLink = `${baseUrl}/r/${cleanSlug}?resumeFile=${encodeURIComponent(resumeFile)}&company=${encodeURIComponent(company)}&campaign=${encodeURIComponent(campaign)}&recipientName=${encodeURIComponent(recipient)}&role=${encodeURIComponent(role)}`;

  // Fetch available resume files from the API
  useEffect(() => {
    fetch('/api/resumes')
      .then((res) => res.json())
      .then((data) => setResumeFiles(data.files || []))
      .catch(() => setResumeFiles([]));
  }, []);

  // Pre-select resume file from query param
  useEffect(() => {
    const paramFile = searchParams.get('resumeFile');
    if (paramFile) {
      setResumeFile(paramFile);
    }
  }, [searchParams]);

  // Generate tracking link action
  const handleGenerate = () => {
    setGenerated(true);
    setSaved(false);
    setCopied(false);
  };

  // Save campaign link to localStorage
  const handleSave = () => {
    const selectedFile = resumeFiles.find((f) => f.publicPath === resumeFile);
    const record: SavedLink = {
      id: crypto.randomUUID(),
      slug: cleanSlug,
      resumeFile,
      resumeDisplayName: selectedFile?.displayName || resumeFile,
      company,
      recipient,
      campaign,
      role,
      generatedUrl: generatedLink,
      createdAt: new Date().toISOString(),
      notes,
    };

    // Read existing saved links from localStorage
    const existing = JSON.parse(localStorage.getItem('savedLinks') || '[]');
    existing.push(record);
    localStorage.setItem('savedLinks', JSON.stringify(existing));
    setSaved(true);
  };

  // Copy link to clipboard
  const handleCopy = async () => {
    await navigator.clipboard.writeText(generatedLink);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  // Open link in a new tab
  const handleOpen = () => {
    window.open(generatedLink, '_blank');
  };

  // Reset form
  const handleReset = () => {
    setSlug('');
    setResumeFile('');
    setCompany('');
    setRecipient('');
    setCampaign('');
    setRole('');
    setNotes('');
    setGenerated(false);
    setSaved(false);
    setCopied(false);
  };

  return (
    <div className="mx-auto max-w-3xl">
      <div className="mb-8">
        <h1 className="text-2xl font-bold text-gray-900">Tracking Link Generator</h1>
        <p className="mt-1 text-sm text-gray-500">
          Generate a tracked resume link, save it, and share it with recruiters
        </p>
      </div>

      {/* Form inputs */}
      <div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
        <div className="grid gap-4 sm:grid-cols-2">
          {/* Slug field */}
          <div className="sm:col-span-2">
            <label htmlFor="slug" className="block text-sm font-medium text-gray-700">Link Slug</label>
            <div className="mt-1 flex items-center gap-2">
              <span className="text-sm text-gray-400">/r/</span>
              <input
                id="slug"
                type="text"
                value={slug}
                onChange={(e) => setSlug(e.target.value)}
                placeholder="sfitzgerald-resumev2"
                className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-200"
              />
            </div>
            {slug && slug !== cleanSlug && (
              <p className="mt-1 text-xs text-gray-400">Sanitized to: {cleanSlug}</p>
            )}
          </div>

          {/* Resume file dropdown */}
          <div className="sm:col-span-2">
            <label htmlFor="resumeFile" className="block text-sm font-medium text-gray-700">Resume File</label>
            <select
              id="resumeFile"
              value={resumeFile}
              onChange={(e) => setResumeFile(e.target.value)}
              className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-200"
            >
              <option value="">Select a resume file...</option>
              {resumeFiles.map((file) => (
                <option key={file.filename} value={file.publicPath}>
                  {file.displayName} ({file.filename})
                </option>
              ))}
            </select>
            <p className="mt-1 text-xs text-gray-400">
              Files from public/resumes/. Add PDFs there to see them here.
            </p>
          </div>

          {/* Company */}
          <div>
            <label htmlFor="company" className="block text-sm font-medium text-gray-700">Company</label>
            <input
              id="company"
              type="text"
              value={company}
              onChange={(e) => setCompany(e.target.value)}
              placeholder="Acme Corp"
              className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-200"
            />
          </div>

          {/* Recipient */}
          <div>
            <label htmlFor="recipient" className="block text-sm font-medium text-gray-700">Recipient / Hiring Manager</label>
            <input
              id="recipient"
              type="text"
              value={recipient}
              onChange={(e) => setRecipient(e.target.value)}
              placeholder="Jane Smith"
              className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-200"
            />
          </div>

          {/* Campaign */}
          <div>
            <label htmlFor="campaign" className="block text-sm font-medium text-gray-700">Campaign</label>
            <input
              id="campaign"
              type="text"
              value={campaign}
              onChange={(e) => setCampaign(e.target.value)}
              placeholder="Frontend Role Q1"
              className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-200"
            />
          </div>

          {/* Role */}
          <div>
            <label htmlFor="role" className="block text-sm font-medium text-gray-700">Role</label>
            <input
              id="role"
              type="text"
              value={role}
              onChange={(e) => setRole(e.target.value)}
              placeholder="Senior Frontend Developer"
              className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-200"
            />
          </div>

          {/* Notes */}
          <div className="sm:col-span-2">
            <label htmlFor="notes" className="block text-sm font-medium text-gray-700">Notes (optional)</label>
            <input
              id="notes"
              type="text"
              value={notes}
              onChange={(e) => setNotes(e.target.value)}
              placeholder="Referral from LinkedIn, follow up in 1 week"
              className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-200"
            />
          </div>
        </div>

        {/* Action buttons */}
        <div className="mt-6 flex flex-wrap gap-3">
          <button
            onClick={handleGenerate}
            className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
          >
            Generate Tracking Link
          </button>
          <button
            onClick={handleSave}
            disabled={!generated}
            className="rounded-lg border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
          >
            Save Campaign
          </button>
          <button
            onClick={handleCopy}
            disabled={!generated}
            className="rounded-lg border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
          >
            {copied ? 'Copied!' : 'Copy Link'}
          </button>
          <button
            onClick={handleOpen}
            disabled={!generated}
            className="rounded-lg border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
          >
            Open Link
          </button>
          <button
            onClick={handleReset}
            className="rounded-lg border border-red-200 px-4 py-2.5 text-sm font-medium text-red-600 hover:bg-red-50"
          >
            Reset Form
          </button>
        </div>
      </div>

      {/* Generated link output */}
      {generated && (
        <div className="mt-6 space-y-4">
          <div className="rounded-xl border-2 border-gray-900 bg-white p-4 shadow-sm">
            <div className="flex items-center justify-between mb-2">
              <p className="text-xs font-medium text-gray-900">Generated Tracking Link</p>
              <button
                onClick={handleCopy}
                className="rounded-md bg-gray-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-gray-800"
              >
                {copied ? 'Copied!' : 'Copy'}
              </button>
            </div>
            <code className="block rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-900 font-medium break-all">
              {generatedLink}
            </code>
          </div>

          {/* Save confirmation */}
          {saved && (
            <div className="rounded-xl border border-green-200 bg-green-50 p-4">
              <p className="text-sm font-medium text-green-800">
                Saved. You can now copy or open the link above.
              </p>
              <p className="mt-1 text-xs text-green-600">
                Campaign saved to localStorage. View saved links in your browser DevTools under Application &gt; Local Storage.
              </p>
            </div>
          )}
        </div>
      )}

      {/* Usage guidance */}
      <div className="mt-6 rounded-xl border border-amber-200 bg-amber-50 p-4">
        <h3 className="text-sm font-semibold text-amber-900">How to use this link</h3>
        <ul className="mt-2 space-y-1.5 text-sm text-amber-800">
          <li><strong>Best option:</strong> Share the generated link directly. When a recruiter visits, the page fires the pixel automatically and provides a PDF download.</li>
          <li><strong>Save first:</strong> Click Save Campaign to log the link details to localStorage before sharing.</li>
          <li><strong>Accuracy note:</strong> Email clients may preload or scan links. Treat opens as signals, not proof that a human read your resume.</li>
        </ul>
      </div>
    </div>
  );
}

What does this code do?

  • useSearchParams reads the ?resumeFile= query parameter so clicking "Use in Generator" on the Resumes page pre-selects the file.
  • The resume file dropdown fetches its options from /api/resumes on mount, showing actual PDF files from public/resumes/.
  • Five action buttons control the workflow: Generate builds the URL, Save stores the record to localStorage, Copy puts the link on the clipboard, Open launches it in a new tab, and Reset clears the form.
  • The generated link includes the resumeFile parameter so the tracked resume page knows which PDF to offer for download.
  • Save Campaign writes a full record (id, slug, company, recipient, campaign, role, resumeFile, generatedUrl, createdAt, notes) to localStorage under the key savedLinks. A green confirmation banner appears after saving.
  • Save src/app/generator/page.tsx.
  • Visit http://localhost:3000/generator in your browser.
  • You should see the form with a resume file dropdown, company, recipient, campaign, role, and notes fields. The five action buttons (Generate, Save, Copy, Open, Reset) should be visible below the form.
  • Select a resume file from the dropdown (if you have a PDF in public/resumes/), type Acme Corp in the Company field, then click Generate Tracking Link. The generated URL should appear below.
  • Click Save Campaign. A green confirmation banner should appear reading "Saved. You can now copy or open the link above."
  • Click Copy Link. The button text should change to "Copied!" for two seconds.

Copy button not working?

The Clipboard API requires a secure context. If you are running on http://localhost:3000, it should work. If you are on a non-localhost HTTP URL, the browser blocks clipboard access. Deploy to HTTPS (Vercel) to fix it.

Help me debug why my copy button does not work on the Generator page.

Create the tracked resume route

The tracked resume page is what a recruiter sees when they open your shared link. It fires the tracking pixel automatically, displays campaign metadata, and provides working Download Resume and Open Resume in New Tab buttons that link to the PDF file you placed in public/resumes/.

  • Create the folder src/app/r/[slug] and add a page.tsx file with the following code:
'use client';

import { useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';

export default function TrackedResumePage() {
  const params = useParams();
  const searchParams = useSearchParams();
  const slug = params.slug as string;

  // Track download state for user feedback
  const [downloadStarted, setDownloadStarted] = useState(false);

  // Check if query params were provided (full URL mode vs short link mode)
  const hasQueryParams = searchParams.toString().length > 0;

  // Extract tracking metadata from query params or fall back to slug-based defaults
  const campaign = searchParams.get('campaign') || slug;
  const company = searchParams.get('company') || (hasQueryParams ? 'Unknown' : '');
  const recipientName = searchParams.get('recipientName') || searchParams.get('recipient') || 'Hiring Manager';
  const resumeVersion = searchParams.get('resumeVersion') || 'v1';
  const source = searchParams.get('source') || 'direct_link';
  const role = searchParams.get('role') || '';
  const resumeFile = searchParams.get('resumeFile') || '';

  // The resumeFile param is already the full public path (e.g. /resumes/S_Fitzgerald_Temp.pdf)
  const pdfPath = resumeFile;

  // Build the pixel URL to fire tracking on page load
  const pixelParams = new URLSearchParams({
    campaign,
    company: company || slug,
    recipientName,
    resumeVersion,
    source,
  }).toString();
  const pixelUrl = `/api/pixel?${pixelParams}`;

  // Handle download click with user feedback
  const handleDownload = () => {
    if (!pdfPath) return;
    // Create a temporary link to trigger download
    const link = document.createElement('a');
    link.href = pdfPath;
    link.download = pdfPath.split('/').pop() || 'resume.pdf';
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    setDownloadStarted(true);
  };

  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
      <div className="w-full max-w-md rounded-xl border border-gray-200 bg-white p-8 shadow-sm text-center">
        <h1 className="text-2xl font-bold text-gray-900">Resume</h1>
        <p className="mt-1 text-xs font-mono text-gray-400">/r/{slug}</p>

        {company && (
          <p className="mt-2 text-sm text-gray-500">
            {company} &middot; {campaign}
          </p>
        )}
        {role && (
          <p className="mt-1 text-xs text-gray-400">Role: {role}</p>
        )}
        <p className="mt-1 text-xs text-gray-400">
          For: {recipientName} &middot; Version: {resumeVersion}
        </p>

        {pdfPath ? (
          <div className="mt-6 space-y-3">
            {/* Download Resume button */}
            <button
              onClick={handleDownload}
              className="inline-block w-full rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
            >
              Download Resume
            </button>

            {/* Open Resume button */}
            <a
              href={pdfPath}
              target="_blank"
              rel="noopener noreferrer"
              className="inline-block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
            >
              Open Resume in New Tab
            </a>

            {/* Download started confirmation */}
            {downloadStarted && (
              <div className="rounded-lg bg-green-50 border border-green-200 p-3">
                <p className="text-sm font-medium text-green-800">
                  Download started.
                </p>
                <p className="mt-1 text-xs text-green-600">
                  If the download did not begin, <a href={pdfPath} target="_blank" rel="noopener noreferrer" className="underline font-medium">click here to open the PDF</a>.
                </p>
              </div>
            )}
          </div>
        ) : (
          <div className="mt-6 rounded-lg bg-gray-50 border border-gray-200 p-6">
            <p className="text-sm text-gray-600">
              No resume file specified. Add a resumeFile parameter to the URL to enable the download button.
            </p>
          </div>
        )}

        <p className="mt-4 text-xs text-gray-400">
          Shared via {source.replace('_', ' ')}
        </p>

        {/* Show note when no query params are present (short link mode) */}
        {!hasQueryParams && (
          <div className="mt-4 rounded-lg bg-amber-50 border border-amber-200 p-3">
            <p className="text-xs text-amber-700">
              This page is using the short link slug &ldquo;{slug}&rdquo; without query parameters. In production, metadata will load from a database lookup.
            </p>
          </div>
        )}
      </div>

      {/* Invisible tracking pixel fires on page load */}
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={pixelUrl}
        width={1}
        height={1}
        alt=""
        className="absolute opacity-0 pointer-events-none"
      />
    </div>
  );
}

What does this code do?

  • The page is now a client component using useParams and useSearchParams to read the slug and query parameters. This enables interactive state for the download confirmation.
  • Two prominent buttons appear when a resumeFile parameter is present: Download Resume triggers a programmatic download via a temporary anchor element, and Open Resume in New Tab opens the PDF directly.
  • After clicking Download, a green confirmation banner appears saying "Download started" with a fallback link in case the browser blocked the automatic download.
  • The invisible <img> tag still fires the tracking pixel API on every page visit, regardless of whether the user clicks download. The pixel uses the campaign metadata from query parameters.
  • The resumeFile parameter is expected to be the full public path (e.g. /resumes/S_Fitzgerald_Temp.pdf) as generated by the Generator page.
  • Save src/app/r/[slug]/page.tsx.
  • Visit http://localhost:3000/r/test-campaign?campaign=Test&company=Acme&recipientName=Jane&resumeFile=/resumes/S_Fitzgerald_Temp.pdf in your browser.
  • You should see a centered card showing "Acme" and "Test" with two buttons: Download Resume and Open Resume in New Tab.
  • Click Download Resume. A green banner should appear saying "Download started" with a fallback link.
  • Check your terminal. The tracking pixel API should log the event with the query parameters you passed in the URL.

Not seeing the tracking log in terminal?

Make sure your dev server is running (npm run dev). The invisible image fires a request to /api/pixel which logs the event. Check that the img tag's src attribute contains the correct pixel URL by inspecting the page source in DevTools.

Help me debug why my tracked resume page is not firing the pixel.

Create the public resumes folder

The tracked resume page links to PDF files served from a public/resumes/ directory. Next.js serves anything in public/ as static files at the root URL path.

  • Create the public/resumes folder and a .gitkeep file by running:
mkdir -p public/resumes
touch public/resumes/.gitkeep
  • Create a public/resumes/README.md file with instructions for placing PDF files:
# Resume PDFs

Place your resume PDF files in this folder.

Files here are served as static assets by Next.js.
For example, `sean-resume.pdf` is accessible at `/resumes/sean-resume.pdf`.

## Usage

1. Export your resume as PDF.
2. Name it something URL-friendly (no spaces): `sean-resume.pdf`
3. Drop it in this folder.
4. In the Generator, enter the filename in the "Resume PDF Filename" field.
5. The tracked resume page will link to `/resumes/sean-resume.pdf`.

## Important

- No upload system exists yet. Manually place PDFs here.
- Files in `public/` are committed to git and deployed with the app.
- Keep file sizes reasonable (under 5 MB) for fast downloads.
  • Save public/resumes/README.md.
  • Confirm the folder exists by running:
ls public/resumes

You should see .gitkeep and README.md in the output.

Why a .gitkeep file?

Git does not track empty directories. The .gitkeep file ensures the public/resumes/ folder is committed to your repository even before you add any PDF files. When you later add a real resume PDF, the folder is already in place.

Wire up the placeholder buttons

The Campaigns page has a "+ New Campaign" button and the Resumes page has a "+ New Version" button. Both are currently non-functional placeholders. You will make them navigate to the Generator page.

  • Open src/app/campaigns/page.tsx.
  • Find this line at the top of the file:
import { sampleCampaigns } from '@/lib/sample-data';
  • Add a Link import so the line now looks like this:
import Link from 'next/link';
import { sampleCampaigns } from '@/lib/sample-data';
  • Find the placeholder button element:
<button className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800">
  + New Campaign
</button>
  • Replace it with a Link component:
<Link
  href="/generator"
  className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
>
  + New Campaign
</Link>
  • Save src/app/campaigns/page.tsx.
  • Open src/app/resumes/page.tsx.
  • Add the same Link import at the top of the file:
import Link from 'next/link';
import { sampleResumeVersions } from '@/lib/sample-data';
  • Find the "+ New Version" placeholder button:
<button className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800">
  + New Version
</button>
  • Replace it with a Link that navigates to the Generator:
<Link
  href="/generator"
  className="rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
>
  + New Version
</Link>
  • Save src/app/resumes/page.tsx.
  • Return to your browser and refresh any page.
  • Click the + New Campaign button on the Campaigns page. It should navigate to /generator.
  • Click the + New Version button on the Resumes page. It should also navigate to /generator.

Buttons still not navigating?

Make sure you replaced the <button> elements with <Link> components, not just wrapped the button inside a Link. A Link renders an <a> tag directly. Also confirm the import Link from 'next/link' line is at the top of each file.

Help me fix my + New Campaign button so it navigates to the Generator page.

Verify the build and deploy
  • Run the production build to confirm everything compiles by running:
npm run build
  • You should see all routes listed with no errors. The /generator route appears as a static page and /r/[slug] as a dynamic route.
  • Commit and push your changes by running:
git add .
git commit -m "Add tracking link generator and tracked resume route"
git push origin main
  • Vercel automatically deploys the push. After a minute, visit your live Generator page and create a tracked link to confirm it works in production.

Your resume tracker now has a complete real-world workflow. You can generate a link, share it with a recruiter, and see the activity on your dashboard. Next up, you will update the README and deploy the final version to Vercel.

Update README, Verify Build, and Deploy to Vercel

Your resume tracker now has a complete workflow. The Generator creates tracked URLs, the tracked resume page fires the pixel, and the dashboard displays the results. All the placeholder buttons navigate to the right place.

Before sharing this project with the world, you need three things: documentation that explains what the project does, a clean production build that proves nothing is broken, and a live deployment anyone can visit. In this step, you will write a comprehensive README, verify the build compiles cleanly, and deploy the app to Vercel.

In this step, get ready to:
  • Replace the default README with comprehensive project documentation.
  • Run a production build to catch any TypeScript or compilation errors.
  • Commit, push to GitHub, and deploy live on Vercel.
Replace the README with project documentation

Good documentation helps others (and your future self) understand what the project does, how to run it, and what its limitations are. The default README.md from the cloned repo is minimal. You will replace it with a comprehensive file covering the project's purpose, architecture, and deployment.

  • Open README.md in the root of your Resume-Tracker folder.
  • Select all existing content and replace it with the following:
# Resume Tracker

A personal resume tracking web application. Embed a tiny invisible tracking pixel in your resume emails or hosted resume pages. Every time someone opens the email or views the page, the pixel fires and the app records metadata about the open event.

## What It Tracks

- **Timestamp** of each open event
- **Campaign** (which job application triggered the open)
- **Company / Recipient** (who you sent the resume to, including name and email)
- **Resume Version** (which version of your resume was viewed)
- **User Agent** (browser/client that loaded the pixel)
- **Referrer** (where the request came from)
- **IP Address** (anonymised, last octet masked)
- **Device Hints** (OS, browser, mobile/desktop)
- **Classification** (likely human, bot, scanner, email proxy, ATS, or unknown)
- **Confidence Score** (how certain the classification is)
- **Follow-up Status** (none, pending, followed_up, replied, rejected, offer)
- **Notes** (personal notes about the application)

## Short Tracked Resume Links

Instead of sharing a long URL with query parameters, you can create clean custom slugs:

```
https://your-app.vercel.app/r/sean-resume-v2
https://your-app.vercel.app/r/apex-v2
https://your-app.vercel.app/r/ai-developer-resume
```

### How it works

1. In the Generator, enter a **Custom Link Slug** (e.g. `sean-resume-v2`).
2. The slug is sanitized: lowercased, spaces become hyphens, special characters are removed.
3. The primary output is a short link like `/r/sean-resume-v2` with no query params.
4. The full URL with query params is available under an "Advanced" toggle for debugging.

### Two modes

- **With query params:** If the tracked resume page receives query parameters (campaign, company, etc.), it uses them to populate the page. This is the full-data mode.
- **Without query params (short link only):** If no query params are present, the page uses the slug as the campaign identifier and shows placeholder data. An amber banner explains that production will use a database lookup.

### Production path

In production, the slug should map to a database record containing all campaign metadata. The short link alone (`/r/sean-resume-v2`) would load everything from the database without needing query params at all.

## Resume PDF Support

The tracked resume page can link to an actual PDF file for download.

### How it works

1. Place your resume PDF in `public/resumes/` (e.g. `public/resumes/sean-resume.pdf`).
2. In the Generator, enter the filename in the "Resume PDF Filename" field.
3. The generated tracked resume URL includes `resumeFile=sean-resume.pdf`.
4. When a recruiter visits the tracked page, they see a "Download Resume" button that links to `/resumes/sean-resume.pdf`.

### Limitations

- No upload system yet. Manually place PDFs in `public/resumes/`.
- Files in `public/` are committed to git and deployed with the app.
- Keep file sizes reasonable (under 5 MB) for fast downloads.

## Recipient Contact Tracking

The Generator collects recipient name, email, and job title. The Companies page shows full contact details, follow-up status, and notes for each application.

### MVP Mode vs Production Mode

**MVP mode (current):** `recipientName` and `recipientEmail` are included as query parameters in tracking URLs. This is acceptable for a private tracker during testing.

**Production mode (future):** Replace query-parameter recipient data with a generated tracking ID that maps to recipient details in a private database. Never expose recipient email addresses in URLs shared publicly or indexed by search engines.

## Important Limitations

Tracking pixel opens are **signals, not proof** that a human read your resume. False positives come from:

- Email security scanners (Barracuda, Proofpoint, Mimecast, etc.)
- Email client prefetching / image proxies (Gmail, Apple Mail, Outlook)
- ATS systems previewing attachments (Greenhouse, Lever, Workday)
- Corporate firewalls and link scanners
- Search engine crawlers (Googlebot, Bingbot)
- Browser extensions and privacy tools
- VPN exit nodes triggering geo-inaccuracy
- Shared devices or multiple tabs

Always treat data as approximate. Never make hiring decisions or confrontational assumptions based on open signals alone.

## Planned Stack

| Layer | Technology |
|-------|------------|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS v4 |
| Deployment | Vercel (Hobby plan, free) |
| Database | TBD (Vercel Postgres / Supabase / Neon) |
| Auth | TBD (NextAuth.js / Clerk) |

## MVP Features

- [x] Tracking pixel API endpoint (returns 1x1 transparent GIF)
- [x] Bot/scanner detection with six classification categories
- [x] Dashboard with 9 KPI cards
- [x] Campaign management with generated pixel URLs
- [x] Company/recipient tracking with email, follow-up status, and notes
- [x] Full event log with classification badges and recipient info
- [x] Resume version comparison
- [x] Tracking Link Generator with custom short slugs and recipient contact fields
- [x] Short tracked resume links (`/r/custom-slug`)
- [x] Tracked resume page with PDF download support
- [x] Resume PDF hosting via public/resumes/
- [x] Settings page with configuration placeholders
- [x] Placeholder login page (auth not yet wired)
- [ ] Database persistence (currently using sample data)
- [ ] Authentication and access control
- [ ] Slug-to-database lookup for short links
- [ ] Email notifications on human opens
- [ ] Replace URL-based recipient params with private tracking IDs

## How the Tracking Pixel Works

```
1. You create a campaign for a job application
2. The app generates a unique pixel URL:
   https://your-app.vercel.app/api/pixel?campaign=X&company=Y&resumeVersion=Z
3. You embed this URL as an <img> tag in your resume email or hosted page
4. When the recipient opens the email/page, their client requests the image
5. The API route:
   - Extracts metadata (user agent, referrer, IP, timestamps)
   - Classifies the request (human, bot, scanner, etc.)
   - Logs the event (console for now, database later)
   - Returns a 1x1 transparent GIF with no-cache headers
6. You view the data on your dashboard
```

## Dashboard and GUI

The app includes a full SaaS-style dashboard:

- **Home** - Welcome page with quick-start instructions
- **Dashboard** - 9 KPI cards + recent events table
- **Campaigns** - Create/manage campaigns with generated pixel URLs
- **Companies** - Track opens by company, recruiter email, follow-up status, and notes
- **Events** - Full event log with recipient name/email and classification badges
- **Resumes** - Compare resume version performance
- **Generator** - Create short tracked links with custom slugs, recipient details, and resume PDF filename
- **Settings** - Configuration placeholders (owner email, app URL, notifications, privacy, DB status)
- **Login** - Placeholder auth page (non-functional)

## Bot / Scanner Detection

Classification uses heuristic user-agent and referrer matching:

| Category | Examples |
|----------|----------|
| `likely_human` | Modern browser + OS combination, realistic user agent |
| `likely_bot` | Googlebot, Bingbot, GPTBot, short/missing UA |
| `likely_scanner` | Barracuda, Proofpoint, Mimecast, Fortinet |
| `email_proxy` | Microsoft Office, Outlook, Gmail Image Proxy |
| `ats_or_recruiting_software` | Greenhouse, Lever, Workday, iCIMS |
| `unknown` | Does not match any known pattern |

Confidence scores range from 0.50 (unknown) to 0.99 (high-confidence bot match).

## Project Structure

```
Resume-Tracker/
β”œβ”€β”€ public/
β”‚   └── resumes/            # Place resume PDFs here
β”‚       β”œβ”€β”€ .gitkeep
β”‚       └── README.md
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app/
β”‚   β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”‚   β”œβ”€β”€ pixel/route.ts      # Tracking pixel endpoint
β”‚   β”‚   β”‚   β”œβ”€β”€ campaigns/route.ts  # Campaigns API
β”‚   β”‚   β”‚   └── events/route.ts     # Events API
β”‚   β”‚   β”œβ”€β”€ dashboard/page.tsx      # Dashboard page
β”‚   β”‚   β”œβ”€β”€ campaigns/page.tsx      # Campaigns page
β”‚   β”‚   β”œβ”€β”€ companies/page.tsx      # Companies page
β”‚   β”‚   β”œβ”€β”€ events/page.tsx         # Events page
β”‚   β”‚   β”œβ”€β”€ resumes/page.tsx        # Resume versions page
β”‚   β”‚   β”œβ”€β”€ generator/page.tsx      # Tracking Link Generator
β”‚   β”‚   β”œβ”€β”€ r/[slug]/page.tsx       # Tracked resume page (short links)
β”‚   β”‚   β”œβ”€β”€ settings/page.tsx       # Settings page
β”‚   β”‚   β”œβ”€β”€ login/page.tsx          # Login placeholder
β”‚   β”‚   β”œβ”€β”€ page.tsx                # Home page
β”‚   β”‚   β”œβ”€β”€ layout.tsx              # Root layout with sidebar
β”‚   β”‚   └── globals.css             # Tailwind CSS import
β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”œβ”€β”€ dashboard/KPICards.tsx   # KPI card grid
β”‚   β”‚   β”œβ”€β”€ layout/Sidebar.tsx      # Navigation sidebar
β”‚   β”‚   └── ui/
β”‚   β”‚       β”œβ”€β”€ Badge.tsx           # Classification badges
β”‚   β”‚       └── Card.tsx            # Reusable KPI card
β”‚   └── lib/
β”‚       β”œβ”€β”€ types.ts                # TypeScript interfaces
β”‚       β”œβ”€β”€ sample-data.ts          # Realistic sample data
β”‚       β”œβ”€β”€ bot-detection.ts        # Request classifier
β”‚       β”œβ”€β”€ tracking.ts             # Event processing
β”‚       β”œβ”€β”€ analytics.ts            # KPI computation
β”‚       β”œβ”€β”€ db.ts                   # Database placeholder
β”‚       └── auth.ts                 # Auth placeholder
β”œβ”€β”€ README.md
β”œβ”€β”€ package.json
└── tsconfig.json
```

## Local Development

```bash
# Clone the repository
git clone https://github.com/SFitz911/Resume-Tracker.git
cd Resume-Tracker

# Install dependencies
npm install

# Start the development server
npm run dev

# Open in your browser
# http://localhost:3000
```

## Deploy to Vercel

1. Push your code to GitHub
2. Go to [vercel.com](https://vercel.com) and sign in with GitHub
3. Click **Add New** > **Project**
4. Import the `Resume-Tracker` repository
5. Vercel auto-detects Next.js - use default settings
6. Click **Deploy**
7. Your app will be live at `https://your-project.vercel.app`

Automatic deployments are enabled: every push to `main` triggers a new production build.

## Privacy and Ethics

- IP addresses are masked (last octet replaced with `x`) to limit personally identifiable information.
- No cookies are set by the tracking pixel.
- Data is only accessible to the app owner (single-user design).
- Recipient email addresses in URLs are for MVP testing only. Production should use opaque tracking IDs.
- Short link slugs do not expose recipient data by design.
- This tool is intended for personal job search analytics only.
- Do not use this to track people without their awareness in contexts where consent is required.
- Comply with your local privacy regulations (GDPR, CCPA, etc.) if you extend this tool.

## Roadmap

1. **Database integration** - Persist events to Vercel Postgres or Supabase
2. **Slug-to-record lookup** - Short links resolve metadata from database instead of query params
3. **Authentication** - Protect the dashboard with NextAuth.js or Clerk
4. **Private tracking IDs** - Replace URL query params with opaque IDs mapped to a database
5. **Notifications** - Email alerts on human opens
6. **Resume upload** - Upload PDFs via the dashboard instead of manual file placement
7. **Export** - CSV/JSON export of event data
8. **Webhooks** - Real-time notifications to Slack or Discord
9. **Advanced analytics** - Time-of-day patterns, geographic estimates, repeat-open tracking

What does this README cover?

The README documents everything a developer (or recruiter viewing your GitHub) needs to understand the project at a glance. It covers the project purpose, what metadata is tracked, honest limitations about false positives, the tech stack, current features, how the pixel works, the full project structure, local setup, deployment steps, privacy considerations, and future plans.

  • Save README.md.

Formatting looks odd in your editor?

Markdown files render differently depending on your editor. The raw text with # headings and | table syntax is correct. You can preview the rendered version on GitHub after you push.

Ask for help: Why does my README markdown look broken in VS Code?

Run the production build

A production build compiles your TypeScript, checks for type errors, and generates optimised output. If any file has a typo, a missing import, or an incompatible type, the build will catch it now instead of after deployment.

  • Run the production build by entering this command in your terminal:
npm run build
  • Wait for the build to complete. You should see output listing each route with symbols like β—‹ (static) or Ζ’ (dynamic), followed by a success message.

What do the build output symbols mean?

Next.js shows route information during the build. The β—‹ symbol means the route was prerendered as static HTML. The Ζ’ symbol means the route uses a serverless function (like your tracking pixel API). A successful build means zero TypeScript errors across your entire project.

πŸ™‹β€β™€οΈ Build failing with TypeScript errors?

Check the error message for the file path and line number. Common causes include missing imports, typos in variable names, or a missing return statement. Compare your code against the original snippets from earlier steps.

Ask for help: My npm run build is failing with a TypeScript error. Help me debug it.

Commit, push, and deploy to Vercel

With a clean build confirmed, you can commit everything to Git and push to GitHub. Then you will import the repository into Vercel for automatic deployments.

  • Stage all files and commit by running these commands:
git add .
git commit -m "feat: complete resume tracker MVP with dashboard, tracking pixel API, and bot detection"

What does this commit message mean?

The feat: prefix follows the Conventional Commits standard. It signals that this commit adds a new feature (the entire MVP). This makes your git history easy to scan and understand.

  • Push your code to GitHub by running:
git push origin main
  • You should see output confirming the push to the main branch on GitHub.

Push rejected or authentication failed?

If you see a permission error, make sure you are authenticated with GitHub. You may need to set up a personal access token or use SSH keys. If the push is rejected because the remote has changes you do not have locally, run git pull --rebase origin main first, then push again.

Ask for help: My git push to GitHub is failing. Help me fix authentication or sync issues.

Now that your code is on GitHub, you will deploy it to Vercel. Vercel provides zero-configuration deployment for Next.js projects and will automatically rebuild your app every time you push.

  • Open your browser and go to vercel.com.
  • Sign in with your GitHub account. If you do not have a Vercel account yet, click **Sign Up** and choose **Continue with GitHub**.
  • Click **Add New...** in the top-right area of the dashboard.
  • Select **Project** from the dropdown.
  • Find the Resume-Tracker repository in the list and click **Import**.
  • Vercel auto-detects the Next.js framework. Leave all settings at their defaults (Framework Preset: Next.js, Root Directory: ./).
  • Click **Deploy**.

What is Vercel doing during deployment?

Vercel clones your repo, runs npm install, then executes next build (the same build you just ran locally). It deploys the output to a global CDN. Your /api/pixel route becomes a serverless function that runs on demand.

The Hobby plan is free for personal, non-commercial use. It includes automatic deployments on every push to main, preview deployments for pull requests, and 1,000,000 function invocations per month.

  • Wait for the build to complete. You will see a "Congratulations" screen with your production URL (something like https://resume-tracker-xxx.vercel.app).
  • Click the production URL or the **Visit** button to open your live app.
  • Navigate through the sidebar to confirm the Dashboard, Campaigns, Companies, Events, Resumes, and Settings pages all load correctly.

Test your tracking pixel live

Open a new browser tab and visit your deployed pixel URL directly. It follows the format https://your-app.vercel.app/api/pixel?campaign=test&company=demo. The browser will show a tiny empty image (the 1x1 GIF). You can check the Vercel function logs to see the tracking event that was recorded.

πŸ™‹β€β™€οΈ Deployment failed on Vercel?

Click on the failed deployment in the Vercel dashboard to see the build logs. The most common issue is a TypeScript error that your local build did not catch (different Node.js versions can surface new warnings). Fix the error locally, commit, and push again. Vercel will automatically retry the deployment.

Ask for help: My Vercel deployment is failing. Help me read the build logs and fix the issue.

From now on, every time you push to main, Vercel automatically builds and deploys the latest version of your app.

Secret mission

Add Real-Time Filtering and Search to the Events Page

Your Events page shows every tracking event in one static list. In a real job search, you might have dozens or hundreds of events across multiple companies. Can you add interactive search, classification filtering, and date range controls so you can instantly find specific opens?

Clean Up Your Resources

Clean Up Your Resources

This project runs on Vercel's free Hobby plan, so there are no ongoing costs for keeping it deployed. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Vercel project deployment (live app at your Vercel URL).
  • Local Resume-Tracker project folder on your Desktop.
  • GitHub repository (SFitz911/Resume-Tracker).

Keep everything running

No action needed. Choose this if you want your resume tracker to stay live and accessible.

  • Your Vercel deployment stays live indefinitely at no cost on the Hobby plan.
  • Vercel only runs serverless functions when a request comes in, so there is no idle cost.
  • Your local project files remain on your Desktop for future development.
  • Your GitHub repo stays available for future pushes and automatic Vercel deployments.

Pause - I'll come back to this later

No pause action is needed. Vercel only runs functions on request, so the app costs nothing when idle.

  • Your deployment stays live but incurs zero cost when nobody visits it.
  • You can stop the local dev server if it is still running by pressing Ctrl+C in the terminal.
  • Everything will be ready to pick back up whenever you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Delete the Vercel project: open the Vercel dashboard, select your Resume Tracker project, navigate to Settings, scroll to the bottom, and click Delete Project.
  • Delete the local project folder by running these commands in your terminal:

macOS

cd ~/Desktop
rm -rf Resume-Tracker

Windows

cd ~\Desktop
rmdir /s /q Resume-Tracker
  • Delete the GitHub repository: open the repository page on GitHub, go to Settings, scroll to the Danger Zone section, and select Delete this repository.

That's a Wrap!

That's a Wrap!

Nice work! You have built a complete resume tracking application from scratch using Next.js, TypeScript, and Tailwind CSS.

You now have a deployed, production-ready web app that demonstrates full-stack development skills, from API route design to SaaS dashboard UI patterns.

You've learned how to:

  • Create a tracking pixel API route in Next.js that captures request metadata, classifies visitors using bot detection heuristics, and returns a transparent 1x1 GIF.
  • Design a SaaS-style dashboard with 9 KPI cards, responsive tables, sidebar navigation, and classification badges across multiple pages.
  • Deploy a full-stack Next.js application to Vercel with automatic GitHub-triggered deployments on every push to main.
  • Added real-time filtering and search to the Events page so you can instantly find specific company opens by name, classification type, or date range.
Final Thoughts: Tracking Actual PDF Resumes

Now that your dashboard is live, you might be tempted to embed a tracking pixel directly inside your PDF resume file. Before doing that, it is important to understand why traditional tracking pixels behave differently in PDFs and how you can track your PDF resume safely and professionally.

1. Why Traditional Pixels Fail in PDFs

In HTML emails, the email client reads an image tag and automatically triggers an HTTP request to load the image. PDFs work differently. They usually render images locally from binary streams embedded inside the document. Because of this, PDFs do not reliably auto-query external image URLs when viewed.

Technically, you can write low-level PDF JavaScript, such as an open action, to trigger a network request when the file is opened. However, modern PDF readers like Adobe Acrobat, Chrome, and Edge often block this behavior by default. Even worse, it may display a serious security warning to the recruiter, which can instantly damage your application’s credibility.

2. The Solution: Click-to-Track Redirects

The safest and most reliable way to track engagement with a PDF resume is to turn your static links into tracked redirects.

Instead of linking directly to your portfolio, LinkedIn, GitHub, or project page, route the link through your tracking API first.

For example, the β€œView Portfolio” link inside your PDF resume could point to:

https://your-app.vercel.app/api/pixel?campaign=google-frontend&company=Google&source=pdf

When the recruiter clicks the link:

  1. They are routed to your API route.
  2. Your server records metadata such as IP address, user agent, timestamp, campaign, company, and source.
  3. Your API instantly redirects them to your real portfolio URL.

This gives you a successful tracking event on your dashboard with zero security warnings, zero custom PDF hacking, and a seamless recruiter experience.

One more thought: this may also be a good time to build your own professional website to showcase your portfolio, projects, resume versions, and case studies in one clean place.

Ready to quiz yourself?