From a job offer link to a tailored CV in 60 seconds.
AI SaaS
Own product (zlozcv.pl)
2026
Solo Founder · Full-Stack · AI
At a glance
An AI CV generator that turns a job offer link into a tailored PDF in 60 seconds — one source-of-truth database, infinite per-offer variants.
01 — The problem
Five rewrites a week just to keep your CV honest.
Every senior dev I know runs the same loop: open last week’s CV_v3_final_FINAL.docx, swap React for Vue, swap “ownership feature-ów end-to-end” for “close collaboration with the design team”, kill the WordPress projects because this one’s for an AI startup, save as CV_v4_final_REAL_FINAL.docx, send. Three days later you find a better offer and you’re editing the same file again, hoping you didn’t already promise this company you knew Vue.
ATS-friendly plus visually readable plus persona-appropriate — three goals that fight each other every time. Generic CV builders solve the formatting and ignore the matching. “Rewrite my CV for this job” prompts hallucinate skills you don’t have. Result: 30 minutes of copy-paste per application before you even click send, and a folder full of slightly-different DOCXs you no longer trust.
ZłóżCV exists because the obvious thing had to ship: one database of your real projects and experience, one paste-the-offer flow, and 60 seconds later a CV that’s tailored, factual, and won’t embarrass you on the technical call.
02 — The vision
Paste the offer. Get the CV. — everything else is plumbing.
Three product principles drove every decision:
- One source of truth. Projects, experience, skills and personas live in one SQLite database. Every CV is a projection of that database tuned to a specific offer — not another DOCX in the “final” folder. Edit a project description once and every future CV inherits it.
- Matching is the product. The headline number on the landing isn’t “PDF downloaded”, it’s
% match. Gemini 3 Pro parses the offer, extracts must-have vs nice-to-have, picks the persona, scores every project against the role and renders a bio in your tone. The PDF is the artifact; the matching is the value. - 60 seconds or it’s broken. Parse the offer, match the persona, score the projects, render the bio, export the PDF — the whole pipeline must finish inside one coffee sip. Any longer and the user is back to copy-pasting in Word.
03 — Who it’s for
One database, four different CVs — same person, four different stories the role wants to hear.
Senior FullStack · 94%
Offer: TypeScript / React / Node. AI leads with multi-tenant SaaS and end-to-end feature ownership; skills section foregrounds TypeScript, GraphQL, system design. POSTGRES stays above the fold.
AI Engineer · 91%
Offer: RAG / LLM pipelines. Bio switches to ML voice; projects highlighted: prompt design, eval pipelines, latency budgets. Function calling and observability move to the top.
WordPress Senior · 89%
Offer: Bricks Builder, Gutenberg, WooCommerce. Bio swaps in agency voice: client work, deadlines, Core Web Vitals, ACF. Anything RAG / LLM-flavoured drops off the page.
Frontend Mid · 87%
Demo persona — the same database emits a junior-Mid React / Tailwind / Next CV. Backend depth is suppressed, UI craft and component-library work get the spotlight. Same source data, totally different story.
04 — The architecture
One Next.js app, one SQLite file, two ways in.
A single Next.js 16 App Router build hosts the marketing landing, the authenticated dashboard and the public /cv/print/{id} route Puppeteer uses to render the PDF. Better Auth handles email/password for the app and bearer tokens for the Chrome extension — one auth table, two consumption patterns. All persistence lives in a single SQLite file mounted into the Docker volume; backups are cp. Gemini 3 Pro does the heavy lifting on the AI side; Stripe, Resend and Puppeteer round out the periphery.
better-sqlite305 — Technical challenges
Five problems that turned into design decisions.
Puppeteer hangs in Docker — /dev/shm exhaustion
Problem. The PDF export endpoint /api/cv/export looked fine in dev. In production, headless Chromium would hang after ~30 seconds and the Vercel function would time out, leaving the user staring at a spinner. The container had plenty of RAM. No error in any log.
Solution. Docker’s default /dev/shm is 64 MB. Chromium uses it to buffer rendered frames; a CV with 5 styled project sections blew through that within seconds and the process froze waiting for shared memory. Two-line fix in docker-compose.yml: shm_size: "2gb" plus the Puppeteer flag --disable-dev-shm-usage. Replaced “wait for network idle” with a deterministic 400 ms CSS-settle delay because the print page has no network requests anyway. Total time to diagnose: a weekend of verbose Puppeteer logs.
Multi-tenant slug collision in schema.ts
Problem. First version of personas.slug and projects.slug shipped with a global unique constraint. Second user signs up, tries to add a project called wordpress-site, gets a database error. The first user’s slug had stolen the namespace globally.
Solution. Drop the bare unique index, replace with a composite per-user constraint:
uniqueIndex("personas_user_id_slug_unique").on(table.userId, table.slug). Same fix applied to projects, experiences and every other user-owned text key. Rule that came out of this: in a multi-tenant SQLite schema, every human-readable identifier needs user_id in its uniqueness scope. Wrote a one-off migration to backfill existing rows, no data loss.
Structured AI output without Gemini’s function-calling SDK
Problem. Gemini’s official function-calling API is the “right” way to get typed output, but it’s ceremonial — tool declarations, response schemas, retry semantics baked into the SDK. For a CV generator that asks the same five questions every time, all that machinery is in the way.
Solution. Two tiny files do the whole job. src/lib/ai/generate-cv.ts builds one mega-prompt (offer + persona context + project list) and asks Gemini for raw JSON with a strict shape: { bio, projectDescriptions, experienceDescriptions, skillsGrouped }. src/lib/ai/json.ts exposes extractJson(), a forgiving parser that strips markdown fences and trailing commentary if the model adds any. Zod validates the shape post-parse. Zero SDK magic; full control over retries, prompt iteration and model swaps (Anthropic SDK is pinned in package.json as a one-line failover).
LinkedIn 1-click import that survives the next redesign
Problem. The Chrome extension reads the user’s LinkedIn profile and pre-populates the onboarding form. LinkedIn’s 2025 redesign rewrapped the /details/{section} endpoint inside a bpr-guid SSR shell and added stricter bot-protection. Half the time the extension would return 999 errors.
Solution. Two paths in onboarding-scan.ts: try the API first, and the moment LinkedIn hostility kicks in, surface a textarea where the user pastes the raw profile and the same parser handles it. The user never sees the difference. The extension keeps the bearer token in chrome.storage.local, so it talks to the same Better Auth backend the web app uses — one auth table, two consumption patterns, no extra glue.
Stripe webhook idempotency on a single-VPS deploy
Problem. Stripe re-delivers webhook events on failure or timeout. Process the same checkout.session.completed twice and you double-grant Pro access, or worse, double-fire a refund. The classic answer is a Redis-backed dedup table, but the deploy is one Docker container on one VPS and there is no Redis.
Solution. A single SQLite table, stripe_webhook_events, with event_id as the primary key. The webhook handler in src/app/api/billing/webhook/route.ts does INSERT OR IGNORE on every incoming event; duplicates silently skip without ever reaching the business logic. It doesn’t scale past one server, but at the current scale that’s a feature, not a limitation — one fewer moving part to monitor.
06 — The workflow
Three steps. Six AI calls. One PDF.
Paste your LinkedIn link (or trigger the Chrome extension scan). Paste a job offer URL or screenshot — Gemini parses must-have skills, tech focus and seniority. The pipeline picks the persona, scores every project against the role, drafts a bio in your tone, renders the PDF through Puppeteer. Average run: 56.4s, exit code 0, no hallucinations — if it isn’t in your database, it isn’t in the CV.
07 — Feature highlights
Four things on the landing, two more hiding behind the dashboard.
The metrics block on zlozcv.pl — 60s generation, 5 projects selected per offer, 2 free CVs without a card, 94% average match score — is the public face. The two features below are the load-bearing infrastructure underneath.
+ Persona-variant project descriptions
Each project ships with variantsPl and variantsEn arrays in the schema — per-persona tweaked descriptions. The generator picks the right variant for the role, falls back to summaryPl/summaryEn if no variant exists. One project entry, infinite role-appropriate angles.
+ Chrome extension with bearer auth
Floating “Wygeneruj CV” button on Pracuj.pl / LinkedIn Jobs / JustJoin / NoFluffJobs. Same Better Auth backend as the web app — bearer token in chrome.storage.local, no separate auth surface to maintain.
08 — Stack
Picked for solo-velocity, single-VPS deploy and zero hidden bills.
Every dependency in package.json earns its place by removing a moving part — not adding one. The whole stack runs on one $5/mo VPS.
/cv/print on one domain.$inferSelect + Zod at the AI boundary = one schema, validated end-to-end with no duplicate types.better-sqlite3 + Drizzle ORMcp. Zero infra. The product writes ~kB per CV, SQLite is plenty.@google/genai/cv/print/{id}. WYSIWYG — what you see in the dashboard is the PDF.hello@zlozcv.pl)docker compose up -d. Caddy handles TLS. shm_size: 2gb learned the hard way.09 — Results
Live, paying, build-in-public.
ZłóżCV is in production on zlozcv.pl with active Stripe subscriptions at 29 PLN/mo, the same database powering my personal CV at cv.kawalec.pl, and a build-in-public counter on the landing showing every generation as it happens. I dogfood it on every job application I send.
Like what you see?
Let’s build the next one.
From a blank page to a working product — AI, automation, full-stack engineering. Get in touch and let’s talk about your idea.