The problem RTL creates that isn’t just CSS
The obvious part of supporting Arabic on a website is direction: rtl in CSS, and that part is genuinely simple. The part that isn’t simple is everything adjacent to the text: navigation arrows that should point the opposite way in an RTL context, breadcrumbs whose visual order needs to flip without the underlying data structure changing, and inline elements — links, emphasis, footnote markers — that Hugo’s default Markdown renderer doesn’t give any hook into for direction-aware behavior.
Hugo solves the second problem with render hooks: template files that override how Markdown renders specific element types (links, images, headings, code blocks) instead of accepting Hugo’s built-in Blackfriday/Goldmark output as-is. That’s the mechanism this walkthrough leans on.
Render hooks: overriding link rendering
By default, Hugo renders a Markdown link [text](url) the same way regardless of the surrounding page’s language. For a link inside Arabic body text pointing to, say, a Latin-script reference term, the punctuation and link boundary can visually scramble in an RTL context without an explicit isolation wrapper.
A render hook for links lives at layouts/_default/_markup/render-link.html:
{{- $isRTL := eq .Page.Language.LanguageDirection "rtl" -}}
<a href="{{ .Destination | safeURL }}"
{{ with .Title }}title="{{ . }}"{{ end }}
{{ if $isRTL }}dir="auto"{{ end }}>{{ .Text | safeHTML }}</a>
The dir="auto" attribute (rather than hardcoding dir="rtl" on every link) matters specifically because link text itself is sometimes Latin script even inside an Arabic paragraph — a term, a citation, a URL fragment shown as text. dir="auto" lets the browser’s bidi algorithm decide per-element based on the actual characters present, rather than forcing a direction that would be wrong for embedded Latin text within Arabic content.
Hugo picks this hook up automatically for every rendered link across the site — no per-page opt-in required, which is the actual value of the render hook mechanism over sprinkling conditional logic through content files.
Direction-aware navigation
The second piece is the site’s primary navigation, which needs to visually reverse for RTL languages — “next lesson” pointing left instead of right, breadcrumb order flowing right-to-left — without changing the underlying data model that defines the navigation structure.
The template checks page language direction once and branches the arrow glyphs and flex order accordingly:
{{ $isRTL := eq .Page.Language.LanguageDirection "rtl" }}
<nav class="lesson-nav" dir="{{ .Page.Language.LanguageDirection }}">
{{ with .PrevInSection }}
<a href="{{ .RelPermalink }}" class="nav-prev">
{{ if $isRTL }}→{{ else }}←{{ end }} {{ .Title }}
</a>
{{ end }}
{{ with .NextInSection }}
<a href="{{ .RelPermalink }}" class="nav-next">
{{ .Title }} {{ if $isRTL }}←{{ else }}→{{ end }}
</a>
{{ end }}
</nav>
The underlying flex-direction in CSS is set from the same dir attribute rather than duplicated as a separate Hugo conditional:
.lesson-nav[dir="rtl"] {
flex-direction: row-reverse;
}
This split matters: Hugo’s template decides which glyph to show (since arrow direction is a content decision tied to reading order, not purely visual), while CSS handles layout flow from the same dir attribute Hugo already set. Duplicating the RTL check in both the template and a parallel CSS class would be redundant and a second place for the two to drift out of sync.
Two-pass weight-then-title sorting, and why it interacts with RTL
Hugo’s list templates sort content, and the natural approach — sort by .Weight when the front matter defines one, falling back to title order otherwise — has a subtlety in a multilingual site: title-based fallback sorting uses Go’s default string comparison, which sorts Arabic Unicode codepoints in an order that has no relationship to Arabic alphabetical order as a native reader would expect. A weight-first sort mostly sidesteps this by making weight authoritative whenever it’s set:
{{ $pages := .Pages }}
{{ $weighted := where $pages "Weight" "!=" 0 }}
{{ $unweighted := where $pages "Weight" "==" 0 }}
{{ $sorted := ($weighted.ByWeight) | append ($unweighted.ByTitle) }}
This is a two-pass sort rather than a single .ByWeight call, because Hugo’s .ByWeight on pages with no weight set falls back to Go’s string sort on title anyway — silently, with no signal that it happened. Splitting weighted and unweighted pages explicitly makes the fallback visible and intentional rather than an implicit default a reader of the template wouldn’t notice.
Why hooks over per-template conditionals
The alternative to render hooks would be sprinkling {{ if eq .Language.Lang "ar" }} conditionals through every content template that touches a link or heading. That works until the site adds a fifth language sharing Arabic’s RTL direction — Urdu or Persian, say — at which point every one of those conditionals needs to be found and updated to check direction rather than a specific language code. Checking .LanguageDirection rather than a hardcoded language string is what actually generalizes; the render hook mechanism just gives that check one place to live instead of many.