← Back to home
Portfolio project

Building Job Hunt OS

A full-stack job search tracker I designed and built from scratch — covering product design, data architecture, auth, file storage, analytics, and deployment.

The problem

I spent months tracking job applications in spreadsheets. By the third week, the sheet had 47 columns, broken conditional formatting, and I had completely lost track of who had responded and who I still needed to follow up with.

I tried existing job tracker apps. They were either too simplistic (no analytics, no documents), too enterprise-heavy (designed for recruiters, not candidates), or visually exhausting. None of them felt like a workspace — a calm, focused place to manage something that already causes enough anxiety.

So I built one.

What it does

  • Track applications with status, priority, salary, work setup, visa sponsorship, and notes
  • Pipeline view (Saved → Applied → Screening → Interview → Final Round → Offer)
  • Log interviews with type, format, prep notes, and outcome tracking
  • Follow-up manager with due dates, types, and completion tracking
  • Notes by category: company research, interview prep, salary, reflection
  • Document storage: upload resumes, cover letters, certificates with private Supabase Storage
  • Analytics: response rates, interview conversion, applications over time, country breakdown
  • Goals: set weekly application and follow-up targets, track week-on-week
  • Demo mode: public read-only workspace with seeded data
  • Landing page and case study for portfolio

Architecture

The app uses Next.js 16 App Router with a strict server/client boundary. Data fetching happens entirely in Server Components — no API routes for reads. Mutations go through Server Actions or client-side Supabase calls with RLS enforced at the database level.

Supabase handles everything backend: PostgreSQL for the database, GoTrue for auth (email + session cookies via @supabase/ssr), and Storage for private file uploads. Row-Level Security policies ensure users can only access their own data — even if the anon key is exposed to the client.

The proxy pattern (a named proxy export in proxy.ts) guards all authenticated routes. Next.js 16 deprecated middleware.ts for this pattern; the named export approach is the recommended replacement.

Analytics are computed entirely server-side in lib/analytics.ts— pure functions that receive typed arrays and return serializable data. This data is passed as props to Recharts client components, keeping the RSC boundary clean.

Key technical decisions

RSC → Client boundary

The Tabs component originally used a render-prop pattern (children: (activeTab) => ReactNode). This breaks Next.js 16's RSC boundary — functions can't cross from Server to Client components.

The fix: a TabsContext + TabsPanel slot pattern. Server components render content into named slots as plain ReactNode children. The Tabs client component manages active state via context; TabsPanelreads context and hides/shows its content. No functions cross the boundary.

Zod + HTML empty strings

HTML <select> elements emit ""when no option is chosen. Zod's z.enum() rejects empty strings, causing silent validation failures wherehandleSubmit never fires.

Fix: z.preprocess(v => v === '' ? null : v, z.enum([...]).nullable().optional()). The preprocessor converts empty strings to null before Zod validates the type.

Private file storage

Documents are stored in a private Supabase Storage bucket with RLS. Files are uploaded to {user_id}/{timestamp}-{filename} paths. Download links are signed URLs generated client-side on demand (1-hour TTL) — never pre-generated or stored in the database.

Goals upsert reliability

The initial goals implementation used Supabase's upsert with onConflict: 'user_id'. This silently failed when the UNIQUE constraint on goals.user_iddidn't exist yet.

The fix: explicit INSERT or UPDATE based on whether a row already exists (determined from the goals prop passed from the Server Component). No conflict detection needed, and errors surface properly.

What I'd do differently

  • Start with a design system. The first three phases accumulated several inconsistencies in typography sizes and spacing that took a dedicated pass to clean up. Starting with defined tokens would have prevented this.
  • Plan the RSC boundary upfront. The Tabs bug surfaced late because the component was designed before the rendering model was clearly mapped. Marking component tree nodes as server/client from the start prevents these surprises.
  • Use Zod for all HTML form values. The preprocessor pattern for empty strings should be the default for any enum field connected to a <select>. Worth making it a project convention from day one.

Stack

Next.js 16 (App Router)Supabase (DB + Auth + Storage + RLS)TypeScriptTailwind CSS v4RechartsReact Hook Form + ZodVercel