“I’ll just build a simple job tracker,” I thought naively six months ago. What started as a weekend project to help friends manage their job searches became an obsession with solving every authentication edge case, Chrome extension challenge, and state management pattern I could encounter.
This is the story of how I accidentally built an enterprise-grade SaaS platform while trying to make job hunting less painful.
Built with: Next.js 15 • TypeScript • PostgreSQL • Better-Auth • Zustand • Chrome Extension • Bun • Playwright
The “Simple” Job Tracker That Wasn’t
When OAuth Providers Fight: The Authentication Nightmare
The first real challenge hit when my friend Sarah tried to sign up with Google, but she’d already created an account with the same email using GitHub. The app crashed spectacularly.
”Can’t you just… merge them?” she asked innocently.
Oh, sweet Sarah. If only you knew what you’d just unleashed.
Three weeks later, I had built a complete OAuth conflict resolution system using Better-Auth. Not because I wanted to—because I had to. When a user tries to sign up with Google but their email already exists via GitHub, the app now gracefully presents a modal explaining the conflict and guides them through linking the accounts.
The elegant solution involved detecting email conflicts in real-time, presenting contextual options based on existing providers, and handling all the edge cases (expired sessions, cancelled OAuth flows, missing email permissions). What started as a simple “just log in” became a sophisticated provider management system that handles account merging like a pro.
The Chrome Extension That Grew Too Smart
”Wouldn’t it be cool if I could just click a button and save jobs from LinkedIn?”
Another innocent friend request. Another rabbit hole.
The Chrome extension started simple: grab the job title and company from LinkedIn’s DOM. But LinkedIn’s structure changes constantly, Welcome to the Jungle uses completely different selectors, and don’t get me started on Indeed’s anti-scraping measures.
So I built something different: a universal content extraction engine. Instead of parsing specific elements, the extension captures the entire page HTML and sends it to the backend for AI-powered extraction. Now it works on LinkedIn, WTTJ, and can easily expand to any job board.
The breakthrough moment came when I realized I was fighting the wrong battle. Instead of trying to keep up with every site’s DOM changes, I let the AI figure out what’s a job title, what’s a company name, and what’s just noise. The extension became job-board agnostic overnight.
The Theme System That Became Art
I wanted the app to look professional. Corporate, but not boring. Modern, but not trendy.
After cycling through dozens of color palettes, I stumbled upon something special: a “warm neutral” system inspired by high-end interior design. Five carefully crafted variations (Base, Sage, Amber, Slate, Rose) that feel sophisticated without being sterile.
The magic wasn’t just in the colors—it was in the system itself. CSS custom properties for every semantic token, automatic dark/light mode adaptation, and smooth transitions that make theme switching feel premium. Users can switch themes mid-session and everything updates instantly, from buttons to shadows to accent colors.
It’s the kind of attention to detail that most users will never notice consciously, but they’ll definitely feel the difference.
The Technical Decisions That Shaped Everything
Why Drizzle ORM Saved My Sanity
I started with Prisma like everyone else. But as the schema grew more complex—interviews with multiple rounds, documents with metadata, user preferences stored as JSONB—Prisma’s query performance started to hurt.
Switching to Drizzle was like trading a heavy SUV for a sports car. The type safety is incredible (every query is fully typed), migrations are just SQL files (no more mysterious generated code), and performance is consistently fast.
The real win came when I needed to store user activity logs as JSONB. With Drizzle, I could write raw SQL for complex queries while keeping everything type-safe. The schema changes that used to terrify me with Prisma became routine maintenance tasks.
The State Management Revelation
Here’s a confession: I almost used Redux.
I know, I know. But this was a complex app with filtering, job management, interview tracking, and real-time updates. Surely I needed the big guns?
Then I discovered Zustand with persistence. The entire global state management layer is maybe 200 lines of code, it syncs across browser tabs automatically, and optimistic updates are trivial to implement. When someone moves a job from “Applied” to “Interview,” the UI updates instantly—and if the API call fails, it rolls back smoothly.
The breakthrough was realizing that most “complex” state is actually just synchronized client-server state. With proper optimistic updates and error handling, the user experience feels instantaneous even on slow connections.
The API Design That Scales
Every endpoint follows the same pattern: Zod validation on the way in, structured logging throughout, standardized error responses on the way out. It sounds boring, but boring is reliable.
The magic happens in the middleware. One function handles authentication, another validates request schemas, and a third formats responses consistently. Adding a new endpoint becomes a 10-minute task instead of an hour of boilerplate.
The Features That Emerged From Real Needs
The Document Problem Nobody Talks About
”Where did I put that version of my resume with the blockchain experience?”
Every job seeker has this moment. Multiple resume versions, cover letters, portfolios—scattered across Downloads folders and Google Drive accounts. The document management system started as a simple file uploader and evolved into something more sophisticated.
Users can now organize documents with smart categorization, link specific files to job applications, and switch between grid/list views depending on their workflow. The breakthrough was treating documents as first-class citizens in the job search process, not just attachments.
Interview Tracking That Actually Works
Most job tracking apps treat interviews as a simple checkbox: “Interview: Yes/No.” But real interviews are complex beasts—multiple rounds, different types (technical, behavioral, cultural), various interviewers, and detailed feedback.
The interview management system tracks everything: when each round happened, who conducted it, what type it was, how it went, and what the next steps are. Users can see their success rates across different interview types and identify patterns in their performance.
The most satisfying part? When someone gets an offer, they can trace the entire journey from initial application through multiple interview rounds to the final decision. Data tells stories, and this data tells success stories.
The Production Journey: From “Works on My Machine” to Actually Working
Testing Like I Mean It
I used to be that developer who wrote tests after the fact. RoleReady broke me of that habit quickly.
With complex OAuth flows, optimistic updates, and Chrome extension integration, manual testing became impossible. I needed confidence that changes wouldn’t break existing features—especially authentication, because nobody forgives auth bugs.
The testing strategy is layered: Vitest for fast unit tests, comprehensive API endpoint testing with realistic mocks, and Playwright E2E tests that cover complete user journeys. The E2E tests are particularly satisfying—watching a robot sign up, add jobs, and manage interviews gives me confidence that real users will have the same smooth experience.
Why Bun Changed Everything
Switching to Bun wasn’t about performance (though the speed is nice). It was about simplicity.
No more npm vs yarn vs pnpm debates. No more lockfile conflicts. No more waiting 2 minutes for npm install to finish. Bun just works, fast, and the developer experience feels polished in a way that Node.js never quite achieved.
The real benefit came during deployment. Bun’s handling of TypeScript and dependencies eliminated entire categories of build-time errors that used to haunt my deployments.
Deployment That Doesn’t Keep Me Awake
The architecture is intentionally boring: Next.js on Vercel, PostgreSQL database, comprehensive monitoring. No microservices, no Kubernetes, no over-engineering.
But boring doesn’t mean careless. Every environment variable is validated with Zod schemas (no more typos in production), structured logging with Pino captures everything I need for debugging, and error boundaries ensure users see helpful messages instead of stack traces.
The most valuable lesson? Production simplicity is a feature, not a limitation.
What I Learned (The Hard Way)
Authentication Is Never Simple
OAuth seems straightforward until your users start mixing providers. The account conflict resolution system taught me that edge cases aren’t edge cases—they’re Tuesday for your users.
Building graceful provider linking, handling expired sessions during the link process, and managing all the OAuth edge cases (missing email permissions, cancelled flows, network timeouts) was a masterclass in defensive programming.
The lesson? Always assume your users will find the most creative ways to break your authentication flow. Plan for it.
Chrome Extensions Are Harder Than They Look
What started as “just grab some DOM elements” became a lesson in browser security, content script limitations, and cross-origin communication.
The breakthrough was realizing that fighting each job board’s unique structure was the wrong approach. Instead, capture everything and let the backend AI sort it out. This shifted the complexity from the extension (limited by browser restrictions) to the server (where I have full control).
Performance Is a Feature
When filter changes take 300ms instead of 30ms, users notice. When optimistic updates don’t work smoothly, the app feels broken even when it’s functioning correctly.
The state management architecture—with proper optimistic updates, cross-tab sync, and instant UI feedback—makes the app feel fast even when the network is slow. That’s the difference between a tool users tolerate and one they actively enjoy using.
Beyond the Code
This project taught me that building software isn’t just about solving technical problems—it’s about understanding how people actually work.
Job searching is stressful, messy, and highly personal. The best technical architecture means nothing if users can’t quickly find what they need or if the interface adds friction to an already difficult process.
The real success metrics aren’t lines of code or architectural decisions—they’re users who spend less time managing their job search and more time landing opportunities.
What’s Next
The foundation is solid, but the possibilities are endless:
- AI-powered application optimization based on success patterns
- Team collaboration features for shared job search strategies
- Advanced analytics to identify the most effective application approaches
- Integration with ATS systems for seamless application tracking
The architecture can handle it all. The question is which problems to solve next.
Sometimes the best way to learn advanced software engineering is to start with a simple problem and refuse to compromise on the solution. This project pushed me to implement enterprise patterns, solve complex edge cases, and build something that doesn’t just work—it works beautifully.