June 1, 2026

TanStack StartReactFastAPIPythonPostgreSQLStripeClaude

Locdoc: tenant screening for Swiss real estate agencies

Renting an apartment in Switzerland is a paper exercise. Agencies receive stacks of dossiers in inconsistent formats, manually read pay slips, chase missing documents, and make decisions with incomplete data. Locdoc handles the extraction, scoring, and selection workflow so agencies can focus on the decision itself.

Tableau de bord régie Locdoc

The approach

I shipped an MVP fast. Not polished, but real: a working upload flow, Claude extraction, a scored result. Then I talked to agencies.

That step matters. It is easy to overbuild a feature nobody needs or to optimize a score that no agency actually looks at. Talking to the market first meant I knew what mattered before spending time on it.

The first paying client came before the product was complete. That forced useful prioritization: what does a real agency need to start using this today. Since then the product has iterated on real feedback. Billing, notification emails, waitlist management, customizable email templates. Each piece came from a real conversation, not a guess.

How it works

An agency creates a listing and gets a public upload link. Candidates open the link and submit their documents. No account required on their side. The backend processes each file with Claude vision, extracts structured data, scores the dossier, and makes it available in the agency dashboard.

The agency sees a list of scored candidates for each listing. They can sort by score, read the full extracted data, and move candidates through a selection workflow: retenu, en_attente (with a waitlist rank), refuse, or desiste. When they make a decision, they send a notification email from inside the app, with a pre-filled template they can edit before sending.

The desiste status is irreversible, enforced by the backend with a 409 on any further status change attempt.

if candidate.status == "desiste":
    raise HTTPException(status_code=409)

Architecture

Browser / SSRREST + httpOnly cookiesroutesvalidate · call service · returnservicesorchestrate · no DB accessrepositoriesDB reads/writes · ownership checksPostgresspawnsBackgroundTaskextractor.pyClaude vision · pure I/Oscorer.pypure function · no I/O

Three rules enforced across the codebase. Routes validate input, call a service, return: no business logic. All reads and writes go through repositories, where ownership checks live. Processing modules are fully isolated: no database access, no FastAPI imports. They take bytes in, return structured data out.

AI extraction pipeline

file bytes + mime_type
  → extractor.extract_data()   # Claude vision, one call per file
  → scorer.score()             # pure function

Each uploaded file goes through Claude's vision API. The extractor pulls structured fields: name, monthly income, contract type, employer, debt collection history. The result feeds the scorer. The whole pipeline runs as a FastAPI BackgroundTask so the upload response is immediate.

background_tasks.add_task(process_dossier, dossier_id)
return {"id": dossier_id}

Dossier status machine: pending → processing → ready → done, or error if extraction fails.

Scoring rules:

ConditionEffect
Rent-to-income ratio below 3.0Score −40, flagged
Active debt collectionScore −50, flagged
Fixed-term or freelance contractScore −10, flagged
Missing critical fieldsScore capped at 40

The agency sees a numeric score and a list of flags. They can still read the full extracted dossier, but the score gives them a starting point when they have 30 applications.

Frontend

TanStack Start with SSR, file-based TanStack Router, TanStack Query, Tailwind v4, react-hook-form, Zod.

Domain API functions live in src/api/<domain>.ts: no React, no hooks. React Query hooks wrap them in src/hooks/use-<domain>.ts. Query keys are centralized and never inlined. The session query has staleTime: Infinity and is populated by SSR on page load. After login, the code removes the session from the cache and navigates: the subsequent beforeLoad fetches the fresh session, so the login response never sets the cache directly.

queryClient.removeQueries({ queryKey: sessionKeys.current() })
navigate({ to: "/dashboard" })

The backend exposes an OpenAPI schema. generate-api-types regenerates openapi.gen.ts from the running backend. CI fails if the committed file differs. Frontend types and backend schemas cannot drift silently.

Auth

Email/password: register, verify email (single-use token, 24h TTL, SHA-256 hashed in DB), login. Google OAuth is also supported: the backend verifies the Google ID token, creates or links the account, and sets the cookie.

JWT HS256, 60-minute TTL, httpOnly cookie. In production, frontend and backend are on different subdomains of locdoc.ch. COOKIE_DOMAIN=locdoc.ch must be set or every page refresh and every Stripe redirect logs the user out. SameSite=Lax allows top-level cross-site navigations like Stripe redirects without weakening cross-origin protection.

Stripe billing

Two plans: free (one listing) and pro (unlimited). The require_pro FastAPI dependency gates any route that needs the paid plan.

async def create_listing(agency = Depends(require_pro)):

The webhook handles the full subscription lifecycle: successful payment, updates, cancellations, failed invoices. past_due subscriptions count as active alongside active and trialing. A temporarily failed payment does not immediately lock an agency out.

ACTIVE_STATUSES = {"active", "trialing", "past_due"}

CI/CD

PR pipelinegit pushpath detectionfrontend/** · backend/**backendruff · pytest / SQLitefrontendtsc · lint · vitest · buildcontractsgenerate-api-types · git diffmerge gateRelease pipeline · v*.*.* tagfull CIbackend imagefrontend imageenv baked inpush GHCRmanual approvalGitHub: production envwrite .envfrom GitHub secretspull + pg_isreadywait for DBalembic upgraderestart services--no-deps · prune imagessmoke test/healthGH releaseauto notes

PR pipeline: path-based change detection. If only frontend/** changed, the backend job is skipped and vice versa. Backend job: ruff format and lint, pytest against SQLite in-memory. Frontend job: TypeScript check, lint, Vitest, full build. A dedicated contracts job regenerates openapi.gen.ts and fails with git diff --exit-code if the committed file differs.

Release pipeline: triggered by v*.*.* tags.

  1. Full CI run.
  2. Backend and frontend Docker images built in parallel and pushed to GHCR. Frontend env vars are baked into the image at this step.
  3. Manual approval gate via a GitHub production environment. Nothing touches the VPS until approved.
  4. SSH deploy: writes .env from GitHub secrets, pulls the new images, starts db first, waits for pg_isready, runs alembic upgrade head, then restarts backend and frontend with --no-deps. Old images pruned.
  5. Smoke test against https://api.locdoc.ch/health.
  6. GitHub release with auto-generated notes.

No staging environment. The manual approval gate is the only checkpoint between a passing CI run and production.

Stack

FastAPI, Python 3.11+, SQLAlchemy, Alembic, PostgreSQL, Claude/Anthropic, Stripe, Resend. TanStack Start, TanStack Router, TanStack Query, React, TypeScript, Tailwind v4, shadcn/ui, react-hook-form, Zod. Docker Compose on a single VPS, GitHub Actions for CI and deploy.