Wer mit Wiki.js v2 produktiv arbeitet, bemerkt schnell eine Lücke: Es gibt keine eingebaute Möglichkeit, auf einer Seite automatisch alle Unterseiten aufzulisten. Jede Landingpage, ob für Team-Wikis, Projektbereiche oder Dokumentationen, muss heute manuell gepflegt werden. Sobald eine neue Unterseite angelegt, verschoben oder gelöscht wird, besteht die Gefahr, dass das zentrale Inhaltsverzeichnis veraltet. In einem lebendigen Unternehmens-Wiki wie unserem ist das nicht tragbar: Wir haben über 800 Einträge und täglich kommen welche dazu.
Für Swissmakers GmbH war klar: Eine dynamische Landingpage, die sich ohne manuellen Aufwand aktualisiert, ist essenziell für konsistente Dokumentation und effizientes Auffinden von Informationen.
Warum Wiki.js v2 das nicht von Haus aus mitliefert
Die Wiki.js-Version 2 basiert intern auf einem Vue.js Single-Page-Application-Ansatz. Alle Seiteninhalte werden clientseitig nachgeladen; serverseitig generierte Platzhalter wie „[children]“ (bekannt aus anderen Markdown-Plugins) existieren nicht. Das Entwicklerteam von Wiki.js hat zwar für Version 3 ein modulares Blocks-System angekündigt, doch für v2-Instanzen bleibt nur der Weg über die öffentliche GraphQL-API oder ein Upgrade. Letzteres ist jedoch im produktiven Umfeld definitiv nicht empfohlen da die v3 seit nun fast zwei Jahren lediglich als Alpha Version verfügbar ist und bis heute als «unstable» gilt. Zudem sind die meisten Features noch gar nicht oder nicht richtig implementiert.
Unsere Lösung: Ein leichtgewichtiger GraphQL-Client im Frontend
Um das Feature umzusetzen, haben wir ein JavaScript-Snippet entwickelt, das wir in jeder gewünschten Landingpage oder übergeordnete «Tech-Page» einbinden können. Der Code wird dabei in die jeweilige Seite unter den «Page Properties» im Tab «Script» eingefügt:

Der Code folgt beim Aufruf der jeweiligen Seite dem folgendem Ablauf:
- holt einmalig alle Seiten aus der GraphQL-API (Standartlimit bis 1000 gesetzt),
- filtert ausschliesslich jene, deren Pfad unterhalb des automatisch erkannten Seitenpfads liegt (also unterhalb der aktuell geöffneten Seite),
- entfernt die Landingpage selbst aus der Ausgabe,
- rendert das Ergebnis als übersichtliche Link-Liste mit dem bereits eingebauten
links-list
-Styling von Wiki.js.
In der wiki-Seite selbst wird dann der gerenderte HTML-content innerhalb des<div id="pageTree">Loading pages …</div>
mit dem generierten Menu ersetzt.

Die Page kann dabei beliebig gestaltet oder erweitert werden, wichtig ist nur, dass das erwähnte Div am gewünschten Ort innerhalb der Seite platziert wird, wo die dynamische Sub-Übersicht gewünscht ist.
JavaScript Code
<script>
(() => {
const debug = false; // auf true setzen, falls etwas nicht wie gewünscht funktioniert.
const MAX_ATTEMPTS = 30; // bis zu 30 × 200 ms auf #pageTree warten (timeout)
let attempts = 0;
const log = (...a) => debug && console.log(...a);
const warn = (...a) => debug && console.warn(...a);
const error = (...a) => debug && console.error(...a);
function init() {
const container = document.getElementById('pageTree');
if (!container) {
attempts++;
warn(`#pageTree not found, attempt ${attempts}`);
if (attempts < MAX_ATTEMPTS) return setTimeout(init, 200);
error('Gave up waiting for #pageTree');
return;
}
/** Pfad der aktuellen Seite ohne führenden Locale-Teil */
const selfPath = location.pathname
.replace(/^\/[^/]+\//, '') // z. B. /en/ entfernen
.replace(/^\/|\/$/g, ''); // führenden / und trailing / entfernen
/** Präfix, das alle Kinder gemeinsam haben (selfPath + "/") */
const childPrefix = selfPath.endsWith('/') ? selfPath : selfPath + '/';
log('[Wiki.js] selfPath:', selfPath);
log('[Wiki.js] childPrefix:', childPrefix);
const query = `
query {
pages {
list(limit: 1000, orderBy: PATH, orderByDirection: ASC) {
path
title
locale
}
}
}`;
fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
})
.then(r => r.json())
.then(({ data }) => {
if (!data?.pages?.list) {
container.textContent = 'API-Fehler: keine Seitendaten.';
error('pages.list fehlt im Response', data);
return;
}
const pages = data.pages.list
.filter(p => p.path.startsWith(childPrefix)) // nur echte Unterseiten
.filter(p => p.path !== selfPath); // Landingpage selbst raus
log('[Wiki.js] gefilterte Seiten:', pages);
if (!pages.length) {
container.textContent = 'Keine Unterseiten vorhanden.';
return;
}
const listHTML = pages.map(
p => `<li><a href="/${p.locale}/${p.path}">${p.title}</a></li>`
).join('');
container.innerHTML = `<ul class="links-list">${listHTML}</ul>`;
})
.catch(err => {
container.textContent = 'Fehler beim Laden der Seitenliste.';
error(err);
});
}
init();
})();
</script>
Was passiert im Detail?
Nachfolgend haben wir für jene, die sich für JavaScript interessieren oder einfach genauer wissen möchten, was Zeile für Zeile genau passiert, das Ganze auf die einzelnen Zeilen aufgeschlüsselt. (Die Zeilennummern beziehen sich auf das komplette Snippet, inklusive <script>
-Tag.)
Zeile(n) | Code | Technische Erklärung |
---|---|---|
1 | <script> | Öffnet einen HTML-Script-Block – der Browser interpretiert alles bis </script> als JavaScript. |
2 | (() => { | Start eines IIFE (Immediately Invoked Function Expression). Dadurch liegt der gesamte Code in einem eigenen Scope; globale Namenskollisionen werden vermieden. |
3 | const debug = true; | Schalter für ausführliche Konsolenausgabe. Bei false werden alle Log-Aufrufe unterdrückt. |
4 | const MAX_ATTEMPTS = 30; | Maximale Anzahl Wiederholungen beim Polling auf das DOM-Element. |
5 | let attempts = 0; | Zähler für die bereits erfolgten Polling-Versuche. |
7 – 9 | const log…warn…error… | Drei Arrow Functions, die jeweils nur dann auf console.* zugreifen, wenn debug aktiv ist. So spart man if-Blöcke in der Logik darunter. |
11 | function init() { | Hauptfunktion. Sie wird wiederholt aufgerufen, bis das Ziel-Element vorhanden ist. |
12 | const container = document.getElementById('pageTree'); | Sucht das DIV, in dem später die Linkliste gerendert wird. |
13 – 18 | if (!container) { … } | Polling-Logik: • Zähler erhöhen • Warnung loggen • Wenn attempts < MAX_ATTEMPTS , mit setTimeout(init, 200) nach 200 ms erneut prüfen.• Nach dem Limit Abbruch mit Fehlermeldung. |
22 – 24 | const selfPath = location.pathname.replace… | Ermittelt den Pfad der aktuellen Wiki-Seite, entfernt dabei: 1. die Sprachpräfix-Sektion (Regex ^\/[^\/]+\/ )2. evtl. führenden bzw. abschliessenden Slash. |
27 | const childPrefix = selfPath.endsWith('/') ? selfPath : selfPath + '/'; | Stellt sicher, dass der Child-Präfix immer mit / endet. Beispiel: 07-internal-it/it-services/ . |
29 – 30 | log('[Wiki.js] selfPath:' …) | Debug-Ausgabe des aktuellen Pfades und des sub-Präfixes. |
32 – 41 | const query = \ …`;` | Template Literal mit GraphQL-Query. • pages.list liefert bis zu 1000 Seiten.• Sortiert nach Pfad, damit childs automatisch gruppiert sind. |
43 – 47 | fetch('/graphql', { … }) | Stellt einen HTTP-POST an den Wiki.js-GraphQL-Endpunkt. Header Content-Type: application/json ist Pflicht. |
48 | }).then(r => r.json()) | Erster Promise-Schritt: Die Antwort wird per Response.json() geparst. |
49 | .then(({ data }) => { | Zweiter Promise-Schritt: ES6-Destrukturierung, um data sofort herauszuziehen. |
50 – 54 | if (!data?.pages?.list) { … } | Prüfung auf Existenz der erwarteten Struktur unter Nutzung von Optional Chaining (?. ). Bei Fehler: Meldung für den Benutzer und Logging. |
56 – 58 | const pages = data.pages.list.filter… | Zweistufiger Filter: 1. startsWith(childPrefix) → nur echte Unterseiten2. !== selfPath → eigene Seite ausschliessen. |
60 | log('[Wiki.js] gefilterte Seiten:', pages); | Debug-Ausgabe des Ergebnisses nach dem Filter. |
62 – 65 | if (!pages.length) { … } | Fehlermeldung, falls keine Unterseiten existieren (z. B. nach Migration). |
67 – 69 | const listHTML = pages.map(p => `<li>…`).join(''); | Baut für jede Unterseite ein <li><a …></a></li> und verbindet alles zu einem HTML-String. |
71 | container.innerHTML = \ <ul class=»links-list»>${listHTML}</ul>`;` | Rendert die Linkliste. Die Klasse links-list sorgt für das card-artige Styling, das Wiki.js mitliefert. |
73 – 76 | .catch(err => { … }) | Fehlerbehandlung der Fetch-Kette. Zeigt nutzerfreundliche Meldung und loggt das Exception-Objekt. |
79 | init(); | Erster Aufruf der Init-Funktion – löst das Polling aus. |
80 | })(); | Schliesst das IIFE und ruft es sofort auf. |
81 | </script> | Ende des Script-Blocks. |
Kleiner Zusatz: Alphabetisches Sortieren nach «Page-Titel» anstatt Pfad
Im Standard GraphQL-Respond sind die jeweiligen Pages nach dem Pfad (PATH URL) sortiert. Diese kann sich aber vom eigentlichen Titel unterscheiden und so kann es auch Sinn machen, die Seiten nach ihrem Titel alphabetisch zu sortieren.
Dafür wird im JavaScript-Code noch vor dem Rendern der Liste const listHTML = pages.map
(Zeile 67) das Array der Seiten mit der Funktion sort() und localeCompare() nachsortiert. Der benötigte Code sieht so aus:
pages.sort((a, b) => a.title.localeCompare(b.title));
Die nötige Änderung, respektive Ergänzung im Code müsste also wie folgt aussehen:
pages.sort((a, b) => a.title.localeCompare(b.title));
const listHTML = pages.map(
p => `<li><a href="/${p.locale}/${p.path}">${p.title}</a></li>`
).join('');
Praxiserfahrungen nach der Integration
Nach wenigen Minuten ist das Snippet in mehreren Bereichen aktiv. Seitdem
- erscheinen neue Dokumente sofort in der Übersicht,
- entfällt die manuelle Pflege der Landingpages / Subpages Übersicht,
- bleibt die Navigation auch bei tiefen Verschachtelungen konsistent.
Gleichzeitig ist das Skript so schlank, dass es künftige Migrationen bis v3 (sollte diese Version dann doch noch irgendwann erscheinen) nicht behindert. Bis dahin sichert es die Funktionsfähigkeit unseres Wikis mit minimalem Aufwand.

Der folgende Screenshot zeigt ein Beispiel, bei dem eine alphabetische Sortierung nach dem Page-Namen mehr Sinn machen würde.
Fazit
Ein fehlendes Kernfeature muss nicht zwangsläufig auf das nächste Major-Upgrade warten. Mit einem präzisen Blick in die GraphQL-API und wenigen Zeilen JavaScript konnten wir unsere Dokumentationsqualität deutlich erhöhen. Wer ebenfalls auf Wiki.js v2 setzt, kann das Snippet ohne weitere Abhängigkeiten übernehmen. Fragen oder Verbesserungsvorschläge bitte an info@swissmakers.ch, wir tauschen uns gerne aus. Für Unterstützung bei der Installation und Konfiguration von Wiki.js kontaktieren Sie uns ungeniert für ein Beratungsgespräch.