diff --git a/src/components/shared/header-link.vue b/src/components/shared/header-link.vue
new file mode 100644
index 0000000..b19ea5d
--- /dev/null
+++ b/src/components/shared/header-link.vue
@@ -0,0 +1,23 @@
+<script setup lang="ts">
+import { type HeaderEntry } from 'content/routes.js'
+
+defineProps<{
+  entry: HeaderEntry,
+}>()
+</script>
+
+<template lang="pug">
+li.header-entry
+  span(
+    v-if='entry.children'
+  ) {{ entry.displayName }}
+    ul
+      HeaderLink(
+        v-for='entry in entry.children'
+        :entry='entry'
+      )
+  a(
+    v-else
+    :href='entry.path'
+  ) {{ entry.displayName }}
+</template>
diff --git a/src/content-env.d.ts b/src/content-env.d.ts
index ad0b67d..470d413 100644
--- a/src/content-env.d.ts
+++ b/src/content-env.d.ts
@@ -19,4 +19,14 @@ declare module 'content/routes.js' {
   type RouteCollection = { [key: string]: RouteDefinition }
 
   const routes: RouteCollection
+
+  type HeaderEntry = {
+    displayName: string
+  } & ({
+    path: string
+  } | {
+    children: HeaderEntry[]
+  })
+
+  const header: HeaderEntry[]
 }
diff --git a/src/main.ts b/src/main.ts
index a147521..146920e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,7 +6,7 @@ import main from './main.vue'
 import './main.sass'
 
 import { createRoutes, useRouteStore } from './routes'
-import { routes as appRoutes, type RouteDefinition } from 'content/routes.js'
+import { routes as appRoutes, header, type RouteDefinition } from 'content/routes.js'
 
 export const createApp = ViteSSG(
   // the root component
@@ -23,5 +23,6 @@ export const createApp = ViteSSG(
         ...appRoutes[route] as RouteDefinition,
       }
     )
+    routeStore._header = header
   },
 )
diff --git a/src/main.vue b/src/main.vue
index 7b70df8..0bf55c2 100644
--- a/src/main.vue
+++ b/src/main.vue
@@ -4,11 +4,14 @@ import { useRoute } from 'vue-router'
 
 import { useRouteStore } from 'src/routes'
 
+import HeaderLink from 'src/components/shared/header-link.vue'
+
 const ready = ref(false)
 
 const currentRoute = useRoute()
 const routeStore = useRouteStore()
 const routeConfig = routeStore._routes[currentRoute.path]
+const headerConfig = routeStore._header
 
 const init = async () => {
   const staleStylesheets = document.head.querySelectorAll('link[rel="stylesheet"]')
@@ -30,6 +33,14 @@ init()
 
 <template lang="pug">
 #main-container
+  header(
+    v-if='ready && !!headerConfig'
+  )
+    ul
+      HeaderLink(
+        v-for='entry in headerConfig'
+        :entry='entry'
+      )
   #main-entry(
     v-if='ready'
   )
diff --git a/src/routes.ts b/src/routes.ts
index 07de9e6..be403ec 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -1,6 +1,6 @@
 import { defineStore } from 'pinia'
 import { type RouteRecordRaw } from 'vue-router'
-import { routes, type RouteDefinition, type Template } from 'content/routes.js'
+import { routes, type HeaderEntry, type RouteDefinition, type Template } from 'content/routes.js'
 
 const markdownBody = () => import ('./views/markdown.vue')
 
@@ -24,6 +24,7 @@ export const createRoutes = (): RouteRecordRaw[] => {
 
 export const useRouteStore = defineStore('routeStore', {
   state: () => ({
+    _header: [] as HeaderEntry[],
     _routes: {} as Record<string, RouteRecordRaw & RouteDefinition>,
   }),
   actions: {