Bilingual URLs in a Roq Site, Without a Plugin
A static-site generator with no opinion on languages is a feature — until you need a second language. Roq does not ship multilingual primitives, but it does not fight them either. With a directory convention, two frontmatter keys, and a tiny redirect, our site became fully bilingual without an extension or a plugin.
The Directory Convention
Roq translates content paths to URLs unless you override them with frontmatter url:. We mirror the URL structure on disk, one directory per language:
content/
├── de/
│ ├── index.html → /de/
│ ├── partner/index.html → /de/partner/
│ └── referenzen/...
└── en/
├── index.html → /en/
├── partners/index.html → /en/partners/
└── testimonials/...
Posts under content/posts/ stay shared, since they are language-agnostic for us.
Two Frontmatter Keys
Each page sets its own URL and points to its translation:
---
title: Partner
layout: page
url: /de/partner/
lang_alt: /en/partners/
---
The English counterpart mirrors them inverted. Two keys, no special syntax.
One Layout Check
In the base layout, we pick the right nav, footer, and language-switcher target by looking at the URL prefix:
{#if page.url.path.startsWith('/en')}
<nav>... English nav ...</nav>
{#else}
<nav>... German nav ...</nav>
{/if}
The else branch covers German pages plus shared paths (/blog/, /posts/), defaulting to the German chrome — exactly what we want for a German-default site.
The language-switcher button links to page.data.lang_alt, with a sensible fallback for pages that have no translation:
<a href="{page.data.lang_alt ?: '/en/'}" class="lang-switch">EN</a>
A Browser-Aware Root
/ itself does not show content. It sniffs the visitor's language and redirects:
<meta http-equiv="refresh" content="0; url=/de/">
<link rel="alternate" hreflang="de" href="/de/">
<link rel="alternate" hreflang="en" href="/en/">
<link rel="alternate" hreflang="x-default" href="/de/">
<script>
var lang = (navigator.language || '').toLowerCase();
var prefersDe = lang === 'de' || lang.indexOf('de-') === 0;
location.replace(prefersDe ? '/de/' : '/en/');
</script>
JS handles the common case (most browsers report a language); the <meta refresh> covers the no-JS fallback; the hreflang tags tell search engines that the redirect is a soft default, not a duplicate.
What This Does Not Do
There is no automatic translation, no shared message catalogue, no per-language post collection. We translate by hand and link via lang_alt. For a site with a handful of pages, that is exactly the right amount of structure.
The pattern scales: adding French is content/fr/, one more nav branch in the layout, and a third redirect target. No plugin, no fork, no fight with the framework.