Image: Google Gemini

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.