diff --git a/nuxt/lib/docs-seo.mjs b/nuxt/lib/docs-seo.mjs
new file mode 100644
index 0000000000..1cfc80ef84
--- /dev/null
+++ b/nuxt/lib/docs-seo.mjs
@@ -0,0 +1,37 @@
+// Derives the SEO surface of a docs page from its frontmatter: the title, the brand
+// qualifier the global title template appends to it, the description, the canonical url,
+// the og-image props and the article's dateModified. Kept free of Nuxt and Vue imports so
+// it can be unit tested with `node --test`; the page component only wires the result into
+// useHead/useSeoMeta/useSchemaOrg/defineOgImage.
+
+import { toIso } from './relative-time.mjs'
+
+const SITE_URL = 'https://flowfuse.com'
+
+/**
+ * Docs frontmatter is written by docs-sync from the FlowFuse/flowfuse repo, so the shape
+ * is narrower than a hand-authored page: `navTitle` is always present, `title` comes from
+ * the H1 that @nuxt/content parses, a description exists only under `meta`, and `updated`
+ * carries a git commit date that is often the empty string.
+ *
+ * @param {{ navTitle?: string, title?: string, meta?: { description?: string }, updated?: string } | null} page
+ * @param {string} path the route path, used as-is for the canonical url
+ * @param {string[]} slugParts the `[...slug]` segments; empty on the docs root
+ */
+export function docsSeo (page, path, slugParts = []) {
+ // Bare, with no brand: the global title template appends "• {siteName}" to it.
+ const heading = page?.navTitle || page?.title || slugParts.at(-1) || 'Documentation'
+
+ return {
+ heading,
+ // Nested pages qualify the brand with "Docs"; the section root does not, because
+ // its own title already reads Documentation.
+ siteName: slugParts.length ? 'FlowFuse Docs' : 'FlowFuse',
+ // Undefined, not '': useSeoMeta drops the tag instead of emitting an empty one.
+ description: page?.meta?.description || undefined,
+ canonicalUrl: `${SITE_URL}${path}`,
+ // The heading alone, because the card template prints "FlowFuse / Docs" above it.
+ ogImage: { title: heading, section: 'Docs' },
+ dateModified: toIso(page?.updated) ?? undefined,
+ }
+}
diff --git a/nuxt/lib/docs-seo.test.mjs b/nuxt/lib/docs-seo.test.mjs
new file mode 100644
index 0000000000..59e2098b84
--- /dev/null
+++ b/nuxt/lib/docs-seo.test.mjs
@@ -0,0 +1,63 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import { docsSeo } from './docs-seo.mjs'
+
+const page = (fields = {}) => ({ ...fields })
+
+test('a page below /docs qualifies the brand the title template appends', () => {
+ const seo = docsSeo(page({ navTitle: 'Bill of Materials' }), '/docs/user/bill-of-materials', ['user', 'bill-of-materials'])
+ assert.equal(seo.heading, 'Bill of Materials')
+ assert.equal(seo.siteName, 'FlowFuse Docs')
+})
+
+test('the docs root leaves the brand unqualified', () => {
+ // Its own title already reads Documentation, so "FlowFuse Docs" would repeat it.
+ const seo = docsSeo(page({ navTitle: 'Documentation' }), '/docs', [])
+ assert.equal(seo.siteName, 'FlowFuse')
+})
+
+test('prefers navTitle over the heading @nuxt/content derives from the H1', () => {
+ const seo = docsSeo(page({ navTitle: 'Changing the Stack', title: 'Changing the stack of an instance' }), '/docs/user/changestack', ['user', 'changestack'])
+ assert.equal(seo.heading, 'Changing the Stack')
+})
+
+test('falls back to the H1 title, then the last slug segment, then Documentation', () => {
+ assert.equal(docsSeo(page({ title: 'Concepts' }), '/docs/user/concepts', ['user', 'concepts']).heading, 'Concepts')
+ assert.equal(docsSeo(page(), '/docs/user/concepts', ['user', 'concepts']).heading, 'concepts')
+ assert.equal(docsSeo(null, '/docs', []).heading, 'Documentation')
+})
+
+test('reads the description from nested meta, where docs-sync writes it', () => {
+ const seo = docsSeo(page({ meta: { description: 'Explore comprehensive documentation for FlowFuse.' } }), '/docs', [])
+ assert.equal(seo.description, 'Explore comprehensive documentation for FlowFuse.')
+})
+
+test('leaves the description undefined rather than empty when a page has none', () => {
+ // Most synced docs pages carry no description at all. An empty string would put
+ // on the page, which is worse than no tag.
+ assert.equal(docsSeo(page({ navTitle: 'Custom Hostnames' }), '/docs/user/custom-hostnames', ['user', 'custom-hostnames']).description, undefined)
+ assert.equal(docsSeo(page({ meta: { description: '' } }), '/docs', []).description, undefined)
+})
+
+test('canonical url is absolute on the production host', () => {
+ assert.equal(docsSeo(page(), '/docs/user/concepts', ['user', 'concepts']).canonicalUrl, 'https://flowfuse.com/docs/user/concepts')
+})
+
+test('the og image gets the bare heading, since the card already prints the section', () => {
+ const seo = docsSeo(page({ navTitle: 'Bill of Materials' }), '/docs/user/bill-of-materials', ['user', 'bill-of-materials'])
+ assert.deepEqual(seo.ogImage, { title: 'Bill of Materials', section: 'Docs' })
+})
+
+test('normalises the git commit stamp docs-sync writes into dateModified', () => {
+ const seo = docsSeo(page({ updated: '2026-08-11 15:07:47 +0200' }), '/docs', [])
+ assert.equal(seo.dateModified, '2026-08-11T15:07:47+02:00')
+})
+
+test('dateModified is undefined when the updated stamp is blank or unparseable', () => {
+ // The synced frontmatter always carries the key; the value is empty whenever
+ // git could not date the file.
+ assert.equal(docsSeo(page({ updated: '' }), '/docs', []).dateModified, undefined)
+ assert.equal(docsSeo(page({ updated: 'last tuesday' }), '/docs', []).dateModified, undefined)
+ assert.equal(docsSeo(page(), '/docs', []).dateModified, undefined)
+})
diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue
index 77b2cf4fd8..e6a450be90 100644
--- a/nuxt/pages/docs/[...slug].vue
+++ b/nuxt/pages/docs/[...slug].vue
@@ -1,5 +1,6 @@