Loading
Case study
AI SaaS · own product · PL market

From a job offer link to a tailored CV in 60 seconds.

Category
AI SaaS
Client
Own product (zlozcv.pl)
Years
2026
Role
Solo Founder · Full-Stack · AI
ZłóżCV landing page — Twoje CV. Zawsze idealnie dopasowane do oferty.
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.

60s
Parse → match → render — full flow latency
94%
Average AI match score across 418 generations
418
CVs generated to date (build-in-public counter)
29PLN/mo
Pro plan flat price — PL market, no per-CV fees
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.

Ta sama baza, 3 różne CV — personas section on zlozcv.pl showing Senior FullStack, AI Engineer and WordPress Senior variants with match scores
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.

Marketing landing
Next.js 16 · (marketing) route group
Dashboard (auth)
React 19 · Server Actions · dnd-kit
Better Auth (email/pwd + bearer)
cookie session for app · bearer token for Chrome extension
Drizzle ORM — SQLite
single-file DB · per-user composite unique indexes · better-sqlite3
Stripe
subscriptions · idempotent webhooks
Google Gemini 3 Pro
parse_offer · match_persona · render_bio
Puppeteer + system Chromium
Docker (shm_size 2GB) · Caddy on VPS · Resend for transactional email
05 — Technical challenges

Five problems that turned into design decisions.

01

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.

02

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.

03

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).

04

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.

05

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.

ZłóżCV workflow on zlozcv.pl — three input steps (links, offer, AI matching) flowing into a single CV.pdf output
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.

ZłóżCV stats and features grid on zlozcv.pl — 60s generation, 5 matched projects, 2 free trials, 94% match score
+ 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.

Layer
Tech
Why
Framework
Next.js 16 (App Router) + React 19
Server Actions cut the API-route ceremony. Route groups keep marketing, dashboard and /cv/print on one domain.
Language
TypeScript strict + Zod 4
Drizzle $inferSelect + Zod at the AI boundary = one schema, validated end-to-end with no duplicate types.
Database
SQLite via better-sqlite3 + Drizzle ORM
One file in a Docker volume. Backups are cp. Zero infra. The product writes ~kB per CV, SQLite is plenty.
Auth
Better Auth (email/password + bearer plugin)
Same auth table powers the web app (cookie sessions) and the Chrome extension (bearer tokens). One source of identity.
AI
Google Gemini 3 Pro via @google/genai
Fast, cheap, willing to return strict JSON. Anthropic SDK pinned in deps as a one-line failover if pricing flips.
PDF rendering
Puppeteer + system Chromium in Docker
Renders a real React Server Component at /cv/print/{id}. WYSIWYG — what you see in the dashboard is the PDF.
Payments
Stripe (29 PLN/mo Pro plan + webhooks)
Idempotency table in SQLite, regulamin and 14-day waiver wired into the checkout. EU-compliant out of the box.
Email
Resend (hello@zlozcv.pl)
Password resets and Stripe receipts. Three lines of code; one less SMTP server to babysit.
Deploy
Docker multi-stage + pnpm + Caddy on VPS
One 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.

Co AI robi w te 60 sekund — exec log from zlozcv.pl showing the six-step Gemini 3 Pro pipeline timing out at 56.4s
94%
Average AI match score across 418 generations to date.
<60 s
Full pipeline latency — parse, match, render, PDF export.
29 PLN
Sustainable Pro tier for the PL market — unlimited CVs.
2 surfaces
Public SaaS + personal cv.kawalec.pl — one shared database.

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.