Skip to main content
Cover image for Building RoleReady: a year of solo product engineering

Building RoleReady: a year of solo product engineering

7 min read
roleready saas solo-dev engineering

The thing I built

RoleReady is a job-search workspace: a kanban board for applications, AI that tailors your resume to a specific posting, interview prep generated per role, company research, a contacts CRM, and a Chrome extension that captures a job in one keystroke. It’s a real product with real billing — Free tier capped at 40 tracked jobs, Pro at $19/month through Polar — not a weekend demo.

I built it alone. That constraint shaped every technical decision more than any framework choice did. When you’re the only person who will ever debug a thing at 2am, you optimize for legibility under future-you’s confusion, not for cleverness. This is the post I wish someone had written before I started: not “here’s my stack,” but “here’s where the stack stopped mattering and discipline took over.”

The stack, stated plainly

Next.js 16 (App Router) on React 19, TypeScript 6, Bun as the package manager and runtime. Postgres 18 with Drizzle ORM. Better Auth for sessions, Polar for billing. Inngest for background work. BAML over OpenRouter for everything AI. Doppler for secrets, deployed to GCP Cloud Run with Cloud SQL behind it, all of it Terraformed. The marketing site is a separate Astro 7 build.

That list is honest but useless on its own — every SaaS has a list like it. The interesting part is the four decisions underneath it that I’d actually defend.

Decision 1: routes never do the work

The single most important architectural rule in the codebase is that API route handlers don’t run AI; they enqueue it. There are 204 route files in app/api/, and the canonical ones all follow the same five-step shape: authenticate, validate input, check ownership, pre-create a task row, send an Inngest event, return a job ID. Then the client polls.

// the shape every long-running route follows
const task = await createAiTask({ userId, kind: 'tailor_resume', status: 'pending' });
await inngest.send({ name: 'ai/tailor.resume', data: { existingTaskId: task.id, jobId } });
return Response.json({ aiJobId: task.id }); // client polls GET /api/ai-jobs/{aiJobId}

The payoff: a 25-second LLM call never sits inside a request that can time out, a serverless function never gets billed for wall-clock spent waiting on a model, retries are free (every Inngest function runs with retries: 3), and the client gets a real progress signal instead of a spinner that might be lying. There are 33 of these background functions. I wrote a whole separate post on why this pattern is worth the indirection — the short version is that it turns “the AI is flaky” from an outage into a retried task.

Decision 2: the AI layer is typed, not vibes

Every AI feature — resume tailoring, cover letters, interview questions, offer comparison, job extraction — is a BAML function with a declared return type. 19 functions across 17 files. BAML compiles prompts into typed TypeScript, so a model that returns a malformed object fails at parse time instead of leaking a undefined into a PDF three screens later.

The part that earned its keep was the client tiering. I defined three clients — Fast, Standard, Premium — each pointed at a different OpenRouter model via env vars, each with a retry policy and a different backup model:

client<llm> RoleReadyStandard {
  provider openai-generic
  retry_policy RoleReadyRetry   // 2 retries, 300ms→10s exponential backoff
  options { base_url "https://openrouter.ai/api/v1", model env.AI_MODEL_STANDARD }
  // falls back to RoleReadyBackup (a different model, no retries) on exhaustion
}

Cheap extraction runs on Fast; reasoning-heavy resume work runs on Premium. When a provider has a bad afternoon, the fallback is a different model entirely, so an outage degrades quality instead of failing the request. I’d make this same call again on day one of the next product.

Decision 3: Drizzle push, no migration files

This one is controversial, so I’ll own it. The schema lives in one file — lib/database/schema.ts, ~2,900 lines, roughly 100 tables — and I sync it to the database with drizzle-kit push, not generated migration files. No migrations/ directory full of timestamped SQL.

For a solo dev pre-scale, this is correct. The schema file is the single source of truth, the diff in a PR is the migration, and I never hand-merge two migration files that both touched the same table. The discipline that makes it safe: I never run push casually against prod — a data-lossy change has to be reviewed for exactly what it drops first. The one place I keep raw SQL is the full-text search trigger for the job bank, because push can’t express a weighted tsvector with a GIN index. That’s the exception that proves the rule.

Decision 4: secrets have no local home

There is not a single .env file in the repo. Every command that needs a secret runs through Doppler: doppler run -c dev -- bun run dev. In production, Cloud Run mounts the secrets as a JSON file and the entrypoint evaluates them into the environment before the server boots. The reason is boring and exactly why I did it: the most common way solo devs leak credentials is a .env that sneaks past .gitignore during a hurried commit. If the file never exists, it can’t leak.

The marketing site is a different animal on purpose

The product is a heavy Next app. The marketing site (roleready.me) is intentionally the opposite: Astro 7, Tailwind v4, zero client framework, ships as static HTML to Vercel. A marketing page that needs React to render a hero is a marketing page that’s slow for the exact audience — first-time visitors on phones — you most need to impress. The two repos share a brand and almost nothing else, and keeping them separate meant the site could stay a 5-minute build while the app got as complex as it needed to.

What solo actually taught me

The hard part of solo product engineering isn’t any one system. It’s that there’s no one to stop you from being clever, and clever is the enemy. The rules above — routes enqueue, AI is typed, schema is one file, secrets have no local home — are all the same rule wearing different clothes: make the safe path the only path, and write it down so future-you can’t argue.

I wrote that last part down literally, in an AGENTS.md that both I and the AI agents I code with have to follow. That’s the next post.