Articles
Building a Frontend-Only Multilingual Blog with Notes and Articles
I built stephen.taipei as a multilingual blog for Notes and Articles: Markdown is the source of truth, translations are prepared when I publish, static pages are generated at build time, and the browser handles reading and language switching. This article explains the content model, shared pipeline, trade-offs around on-demand loading and SEO, and what a frontend-only reading experience saves me.
· 8 min read
I wanted my website to let me jot down a thought and also explain a technical problem in full. Short pieces go in Notes, longer ones in Articles. They can link to each other, and when visitors switch languages, the content should switch with them.
That sounds like an ordinary requirement. But if I start by adding a database, an admin backend, and a content API, then manage a separate version for each language, I quickly end up maintaining another product.
I narrowed the scope to what I would actually use: one author, content in Git, a site rebuild after each publication, and only static files and a browser on the reading side. That is the Notes and Articles architecture behind stephen.taipei today.
A frontend-only reading experience#
Let’s make the boundary clear. The project uses React, Vite, and Tailwind. Content processing, image conversion, and build scripts still need Node.js, and translation calls an external service at publishing time. After deployment, Nginx serves the static files.
By a frontend-only reading experience, I mean that readers don’t need a dedicated blog backend to look up content, assemble pages, or translate on the fly. There is a build process and a host, but no always-running application API or database for reading articles.
Doing the work ahead of time keeps the reading side simple.
Notes and Articles share a source, but keep separate models#
All content lives under content/, and the originals are Markdown. I keep the two content types in separate formats:
content/
tags.yaml
tags.locales.yaml
notes/2026/20261004-0944.md
notes/2026/20261004-0944.locales.yaml
articles/frontend-only-multilingual-blog/
index.md
locales.yaml
cover.webp
Notes are short cards. Their filenames use the minute in Taipei local time as an ID, and a note can cover an idea, something I learned, a launch, a pitfall, or something I’m reading. A single paragraph is enough; I don’t need to come up with a title every time. The site warns me past 500 characters and rejects notes over 1,000 characters, so longer pieces belong in Articles.
Articles have a fixed slug, title, summary, and date, with an optional cover and update time. Once an article’s URL is public, I leave it unchanged so shared links keep working.
The two are connected with article:. A note can capture a question and link to the full article; the article page lists the notes that refer to it. The short piece preserves the original starting point, while the longer one works through the reasoning. There’s no need to paste the same full text in both places.
Catch content errors before the build#
If Markdown were rendered straight onto a page, many problems might not show up until after publication: a mistyped date, a broken article reference, an image without alt text, or several near-duplicate tags for the same topic.
I put these rules in a shared content pipeline. loadContent reads the files, validates their fields with Zod, and then processes Markdown, images, locales, and content relationships. The CLI checks and the Vite build use the same pipeline, which narrows the gap between “it works on my machine” and “it fails in production.”
Tags must use keys registered in tags.yaml. Articles and other notes referenced by a note must exist. Local previews can include drafts; production builds exclude them. These rules are enforced by checks instead of relying on me to remember them while publishing.
Markdown also has clear output boundaries: raw HTML is not emitted directly, generated HTML is sanitized, and code blocks are highlighted at build time. Images referenced by local relative paths are converted to WebP at up to 1,600 pixels wide, with EXIF data removed, including GPS data. Articles, cards, and share previews reuse the processed image. Remote images and images referenced by root-relative paths do not go through this local conversion process.
Pay the cost of multilingual content when publishing#
The site currently supports 22 languages. I write the original in Traditional Chinese once, and store the translated titles, summaries, bodies, and image descriptions for the other 21 languages in the neighboring locales.yaml file.
One decision matters to me: readers don’t have to wait for a translation. content:localize generates translations when I publish. The production build reads the files that already exist; it doesn’t call the translation service again on every deployment.
A source digest ties each translation to its original. If I change the source, the old translation can no longer be treated as current. content:check fails if a locale is missing or a translation is stale. The translation process also protects code, inline code, and link targets so technical identifiers and URLs aren’t translated.
That doesn’t make automatic translations as good as human-edited copy. It helps keep translations complete and up to date, while terminology and tone still need proofreading. Unchanged source text keeps its existing translations, and rerunning localization won’t overwrite text edited by hand.
Show summaries in lists; load articles when readers open them#
As the content grows, loading the full text of every article in every language on the homepage would make even a frontend-only site slow.
The Vite plugin exposes content through three entry points: virtual:content/latest for the homepage, virtual:content/index for content pages, and virtual:content/article/<slug> for each article. The homepage shows the latest three notes and cards for three articles. Note data still includes the full text and translations; the card simply truncates what it displays. Article cards carry summary and other metadata, while a dynamic import loads the full body for the selected article.
React’s Suspense waits for the body, and the outer error boundary displays a message if loading fails. If the body request fails, the title, summary, and page navigation remain available.
There’s a trade-off: each article currently has one body chunk containing translations for every language. The translations are not split into one chunk per language. Full note text and translations go into the content index. As the site grows, the index size and the amount of multilingual article content downloaded are the next things I need to measure.
Keep browser language switching and a static reading entry point#
With an SPA alone, a shared link often shows only the homepage title and image. That isn’t enough for an article.
So at build time, each note, article, and tag URL gets its own HTML shell with a title, description, canonical, Open Graph metadata, and JSON-LD. A readable copy of the original text goes inside noscript. Readers can still access the article and its dedicated cover without running JavaScript.
The same content also produces feed.xml, sitemap-content.xml, Markdown versions of articles, and the article index in llms.txt. These are build outputs, not another set of content to keep in sync by hand.
The multilingual SEO boundary matters too: for now, each Note or Article has a single URL shared across all languages, and the browser switches the language. Static content pages, feeds, and Markdown versions remain in the original language. The homepage is also pre-rendered for language and light or dark mode; that doesn’t mean each article has 22 independently indexable language pages.
If I later want search engines to index a separate page for every language, I’ll need language-specific routes, corresponding static pages, and hreflang. That’s a separate requirement this setup doesn’t meet yet.
Publishing is also a delivery process#
My publishing flow is: write the original, generate translations, check the content, commit to Git, and then merge into main. The existing GitHub Actions workflow rebuilds and deploys the site.
npm run content:localize
npm run content:check
npm run test:content
npm run build
If any locale is missing, I fix that first. If an article is still a draft, it stays in the local preview. The production output also checks that draft pages are absent and scans key public outputs—including the feed, sitemap, homepage, and JavaScript bundles—for draft markers. Like code, content has diffs and versions, and can be rolled back with it.
This approach gives up real-time updates: even a one-line change has to go through a build and deployment. For my technical site, I’m willing to wait in exchange for one less backend, one less runtime dependency, and a change history that’s easier to trace.
When this approach works well#
This architecture works well for one author who can publish at a pace that allows for a rebuild, writes mostly text, and is already comfortable with Git. Short notes lower the barrier to writing, articles preserve the full reasoning, and multiple languages make the same content available to more readers.
If the needs grow to include real-time collaboration, access controls, reader comments, accounts, or subscriptions, I’ll need to reconsider a CMS and backend. A frontend-only setup doesn’t make those needs disappear; I’ve simply left them out of this blog’s scope for now.
The design principles here are close to the ones I described in An Architecture Pattern for Multiple Layouts and Themes: separate responsibilities first, then use shared rules to keep things consistent.
For this site, the goal is to keep writing and know exactly what each publication ships.