I had a static portfolio site: a few pre-built pages that a free repository host simply serves as they are. No server with its own logic, no database - just a set of files. The home page had a language switcher. Then I needed a blog section, also in two languages.
Sounds like half an evening of work: render the pages, add a button. On the very first real article it turned out that some readers were seeing a blank page.
By the end of this text you'll see how a simple bilingual static blog is put together, and the three decisions that determine whether it works or breaks quietly. Quietly is the key word here. None of these failures takes the site down or writes an error to a log. The page opens, it's served successfully, it looks intact - and it contains no text.
The problem: two audiences, one site
The setup is simple: there are Russian-speaking readers and there are English-speaking ones, and there's one site. I don't want to breed two parallel sites - that's everything twice over: layout, navigation, publishing, proofreading. So one article has to exist in two language versions and live at a single address.
Static hosting imposes a constraint worth saying out loud if you've only worked with server-side applications before. Here there's no one to make a decision at request time. There's no code that looks at the reader and hands them the right variant. The host serves the file you put there - that's it. Any language-selection logic lives inside the page already, in the reader's browser.
Hence three forks in the road, and they're usually where things break: where the second language physically lives, who produces it and when, and who knows what has already been published. I'll take them one at a time.
flowchart TB U["Читатель открывает<br/>один адрес статьи"] --> P["Одна HTML-страница"] P --> B1["Блок ru"] P --> B2["Блок en"] SW["Переключатель языка"] -->|"показать / спрятать"| B1 SW -->|"показать / спрятать"| B2 P -.->|"ничего не догружается"| NET["Сеть не нужна"]
Decision one: both versions in the page source
Both language versions of the article sit in the page's source code as two blocks. The switcher doesn't load anything - it only shows the right block and hides the other one. Yes, this means every reader receives twice as much text as they read.
The alternative is to keep the second version as a separate file and fetch it with a request when the reader clicks the button. You shouldn't do that, and the reason isn't speed.
Text that isn't in the page source will be seen by no one except a person with JavaScript enabled. A crawler that doesn't wait for the fetch will index an empty shell. A messenger that builds its link preview from the page markup will show a title with no description - and that's exactly how the article gets opened in the first days after publication. You'll lose precisely the readers you wrote it for.
So the rule is simple: both texts live in the file the host serves. Switching languages is working with the visibility of what has already arrived, not going out for data.
The trap I hit on the very first article
The switcher has a question it must answer before showing anything: is there even a version in this language? If there isn't, it should honestly show the one that exists, not emptiness.
My check searched for a language block across the whole page. And the site header contains English menu labels - they're always there, regardless of the article's language. The check found them, concluded "an English version exists," and hid the Russian text. For a reader whose browser locale is English - that is, the language set in their system or browser settings - the article opened blank. Not with an error. Just blank.
The fix is to limit the check to the article's own area: search not "somewhere on the page" but inside the container with the text. The lesson is broader than this particular bug - any check of the form "do we have X?" needs to know where exactly it's looking. The whole page is almost always too wide a scope, because the header, footer and navigation have lives of their own.
Styling: the blog as part of the site
Design tokens - the variables for colors, fonts and spacing - I took from the home page. I didn't pick out similar-looking ones by eye; I took the same values. It's a cheap operation that settles the question "why does the blog look like a section glued on the side" before it comes up.
You have to break the generator with real text
A separate lesson that cost me an unpleasant five-minute discovery. The page generator - the script that turns article text into finished HTML - I tested on sample material I'd written myself. It passed.
The first real article arrived with third-level subheadings and numbered lists. The generator understood neither: it would have dumped them onto the site raw, markup characters and all. The test text was too polite - without realizing it, I'd written it to fit what I already knew how to handle.
Test your generator on the most complicated real article you have. Not on the one you wrote for testing.
flowchart LR
DB["База: статья и статус"] --> T["Перевод - отдельный шаг,<br/>не автоматика публикации"]
T --> S{"Статус = Scheduled?"}
S -->|"нет"| W["Ничего не происходит"]
S -->|"да"| PUB["Публикация"]
PUB --> REPO["Репозиторий:<br/>страница в списке = вышла"]
REPO -->|"источник правды<br/>о факте"| DBDecision two: translation is a separate step, not automation
The temptation is obvious: since both versions have to be in one file, let publishing translate the Russian text itself and drop the English in next to it. One command and done.
I didn't do that. The English version isn't translated automatically at publish time. It's a separate step run by an explicit command, the result goes into a separate field, and it's proofread by a human - together with the Russian version, not after it.
The reason isn't translation quality. The reason is that automation inside publishing has no moment at which the text can be read. It's born and leaves for the site in a single action. You find out what it says when a reader sees it. By separating the steps, you get the ordinary editorial pause: the translation exists, it sits in a field, you can see it, you can edit it.
The side effect is a pleasant one: the English version stops being mandatory. An article can go out in one language first and the translation can arrive later - and decision one already supports that, because the switcher knows how to honestly say "there's no version."
Decision three: the source of truth about what's already published
The last question is the most boring and the most important: how does the system know which articles are already on the site?
The obvious answer is from the drafts database: it has a "published" flag right there. That answer is wrong, and here's why.
Publishing works as a static deploy: the script renders the pages, commits them and pushes to the repository, and the host rolls them out on its own in a couple of minutes. There are several places in this chain where things can stall: the render, the commit, the push, the rollout. The flag in the database is set on the intention to publish, not on the fact that the file is on the host. Let one link go silent and the database is convinced the article is out, while the link leads to nothing.
So the list of what's published is kept by the repository. The file with the list of articles sits next to the pages and is updated on every publish - in the same commit as the pages themselves. If an article made it into the list, then its page really did arrive: they arrive together or not at all.
The formulation I keep in my head: the database knows about intentions, the repository knows about facts. This isn't only about blogs. As soon as you have a separate store of state and a separate place where the state actually lives, the question "which of them is right" has to be settled deliberately - otherwise it settles itself, and not in your favor.
Put it to work
What to check in practice
If you're building something similar, here are concrete checks. They take about twenty minutes and catch all three failures I described.
- Open a published article and look at the page source (in the browser that's
Ctrl+Uor the "View Page Source" item). Use in-page search to find a fragment of the Russian text and a fragment of the English one. Both should be there - before you touch the switcher at all. - Switch your browser or system language to English and open the article again, preferably in a private window so saved settings don't interfere. The text should be there. Repeat for Russian.
- Check an article that doesn't have an English version yet. It should open in Russian, not blank.
- Find the "is there a version in this language" check in the code and look at what area it searches. If the search covers the whole page - limit it to the article container and re-run the previous two checks.
- Send yourself a link to the article in a messenger and look at the preview. The title and description should be picked up.
- Run the page generator on the most complicated real article: with subheadings of different levels, numbered and plain lists, links, emphasis. Look at the result in a browser, not at the fact that the script finished without an error.
- Compare the list of published articles in the database with the list in the repository. The discrepancy is your answer to the question of which one is the source of truth.
How to tell it worked
The criterion is simple and checkable: the article opens non-empty in any browser language, and both texts are visible in the page source. Not "the switcher works" - the switcher worked in the broken version too; it was diligently hiding the only text there was.
The second criterion is about publishing: a couple of minutes after the push to the repository the page is available at its direct link, and it's in the list file. If it's in the list but doesn't open, or opens but isn't in the list - you still don't have a single source of truth.
And the third, informal one: after publishing you don't need to fix anything by hand. Not markup the generator didn't understand, not a translation that arrived on its own. If every article comes with five minutes of manual touch-up, that's not a trifle - it's a bill you'll be paying on every publish.