A full-stack job search tracker I designed and built from scratch — covering product design, data architecture, auth, file storage, analytics, and deployment.
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.
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.
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.
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.
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.
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.
<select>. Worth making it a project convention from day one.