Skip to content
Blog
MethodOctober 8, 2026

Four languages, 884 keys, one solo dev

Grimoire Culinaire declares four languages, but only two are complete and the locale is hard-locked to French in the code. That is not neglect: it is a deliberate shipping pattern, feature first, translation when it pays off.

The illusion of free multilingual support

When I started Grimoire Culinaire, my Flutter recipe app, I created four translation files on day one: app_fr.arb, app_en.arb, app_es.arb, app_it.arb. Four declared languages, four potential markets, for the price of four files. Flutter makes it so easy it feels like a gift.

Six months later, I recounted the keys, script in hand, on the very day I am writing this: FR 884 out of 884, EN 884 out of 884, ES 101 out of 884, IT 101 out of 884. That is 783 empty strings per Latin language. The Spanish file starts like this:

brigit@7sailjson
"appTitle": "",
"shoppingListTitle": "",
"addItem": "",
"editItem": "",

The template that enforces discipline

The app title itself is not translated into Spanish. That is what a declared language is worth without a plan to keep it alive. The one early choice I defend without reservation is making French the source language:

  • Every new feature creates its keys in French; the template is the authority.
  • English gets translated right away, while the context is still fresh.
  • ES and IT stay nearly empty: the debt is not hidden in a backlog, it lives in the repo, measurable with one command.
brigit@7sailyaml
arb-dir: lib/shared/l10n
template-arb-file: app_fr.arb

The blunt guardrail

A half-translated app is worse than an untranslated one. A Spanish user who sees one screen in Spanish and French labels everywhere else does not conclude the translation is in progress: they conclude the app is broken. So I locked it down, in app.dart, line 450:

Four languages declared, one actually served. And the backend tells a third story: in the Supabase seed, app.supported_locales is ["fr", "en"]. Three levels of truth, then: four languages in the client, two promised by the server, one displayed. It looks ugly on paper. In practice it is the only honest state until coverage is complete.

brigit@7saildart
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('fr'),
Better a 100% French app than a half-translated Spanish one.

Feature first, translation when it pays off

The telling detail is which keys are filled in Spanish. Not a foundation: appTitle is empty, addItem and editItem are empty. The 101 filled keys cover the recent flows: voice capture ("Toca para dictar tu receta"), photo OCR, the shopping list, referrals, promo codes.

The explanation is mundane. My recent features are written with a discipline the older ones lacked: all four languages at once, while the context is fresh. Translating a key while writing the feature costs thirty seconds. Coming back to translate 783 keys out of context is a project you have to plan, review and test. Translation follows the same law as tests or docs: marginal when done along the way, monumental as a catch-up job.

Will Spanish arrive naturally, feature after feature? No. At this pace, the 783 historical keys will never fill themselves. The catch-up will be a dedicated batch, triggered the day a business reason justifies it. I am not promising a date, and I own that.

What I take away

  • Declaring a language costs one line. Keeping it costs 884 keys. The real i18n deliverable is not the translation file, it is the decision of when to turn it on.
  • A hard-locked locale beats a half-translated experience that looks broken.
  • Visible, countable debt (783 empty strings in the repo) beats debt buried in a ticket.
  • Translating along the way costs thirty seconds per key. As a catch-up, it is a project in its own right.