Most of what we build is used in more than one language. Test-Start and Instruktori AI work in Albanian and Macedonian. The Caffè de Paris site serves guests in Albanian, Macedonian and Turkish. Our own site is written in English.
In each case, the hard part was not translating words. It was deciding where text lives, what happens when a translation is missing, and how the product behaves when the same content has to make sense in two alphabets.
That is why we treat multilingual support as a data problem, settled in the first schema, rather than a translation task at the end.
Two kinds of text
The first decision is to separate two kinds of text that are often mixed together.
Interface text is part of the product: button labels, error messages, navigation, emails. It changes when the software changes, so it lives with the code, in translation files keyed by message.
Content is part of the business: menu items, service descriptions, exam questions, articles. It changes when the business changes, so it lives in the database, with every translatable field stored per language.
Mixing them causes trouble in both directions. Content kept in code means a developer must deploy to correct a menu description. Interface text kept in the database means one missing row can break a button.
A schema built for languages
For content, we model translations explicitly from the start. A simplified sketch, using driving-exam questions as the example:
CREATE TABLE question (
id UUID PRIMARY KEY,
category TEXT NOT NULL, -- 'B' or 'C1'
format TEXT NOT NULL, -- 'choice', 'sign' or 'matching'
image_path TEXT
);
CREATE TABLE question_text (
question_id UUID REFERENCES question (id),
locale TEXT NOT NULL, -- 'sq' or 'mk'
body TEXT NOT NULL,
explanation TEXT,
reviewed_at TIMESTAMP,
PRIMARY KEY (question_id, locale)
);The structure says something important: a question is one thing with several texts. Its category, format and image are shared; only the words differ.
It also makes gaps visible. One query lists every question without a reviewed Macedonian text, which is far better than a candidate discovering the gap in the middle of a test.
A question is one thing with several texts.
Fallbacks are a product decision
Sooner or later a translation will be missing. The product needs a rule for what happens next, and the right rule depends on the content.
- Interface text falls back to a default language, and the missing key is logged so it gets fixed.
- Marketing content, such as a service description, can fall back to the main language.
- Exam content should not fall back at all. A candidate practising in Macedonian should never meet an Albanian question halfway through a test. The question is left out until its translation exists.
Writing these rules down early prevents the worst outcome: a page that silently mixes languages.
Translations need owners
A schema can make room for languages, but someone still has to write and check the words. Decide who that is for each language before any content is written, and make the product support their work.
In practice that means a status for every translated text: missing, draft or reviewed. The reviewed_at column in the sketch above exists for this reason. Reviewers see a list of what is waiting for them, and nothing marked as draft appears in places where a mistake would matter, such as an exam.
Machine translation can be a useful first draft. It is never the last step for content people rely on. A native speaker reviews it, and the review is recorded.
Typography and alphabets
Albanian, Turkish and English use the Latin alphabet; Macedonian uses Cyrillic. That has consequences well beyond translation.
Fonts must cover every alphabet. A typeface chosen for its Latin letters may have no Cyrillic, or a weak one. If the browser falls back to a system font for Macedonian, the same page looks like two different brands. We check coverage for every language before a font is approved, including ë and ç in Albanian and ğ, ş and ı in Turkish.
Text length varies. The same label can be noticeably longer in one language than in another. Buttons, navigation and headings are designed with room to grow, and tested in the longest language, not the one the designer happens to read.
Letter case depends on the language. Turkish has a dotted and a dotless i, so uppercasing must use the right locale:
"istanbul".toUpperCase(Locale.ENGLISH); // ISTANBUL
"istanbul".toUpperCase(Locale.forLanguageTag("tr")); // İSTANBULAn uppercase heading built with the wrong locale is a small error that every native reader notices.
Dates, numbers and prices
Dates, numbers and currencies should be formatted by the platform's locale tools, never assembled by hand. The order of day and month, the decimal separator and the way a price is written all vary by language and country.
Using the standard tools (Intl in the browser, java.time and NumberFormat on the server) removes a whole category of bugs, and makes each new language cheaper to add.
Search across languages
Search is where multilingual products fail quietly. A few rules we follow:
- Search the user's language first, then the others if that makes sense for the content.
- Normalise letters, so someone typing "e" still finds "ë", and capitalisation never matters.
- Never transliterate silently. Matching Cyrillic input to Latin text, or the reverse, can help, but it should be a deliberate choice tested with real queries.
Addresses and search engines
Each language gets its own address, such as /sq/ and /mk/, rather than one page that changes language based on a cookie. Visitors can share a link in the language they read, and search engines can index every version.
Pages tell search engines which versions belong together, so a Macedonian searcher is shown the Macedonian page. And we never switch language automatically based on a visitor's location: someone in Skopje may read Albanian, and someone in Tirana may prefer English. The visitor chooses, and the choice is remembered.
What goes wrong when languages come later
We are sometimes asked to add a language to an existing product. The same problems appear every time:
- Sentences assembled from fragments, such as "You have" + count + "bookings", which cannot be translated correctly because word order and plural rules differ between languages.
- One text column per field, with no room for a second language, so translation becomes a migration across every table.
- Hard-coded formats: dates built as strings, prices with a fixed symbol and separator.
- Layouts sized for one language, which break as soon as a longer word arrives.
- No owner for translations, so new content ships in one language and the others drift behind.
Each problem is fixable on its own. Together they explain why retrofitting a language costs much more than designing for it.
Decide it before the first schema
Every point above is cheap to get right at the start and expensive to fix later. The questions to settle first are short: which languages, who writes and reviews them, and which content may fall back and which may not.
What this means for your project
If your product will ever speak more than one language, we design for it in the first schema: content stored per language, explicit fallback rules, fonts that cover every alphabet, and locale-aware formatting and search. Adding a language later becomes content work, not a rebuild.


