Those who work with Wiki.js v2 working productively will quickly notice a gap: There is no in-built option to automatically all subpages to list. Today, every landing page, whether for team wikis, project areas or documentation, has to be maintained manually. As soon as a new subpage is created, moved or deleted, there is a risk that the central table of contents will become outdated. This is not acceptable in a lively company wiki like ours: We have over 800 entries and more are added every day.
For Swissmakers GmbH was clear: a dynamic landing page that updates itself without manual effort is essential for consistent documentation and efficient retrieval of information.
Why Wiki.js v2 does not include this by default
Wiki.js version 2 is based internally on a Vue.js single-page application approach. All page content is reloaded on the client side; server-side generated placeholders such as "[children]" (known from other Markdown plugins) do not exist. Although the Wiki.js development team has announced a modular blocks system for version 3, the only option for v2 instances is to use the public GraphQL API or an upgrade. However, the latter is definitely not recommended in a productive environment as v3 has only been available as an alpha version for almost two years now and is still considered "unstable". In addition, most of the features have not yet been implemented at all or not properly.
Our solution: A lightweight GraphQL client in the frontend
To implement the feature, we have created a JavaScript snippet that we can integrate into any desired landing page or higher-level "tech page". The code is inserted into the respective page under the "Page Properties" in the "Script" tab:

The code follows the following sequence when the respective page is called up:
- fetches all pages from the GraphQL API once (default limit set to 1000),
- only filters those whose path is below the automatically recognised page path (i.e. below the currently open page),
- removes the landing page itself from the output,
- renders the result as a clear link list with the already integrated
links-list
-styling of Wiki.js.
In the wiki page itself, the rendered HTML content is then displayed within the<div id="pageTree">Loading pages ...</div>
with the generated menu.

The page can be designed or extended as desired, the only important thing is that the div mentioned is placed at the desired location within the page where the dynamic sub-overview is required.
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>
What happens in detail?
For those who are interested in JavaScript or simply want to know exactly what happens line by line, we have broken it down into the individual lines below. (The line numbers refer to the complete snippet, including the -tag.)
Line(s) | Code | Technical explanation |
---|---|---|
1 |
| Opens an HTML script block - the browser interprets everything up to as JavaScript. |
2 | (() => { | Start of an IIFE (Immediately Invoked Function Expression). This means that the entire code is in its own scope; global name collisions are avoided. |
3 | const debug = true; | Switch for detailed console output. With false all log calls are suppressed. |
4 | const MAX_ATTEMPTS = 30; | Maximum number of repetitions when polling the DOM element. |
5 | let attempts = 0; | Counter for the polling attempts already made. |
7 - 9 | const log...warn...error... | Three Arrow Functions, each of which can only be console.* access when debug is active. This saves if blocks in the logic below. |
11 | function init() { | Main function. It is called repeatedly until the target element is available. |
12 | const container = document.getElementById('pageTree'); | Searches for the DIV in which the link list will later be rendered. |
13 - 18 | if (!container) { ... } | Polling logic: - Increase counter - Log warning - When attempts < MAX_ATTEMPTS , with setTimeout(init, 200) Check again after 200 ms.- After the limit cancellation with error message. |
22 - 24 | const selfPath = location.pathname.replace... | Determines the path of the current wiki page, removing it: 1. the language prefix section (Regex ^\/[^\/]+\/ )2. possibly leading or trailing slash. |
27 | const childPrefix = selfPath.endsWith('/') ? selfPath : selfPath + '/'; | Ensures that the child prefix always starts with / ends. Example: 07-internal-it/it-services/ . |
29 - 30 | log('[Wiki.js] selfPath:' ...) | Debug output of the current path and the sub-prefix. |
32 - 41 | const query = \ ...`;` | Template Literal with GraphQL-Query. - pages.list delivers up to 1000 pages.- Sorted by path so that children are automatically grouped. |
43 - 47 | fetch('/graphql', { ... }) | Provides an HTTP POST to the Wiki.js GraphQL endpoint. Header Content-Type: application/json is mandatory. |
48 | }).then(r => r.json()) | First Promise step: The answer is sent via Response.json() parsed. |
49 | .then(({ data }) => { | Second Promise step: ES6 restructuring in order to data immediately. |
50 - 54 | if (!data?.pages?.list) { ... } | Check for the existence of the expected structure using optional chaining (?. ). In the event of an error: Message for the user and logging. |
56 - 58 | const pages = data.pages.list.filter... | Two-stage filter: 1. startsWith(childPrefix) → only real subpages2. !== selfPath → Exclude own page. |
60 | log('[Wiki.js] filtered pages:', pages); | Debug output of the result after the filter. |
62 - 65 | if (!pages.length) { ... } | Error message if no subpages exist (e.g. after migration). |
67 - 69 | const listHTML = pages.map(p => ` | Builds one for each underside <li><a …></a></li> and combines everything into an HTML string. |
71 | container.innerHTML = \
| Renders the link list. The class links-list provides the card-like styling that Wiki.js delivers. |
73 - 76 | .catch(err => { ... }) | Error handling of the fetch chain. Displays a user-friendly message and logs the exception object. |
79 | init(); | First call of the Init function - triggers polling. |
80 | })(); | Closes the IIFE and calls it up immediately. |
81 |
| End of the script block. |
Small addition: Alphabetical sorting by "page title" instead of path
In the standard GraphQL response, the respective pages are sorted according to the path (PATH URL). However, this can differ from the actual title, so it can also make sense to sort the pages alphabetically according to their title.
To do this, before the list is rendered, the JavaScript code const listHTML = pages.map
(line 67) sorts the array of pages using the sort() and localeCompare() functions. The required code looks like this:
pages.sort((a, b) => a.title.localeCompare(b.title));
The necessary change or addition to the code should therefore be as follows:
pages.sort((a, b) => a.title.localeCompare(b.title));
const listHTML = pages.map(
p => `<li><a href="/en/${p.locale}/${p.path}/">${p.title}</a></li>`
).join('');
Practical experience after integration
After a few minutes, the snippet is active in several areas. Since then
- new documents appear immediately in the overview,
- eliminates the need for manual maintenance of the landing pages / subpages overview,
- navigation remains consistent even with deep nesting.
At the same time, the script is so lean that it will not hinder future migrations up to v3 (should this version be released at some point). Until then, it ensures the functionality of our wiki with minimal effort.

The following screenshot shows an example where alphabetical sorting by page name would make more sense.
Conclusion
A missing core feature does not necessarily have to wait for the next major upgrade. With a precise look at the GraphQL API and a few lines of JavaScript, we were able to significantly improve our documentation quality. If you also use Wiki.js v2, you can use the snippet without any further dependencies. Please send any questions or suggestions for improvement to info@swissmakers.chwe are happy to exchange ideas. For support with the installation and configuration of Wiki.js Contact us Feel free to contact us for a consultation.