Building a Multilingual Blog with FastHTML

Create a simple multilingual blog in Python with native Markdown support and code highlighting using FastHTML

Posted on 2025-02-21 by Simon Moisselin

pythonfasthtmlwebtutorial

I wanted a personal blog to write about my coding adventures. Simple enough, right? But I also wanted to write some posts in French for friends back home. And in markdown, of course. And with great code highlighting. And also one day I would want to add code interactivity.

That's when the "simple blog" turned into a rabbit hole of framework decisions and tech stack debates. And that's when FastHTML solved all my problems.

There is a hidden transaction cost of switching between multiple programming frameworks while coding.

Every blogging framework promises simplicity. Hugo's "just markdown." Astro's "just components." MDX lets you "just embed React." But each "just" adds another layer of syntax, another concept to juggle, another context switch for your brain.

Want to add a simple language switcher? Now you're learning Hugo's templating language, or figuring out how Astro's islands work, or wrestling with React's state management inside your MDX files. Each solution introduces its own special syntax, its own patterns, its own way of thinking. That's a lot of mental overhead.

You should not need a new blogging framework just to show some text in another language, or adding a common section.

As a machine learning engineer, I have one core preference: THIS CANNOT BE IN JAVASCRIPT, IF POSSIBLE. My code lives in Python - that's where my brain works.

When FastHTML promised web development purely in Python, it felt like finding an oasis in a desert of JavaScript frameworks (yes - surprisingly nice LLM poetry I decided to keep right here). Finally, a way to build web apps without leaving my native programming language.

"Locality of Behavior" to help for eliminating transaction costs

I stumbled upon this concept of "locality of behavior" that started to change the way on how I think about web development. Instead of spreading related code across different files and languages, what if everything that makes a feature work lived together?

Let's look at the heart of my blog - the post renderer:

@app.get("/posts/{slug}")
def post(session, slug: str):
    post = get_post(slug, session.get('language', DEFAULT_LANG), posts_by_lang)
    return (
        nav(),
        render_post(post)
    )

def render_post(post: Post):
    return Container(
        Div(
            Div(
                render_dropdown_language_post(post),  # Language switcher lives right here
                H1(post.metadata["title"], cls="text-3xl font-bold mb-4"),
                P(post.metadata["description"], cls="text-gray-600 mb-6"),
                cls="bg-gray-50 p-6 rounded-t-lg"
            ),
            Div(render_md(post.content), cls="p-6"),
            cls="border rounded-lg shadow-sm mx-auto"
        ),
        id="post-container",  # This ID is crucial for HTMX targeting
    )

This single function render_post almost tells you the whole story. You can see:

  1. The page structure (nested Divs creating a card-like layout)
  2. The styling (Tailwind classes showing exactly how it looks)
  3. The markdown rendering (render_md(post.content))
  4. The function for the language switcher integration (render_dropdown_language_post(post)) - more explained below

All the pieces that make this feature work are right here in plain Python. No need to jump between files to understand how things connect. But that's just the static part, just some magic mapping between tags and python functions. Let's look at the dynamic part when implementing the language switcher.

I am mostly using fasthtml common components and the monsterui components:

from fasthtml.common import *
from monsterui.all import *

Interactivity with HTMX

I didn't want to refresh the entire page if user decided to switch language. I just want the content of the post to be updated. For that, I used HTMX to update the content of the post.

def render_dropdown_language_post(post: Post):
    return Form(Select((Option(lang_str(lang), value=lang, selected=lang == post.metadata["language"]) for lang in ["en", "fr"]), cls="flex justify-end", hx_post="/setlanguage",
                  name="lang"),
                  Hidden(value=post.metadata["slug"], name="slug"),
                  hx_target="#post-container",
                  hx_trigger="change")

The hx_trigger="change" attribute is used to trigger the request when the user changes the language in the dropdown.

If you scroll, you can see inside render_post the following id being used:

Container(...,id="post-container",)  # This ID is crucial for HTMX targeting

This is the element that will be updated with the new content, by using the hx_target="#post-container" attribute in the language switcher form.

But what is going to be trigger? You can see that the language switcher is a form that will send a POST request to the /setlanguage endpoint with the selected language and the slug of the post. The POST request will trigger the set_language function, from the attributes hx_post="/setlanguage".

Let's look at the set_language function:

def set_language(session, lang: str, slug: str):
    session['language'] = lang
    return render_post(get_post(slug, lang, posts_by_lang))

FastHTML lets you store data in sessions, which are just Python dicts saved in the browser. Cookies would have worked fine too and might even be simpler, but sessions were easy to set up. With sessions I can just write session['language'] = lang to save and session.get('language') to load the language choice.

Automated Translation Pipeline

When I write a new English post, I want the French version to update automatically - but only when the source content changes. Here's how the system works:

Translation with Structured Output

Using OpenAI's API with Pydantic validation ensures I get exactly the fields I need:

class PostTranslated(BaseModel):
    title: str
    description: str
    content: str

def translate_post(post: Post, dst_lang: str) -> Post:
    client = instructor.from_openai(openai.OpenAI())
    post_hash = get_hash(post)
    # ... (translation logic) ...
    return Post(
        metadata={
            **post.metadata,
            "title": post_translated.title,
            "hash_src": post_hash  # <-- Critical for change tracking
        },
        content=post_translated.content
    )

The PostTranslated model acts as a contract with GPT-4 - no more guessing about response formats.

Pre-commit Safety Net

My .pre-commit-config.yaml ensures translations stay in sync:

- repo: local
  hooks:
  - id: translate-blogs
    entry: python3 translate_blogs.py
    always_run: true  # <-- Runs on every commit

This hook compares content hashes between source and translated posts. A post only translates if:

  1. The English version has changed (hash mismatch)
  2. No French version exists yet
  3. I manually force a re-translation

Hashing for change tracking

I should not translate every post at every commit. This would be a waste of money and time. So I need a way to detect meaningful changes. My hashing function considers both content and metadata:

  1. Existing articles should be translated only if some metadata changes (like title, description, status, but not the date or the tags.) or if the content has changed
  2. New articles should be translated automatically
def get_hash(post: Post) -> str:
    return hashlib.sha256(
        post.content.encode() +
        yaml.dump({k: v for k, v in post.metadata.items() if k not in ["date", "tags"]}).encode()
    ).hexdigest()

I have a translate_blogs.py script that will translate the posts and save them in the articles/fr folder, ran at every commit.

if __name__ == "__main__":
    posts_by_lang = get_posts_by_language()
    lang_src = "en"
    lang_dst = "fr"

    new_posts = []
    for post in tqdm(posts_by_lang[lang_src]):
        post_dst = get_post(post.metadata["slug"], lang_dst, posts_by_lang)
        if post_dst and post_dst.metadata.get("hash_src") == get_hash(post):
            continue
        translated_post = translate_post(post, lang_dst)
        new_posts.append(translated_post)
    for post in new_posts:
        save_post(post, pathlib.Path(f"articles/{lang_dst}/{post.metadata['slug']}.md"))

With the simplest .pre-commit-config.yaml possible:

repos:
-   repo: local
    hooks:
    -   id: translate-blogs
        name: Translate Blogs
        entry: python3 translate_blogs.py
        language: system
        types: [python]
        pass_filenames: false
        always_run: true
        verbose: true

Lessons Learned

The HTMX form integration took more time than expected - I initially tried avoiding <form> elements altogether before realizing they were necessary to send both the language preference and post_slug simultaneously. Sometimes web standards exist for good reason.

What's empowering is having complete ownership over the content pipeline. The translate_blogs.py system I built could easily extend to other transformations - I'm considering adding one-click "simplified explainer" versions of technical posts or auto-generating section-specific questions for readers (and me!).

I know existing frameworks could achieve similar results, but there's a deep satisfaction in crafting a solution from scratch that is done in just a few hundred lines of Python.

Full code available here