Selected work

06 Case study Live

Plutarch

Ρίζα is an open dictionary of Modern Greek where every definition carries a citation you can click through to. Type any form of a word — accented or not, monotonic or polytonic — and land on its entry, with declension and conjugation tables, cross-references and real usage examples.

Role
Solo
Timeline
Aug 2026 · 4-day build
Stack
Next.js 16 · TypeScript · Postgres (Neon) · Vercel
An open antique lexicon on a dark desk with a brass loupe resting on the page and a cobalt ribbon bookmark.

01 Problem

Online Greek dictionaries are either thin word lists or closed products. Greek also makes lookup hard: the same word arrives as dozens of inflected forms, with or without accents, and in monotonic or polytonic spelling. A search box that only matches the headword exactly fails most real queries.

I wanted the opposite: an open dictionary where any form of a word finds its entry, and every definition cites where it came from.

02 What I built

Ρίζα ("root") imports the Greek Wiktionary extraction from kaikki.org into Postgres and serves it through a Next.js app:

  • Search that ignores accents and case, accepts polytonic input, and resolves inflected forms back to their lemma.
  • Server-rendered entry pages with senses, usage examples, cross-references, and declension and conjugation tables.
  • Greek permalinks (/λέξη/…), a sitemap, word of the day, and an anonymous report-an-error flow.
95,774Entries
130,584Senses
38,920Usage examples
98Tests

03 Architecture

Two halves. At build time, a streaming pipeline turns 1.76 million raw records into five CSVs and bulk-loads them. At request time, two SQL functions do all of the search and ranking, so the app stays thin.

Data flow: kaikki Greek Wiktionary dump, streamed through a reader and a pure mapper into five CSVs, bulk-loaded into Postgres on Neon; at request time SQL search functions serve a Next.js app on Vercel that renders entry pages for the browser. BUILD TIME kaikki dump el Wiktionary 1.76M records read.ts streaming gunzip flat memory map.ts pure: entry or reject reason 5 × CSV entries, senses, forms, relations… COPY → stage dedup + renumber in SQL REQUEST TIME Postgres · Neon Frankfurt · 15 migrations btree prefix range + pg_trgm GIN SQL functions search_entries() get_entries() 6-tier ranking Next.js · Vercel /api/search /lexi/[word] · ISR 1h region fra1 Browser search box entry, declension, conjugation tables normalise_el() in SQL ≡ normalise() in TypeScript, checked by a parity script
Fig. 1 · Build-time pipeline and request path

04 A week with Ρίζα

One reader's week with the dictionary. The phone shows the app's own Greek interface, and the right side shows the data behind each page: the search key, a lemma with its inflected forms, the cited senses, the cross-references and the reports queue. Every beat is a feature that ships; the glosses are short paraphrases, not quoted entries.

  1. MonAny form

    A reader types εκανες, no accents. The search key strips them, finds the inflected form, and lands on its verb: κάνω.

  2. TueCited senses

    Every sense carries a link to where it came from: Greek Wiktionary, CC BY-SA.

  3. WedThe table

    The conjugation table shows the form they typed, in context.

  4. ThuWord of the day

    Ρίζα, “root”, the dictionary’s own name. Cross-references lead to the words around it.

  5. FriReport a mistake

    A reader flags an example. It’s sent anonymously (only a salted hash is kept, for rate limiting) to the reports queue.

05 Try it

Every query and every stored word goes through the same few steps before they're compared. Type any form of a Greek word, accented or not, monotonic or polytonic, and watch it reduce to the key the database actually searches. Combining accent marks are highlighted as they're stripped.

Interactive · search-key pipeline

Type a Greek word to see it reduced, step by step, to the key the database searches. (This demo needs JavaScript.)

06 Key decisions

Decision 1

Two normalisers, and a script that proves they agree

Context
The search key is generated in SQL when rows are written, but queries are normalised in TypeScript. If the two drift apart, search silently returns nothing.
Decision
Keep normalise_el() and normalise() as deliberate twins: decompose, strip combining marks, recompose, lowercase, fold final sigma. A parity checker runs both over the same inputs and also runs the app's real ranking query.
Trade-off
Two implementations to maintain, in exchange for a failure mode that is caught instead of invisible.

Decision 2

Cap forms per entry instead of dropping words

Context
The corpus didn't fit the free database tier. The obvious filter would have deleted about 74,000 headwords.
Decision
Keep every entry and cap the inflected forms stored per entry, preferring tagged forms. That cut the corpus from 371 MB to 255 MB with all 95,774 entries intact.
Trade-off
The long tail of rare verb forms isn't searchable. Losing words would have been worse.

Decision 3

Rank by prominence, and say so

Context
Good ranking wants word frequency, but frequency lists mean an extra download and licensing questions.
Decision
Rank in six tiers (exact lemma first, substring last) and break ties by prominence: how many other entries point at a word.
Trade-off
Prominence is a proxy, not usage frequency, and it's documented as such. Real frequency data is the next ranking upgrade.

Decision 4

Put the database next to the functions

Context
The same query took 24 ms locally and 103 ms deployed, and TCP connection setup dominated cold requests.
Decision
Use Neon's HTTP driver in production, with node-postgres over TCP locally, and pin the Vercel functions to Frankfurt, the same region as the database.
Trade-off
Two code paths for one database client, switched by host, rather than one slower one.

07 Hard problems

One letter, three encodings

The accented letter ά reaches the database as three different codepoint sequences, and decomposition collapses them. Then a second bug turned up: Postgres's libc collation on macOS lowercases Ύ to ώ instead of ύ. Both normalisers now strip accents before lowercasing, so the order of operations can't reintroduce it.

A sequential scan on every keystroke

A LIKE pattern built inside a CTE stopped Postgres from using the index, so every keystroke scanned 880,000 inflected forms. Rewriting the prefix match as a range query on a btree index took it from 61 ms to 0.028 ms.

Ranking undone by the app

The SQL function ranked correctly and its tests passed, but the app wrapped it in an outer ORDER BY that threw the ranking away in production. The fix was WITH ORDINALITY. The lesson was to test the query the app actually sends, so the parity checker now does exactly that.

08 Outcome & what I'd do differently

Ρίζα is live and stable: 95,774 entries, declension tables for 63,971 of them, conjugation tables for 5,455 verbs, and a 98-test suite, built in four days. The source is private for now. The dictionary content is CC BY-SA, inherited from Wiktionary.

  • Scope. The original plan (a diachronic dictionary with five "lenses" and Cypriot dialect research) was far bigger than what a first version needed. Shipping the core first was the right call, and I'd plan that way from day one.
  • Test the integration, not just the parts. Unit tests on the SQL function missed the bug in the query that wraps it.
  • Audience before features. The next version starts with who it's for, plus an admin view to triage error reports.