Back to blog
engineering kvalty backend

The 1.4 Million Line Delete

I just finished a complete backend rewrite of Kvalty.cz. Replaced Directus with a custom solution. Here's what that actually looked like.

The numbers speak for themselves:

  • -1,392,860 lines deleted
  • +334,224 new lines written
  • 181 commits
  • 3 weeks of work

I just finished a complete backend rewrite for Kvalty.cz. Threw out Directus and replaced it with a custom solution.

Why Directus Had to Go

I won’t lie — Directus served its purpose in the early days. When I was an Android developer fumbling through my first web backend, having a headless CMS that gave me a GraphQL API out of the box felt like a gift. But gifts have hidden costs.

GraphQL complexity was eating me alive. Every query had to be hand-crafted with nested fragments. Pagination required relay-style cursors that broke in subtle ways. And any time I needed to join data across 3+ tables — which, with 163 tables, was constantly — I’d end up with monstrous queries that were nearly impossible to debug. A single driving school detail page required 7 nested GraphQL fragments just to load all the related courses, prices, reviews, and metadata.

The extension system was fragile. I had 21 custom Directus extensions — hooks, endpoints, custom interfaces. Every time Directus pushed a minor version update, at least 3 or 4 of them would break. The extension API wasn’t stable. I spent more time maintaining compatibility shims than building actual features. One memorable weekend, a Directus update changed how hook context objects were passed, and 9 of my 21 extensions silently stopped working in production. I didn’t notice for two days.

Performance degraded as the dataset grew. With 1,400+ driving schools, 15,000+ courses, and all the associated pricing, review, and geographic data, Directus’s generic query layer started showing its age. Complex filtered searches — the core feature of Kvalty — were taking 800ms+ on a good day. The GraphQL layer added overhead that a direct database query wouldn’t need.

No end-to-end type safety. This was the killer. I’m building a TypeScript monorepo. I want to change a database column and have the compiler scream at me everywhere that column is referenced — from the API handler to the React component. Directus gave me auto-generated GraphQL types, but they were loose, often wrong after schema changes, and there was always a gap between what the database actually contained and what TypeScript thought it contained. I was runtime-debugging type mismatches in production. That’s not engineering, that’s gambling.

The New Stack

The replacement architecture is clean and purpose-built:

Hono as the API server. Lightweight, edge-ready, runs on Cloud Run. The entire router with all middleware weighs less than what a single Directus extension used to.

tRPC for type-safe procedures. This is the real win. I define a procedure on the server, and the client knows exactly what it accepts and returns. No codegen, no GraphQL schema files, no hoping the types match. Change a return type, and every consumer breaks at compile time. Exactly what I wanted.

Drizzle ORM for schema management and queries. My 163 tables are now defined in TypeScript. Migrations are generated automatically from schema diffs. The query builder gives me full control — joins, subqueries, aggregations — without the abstraction tax Directus imposed. Those 800ms searches? Down to 90-120ms.

Better Auth replacing Directus’s built-in auth system. OAuth flows, session management, role-based access — all handled by a dedicated library instead of being entangled with the CMS. Separating auth from content management was a sanity upgrade I didn’t know I needed.

The Migration Strategy

You can’t just switch off a production system serving thousands of users and hope the new one works. Here’s how we did it.

Week 1: Schema mapping and parallel infrastructure. I mapped every Directus collection and field to its Drizzle ORM equivalent. All 163 tables, their relationships, their constraints. Claude and I went through them systematically — 20-30 tables per session. Meanwhile, I set up the new Hono/tRPC server alongside the existing Directus instance. Both running, both hitting the same PostgreSQL database. The new stack could read data but wasn’t serving any traffic yet.

Week 2: Endpoint migration and data scripts. This was the brutal part. Every API endpoint that the three Next.js apps consumed — and there were over 80 distinct data-fetching patterns — had to be reimplemented as tRPC procedures. I wrote migration scripts for the data that Directus stored in its own internal format (user sessions, file metadata, revision history). Some of this data was straightforward to move. Some of it was stored in Directus-specific JSON blobs that needed parsing, transformation, and re-insertion. I ran parallel data validation scripts that compared responses from the old and new APIs for every endpoint. If the responses didn’t match byte-for-byte (after normalization), the migration script flagged it.

Week 3: Cutover, auth migration, and cleanup. Flipped the DNS. All three Next.js apps pointed at the new tRPC endpoints. Better Auth took over session management. The 21 Directus extensions were deleted. The Directus dependency — and its 1.39 million lines of node_modules — vanished from the lockfile. This was the week where most of the 181 commits landed, because every edge case in production surfaced at once.

When AI Gets It Wrong

It wasn’t “first try, perfect result.” A few times, AI and I seriously misunderstood each other, and some of the more complex parts needed iteration to actually work in production. No magic autopilot happened.

The PostGIS geography incident. I asked Claude to migrate the geographic search queries from Directus’s custom filter syntax to raw Drizzle queries with PostGIS. Claude assumed I was using geometry types. I was using geography types. The difference matters — geometry does planar math, geography does spherical. Every distance calculation was off by 15-30% depending on latitude. Schools near the edges of the Czech Republic were being excluded from radius searches that should have included them. It took me two days to notice because the results looked “close enough” in Prague, where most of my test data lived. Lesson: always test edge cases at the geographic edges, not just the center.

The auth session format assumption. Claude generated the Better Auth integration assuming sessions would be stored as JWTs in cookies. My existing Directus setup used opaque session tokens with server-side storage. During the migration, Claude built a JWT-based flow that worked perfectly in dev — but in production, existing logged-in users had opaque tokens that the new system didn’t recognize. Every active user got silently logged out. Not a catastrophe, but not great for the 200+ users who had active sessions that Saturday morning.

The batch insert transaction lock. During data migration, Claude wrote a script that inserted course records in a single massive transaction — 15,000 rows. On my local Postgres, fine. On the production Cloud SQL instance with concurrent reads, it locked the courses table for 12 seconds. The three Next.js apps all threw 504 timeouts. I had to kill the transaction, rewrite it to batch in chunks of 500 with SAVEPOINT markers, and run it during a low-traffic window at 3 AM.

The Human-AI Dynamic

It worked like extremely intensive pair programming. I played the architect, set the guardrails, and Claude did the raw heavy lifting. I had to do honest code reviews, push back, and hold the direction when it started coming up with nonsense.

A typical session looked like this: I’d outline 5-8 tRPC procedures that needed to exist, describe the expected inputs and outputs, specify which Drizzle queries to use, and let Claude generate them. Then I’d review every procedure — checking query correctness, error handling, type narrowing, and edge cases. About 60% of the generated code shipped as-is. 30% needed minor corrections (wrong column name, missing null check, inefficient join). 10% needed significant rework or a completely different approach.

The 60% that shipped as-is is why this took 3 weeks instead of 6 months. The 10% that needed rework is why you can’t just let AI run unsupervised.

Post-Migration: What Broke in Production

Even with parallel validation, production always finds what testing doesn’t.

Sorting inconsistency. Directus had a default sort order for collections that I’d never explicitly set — it was sorting by internal row ID. The new Drizzle queries sorted by created_at. For most data, the order was the same. For 47 driving schools that were bulk-imported on the same day with identical timestamps, the order was random. Three users reported that “the results keep changing.” Fix: explicit deterministic sort with a secondary sort key.

File URL format change. Directus served files through its own asset pipeline (/assets/{uuid}). The new system served them from Cloud Storage directly. I updated the URLs in the database, but missed the 340 course description fields that had Directus asset URLs hardcoded in rich text HTML. Fix: a migration script with regex replacement, plus a redirect rule on the old asset paths as a safety net.

Rate limiting gap. Directus had built-in rate limiting that I forgot about. The new Hono server launched without any. For 6 hours on a Tuesday, the API was completely unprotected. Nothing bad happened — but it could have. Fix: added hono-rate-limiter middleware within the hour of noticing.

The Result

Worth the nerves. The backend is now much higher quality, the code is clean, and most importantly — it’s a joy to work with now.

Some concrete before/after numbers:

MetricDirectus (Before)Custom Stack (After)
Cold start time~4.2s~1.1s
Filtered school search800-1200ms90-120ms
Full deploy cycle~8 min~3 min
Type-safe end-to-endNoYes
Dependencies (lockfile)1,847 packages412 packages
Custom extensions to maintain210

Developer experience is the metric that doesn’t fit in a table. Before: dread. Every feature meant fighting Directus’s opinions about how data should flow. After: I think about the feature, describe it, and build it. The framework gets out of the way.

Claude Code just had its birthday — one year — and I’ve come an enormous distance with it during that time. I created all of Kvalty with it — a project that’s already huge and growing, but we’re really still at the beginning.

This rewrite was the biggest challenge so far, one I definitely couldn’t have pulled off without that year of experience and mutual calibration.

What made it work

AI is an incredibly powerful tool. But you have to know how to work with it.

There’s no shortcut around understanding your own system. The rewrite worked because I knew what the old backend did wrong, what the new one needed to do right, and I could verify every decision Claude made. Without that context, the same rewrite would have been a disaster.

181 commits. 3 weeks. 1.4 million lines gone. And the system is better for it.

Could I have done this without AI? Technically, yes — in maybe 6 months, with burnout, and probably a few production outages along the way. Could AI have done this without me? Absolutely not. It didn’t know why Directus had to go. It didn’t know which of the 163 tables mattered and which were Directus internal junk. It didn’t know that the geography/geometry distinction would bite us in the Czech Republic’s border regions.

The rewrite worked because both sides brought what the other couldn’t. That’s the actual lesson.

Martin Svoboda

Martin Svoboda

Android developer at Fortuna, founder of Kvalty.cz and Ferda App. Building products with Kotlin, React, and AI-assisted engineering from Prague.