diff --git a/.eleventyignore b/.eleventyignore
index 0223473529..f6ebfbdc16 100644
--- a/.eleventyignore
+++ b/.eleventyignore
@@ -2,3 +2,10 @@
# directly); 11ty must not also render them.
src/changelog/**/*.md
src/blog/**/*.md
+
+# Customer stories listing page is served by Nuxt (nuxt/pages/customer-stories/index.vue).
+# The individual story markdown files are NOT ignored here - they keep `permalink: false`
+# (src/customer-stories/customer-stories.json) instead, so `collections.stories` stays
+# populated for the few live 11ty pages that still read it (src/landing/tulip.njk,
+# src/node-red/index.njk, src/_includes/stories-block.njk) without 11ty writing output files.
+src/customer-stories.njk
diff --git a/nuxt/components/StoryTile.vue b/nuxt/components/StoryTile.vue
new file mode 100644
index 0000000000..6c6d4c4ef6
--- /dev/null
+++ b/nuxt/components/StoryTile.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ brand }}
+
{{ title }}
+
+
+
+
diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts
index 6cba515b45..3a302b77d9 100644
--- a/nuxt/content.config.ts
+++ b/nuxt/content.config.ts
@@ -161,6 +161,46 @@ export default defineContentConfig({
}).optional(),
})
}),
+ // Source files stay at src/customer-stories/ (11ty's historical location) rather than
+ // being copied into nuxt/content/ - keeps this migration a content-config-only change.
+ // The directory data file (src/customer-stories/customer-stories.json) sets
+ // `permalink: false` so 11ty keeps these in `collections.stories` (still read by a
+ // few live 11ty pages - src/landing/tulip.njk, src/node-red/index.njk,
+ // src/_includes/stories-block.njk) without also writing output files for them.
+ stories: defineCollection({
+ type: 'page',
+ source: {
+ cwd: join(__dirname, '../src'),
+ include: 'customer-stories/**/*.md',
+ },
+ schema: z.object({
+ description: z.string().optional(),
+ image: z.string().optional(),
+ date: z.coerce.date(),
+ // Card-badge logo shown on the listing/related-stories tiles - distinct from
+ // story.logo below (the sidebar logo on the detail page). Most stories leave
+ // this unset even when story.logo is set; that's existing 11ty behaviour, not
+ // a migration bug. Nullable because most story files write the key with no
+ // value ("logo:"), which YAML parses as null rather than omitting the key.
+ logo: z.string().nullable().optional(),
+ usecase: z.array(z.string()).optional(),
+ subtitle: z.string().optional(),
+ hubspot: z.object({
+ formId: z.string(),
+ }),
+ story: z.object({
+ brand: z.string(),
+ // Nullable for the same blank-key-in-YAML reason as top-level `logo` above.
+ url: z.string().nullable().optional(),
+ logo: z.string().optional(),
+ quote: z.string().optional(),
+ challenge: z.string(),
+ solution: z.string(),
+ products: z.array(z.string()),
+ results: z.array(z.string()),
+ }),
+ })
+ }),
ebooks: defineCollection({
type: 'page',
source: 'ebooks/*.md',
diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts
index b9303aa725..9ec6ed4072 100644
--- a/nuxt/nuxt.config.ts
+++ b/nuxt/nuxt.config.ts
@@ -70,6 +70,17 @@ function collectProductRoutes (dir: string): string[] {
return routes
}
+// Same idea as collectApplicationGuideRoutes above, for customer stories (flat
+// src/customer-stories/ dir, see content.config.ts).
+function collectStoryRoutes(dir: string): string[] {
+ const routes = ['/customer-stories/']
+ for (const file of readdirSync(dir)) {
+ if (!file.endsWith('.md')) continue
+ routes.push(`/customer-stories/${basename(file, '.md')}/`)
+ }
+ return routes
+}
+
// Same idea for blog posts. Each entry also carries its `tags` so the 13 tag-listing
// pages (and their own pagination, 19 entries/page) can be sized correctly, and its
// `authors` so the /blog/author/{slug}/ pages can be enumerated.
@@ -361,6 +372,7 @@ export default defineNuxtConfig({
...blogFiles.map(f => f.route),
...blogAuthorRoutes,
...collectHandbookRoutes(join(__dirname, 'content/handbook'), '/handbook'),
+ ...collectStoryRoutes(join(__dirname, '../src/customer-stories')),
]
})(),
crawlLinks: false,
diff --git a/nuxt/pages/customer-stories/[slug].vue b/nuxt/pages/customer-stories/[slug].vue
new file mode 100644
index 0000000000..a462b6f66e
--- /dev/null
+++ b/nuxt/pages/customer-stories/[slug].vue
@@ -0,0 +1,183 @@
+
+
+
+
+ No-one has written anything yet. Come back soon!
+
+
+
+
diff --git a/nuxt/public/images/stories/product-icons/ff-dashboard.svg b/nuxt/public/images/stories/product-icons/ff-dashboard.svg
new file mode 100644
index 0000000000..0a21920f8b
--- /dev/null
+++ b/nuxt/public/images/stories/product-icons/ff-dashboard.svg
@@ -0,0 +1,9 @@
+
diff --git a/nuxt/public/images/stories/product-icons/ff-device-agent.svg b/nuxt/public/images/stories/product-icons/ff-device-agent.svg
new file mode 100644
index 0000000000..6f987829be
--- /dev/null
+++ b/nuxt/public/images/stories/product-icons/ff-device-agent.svg
@@ -0,0 +1,7 @@
+
diff --git a/nuxt/public/images/stories/product-icons/ff-icon.svg b/nuxt/public/images/stories/product-icons/ff-icon.svg
new file mode 100644
index 0000000000..65c7be9bc3
--- /dev/null
+++ b/nuxt/public/images/stories/product-icons/ff-icon.svg
@@ -0,0 +1,9 @@
+
diff --git a/nuxt/public/images/stories/product-icons/ff-project-nodes.svg b/nuxt/public/images/stories/product-icons/ff-project-nodes.svg
new file mode 100644
index 0000000000..0415636ef3
--- /dev/null
+++ b/nuxt/public/images/stories/product-icons/ff-project-nodes.svg
@@ -0,0 +1,6 @@
+
diff --git a/nuxt/public/images/stories/product-icons/node-red.svg b/nuxt/public/images/stories/product-icons/node-red.svg
new file mode 100644
index 0000000000..59f3b9ab0d
--- /dev/null
+++ b/nuxt/public/images/stories/product-icons/node-red.svg
@@ -0,0 +1,9 @@
+
diff --git a/nuxt/redirects.ts b/nuxt/redirects.ts
index 209fa3f9c2..a560684b91 100644
--- a/nuxt/redirects.ts
+++ b/nuxt/redirects.ts
@@ -44,7 +44,6 @@ export const redirects: Record = {
'/blueprints/manufacturing/andon-task/': { redirect: { to: '/blueprints/manufacturing/andon-system/', statusCode: 301 } },
'/landing/technology-migration-1/': { redirect: { to: '/vs/kepware/', statusCode: 301 } },
'/landing/technology-migration-2/': { redirect: { to: '/vs/kepware/', statusCode: 301 } },
- '/customer-stories/scaling-manufacturing-automation-with-flowfuse/': { redirect: { to: '/customer-stories/', statusCode: 301 } },
'/blog/2025/10/the-ai-orchestation-hype/': { redirect: { to: '/blog/2025/10/the-ai-orchestration-hype/', statusCode: 301 } },
'/node-red/core-nodes/mqtt/': { redirect: { to: '/node-red/core-nodes/mqtt-in/', statusCode: 301 } },
'/blueprints/manufacturing/manufacturing-support-request/': { redirect: { to: '/blueprints/manufacturing/andon-system/', statusCode: 301 } },
diff --git a/nuxt/server/api/__sitemap__/content-urls.get.ts b/nuxt/server/api/__sitemap__/content-urls.get.ts
index 317bee115e..52c2527a2a 100644
--- a/nuxt/server/api/__sitemap__/content-urls.get.ts
+++ b/nuxt/server/api/__sitemap__/content-urls.get.ts
@@ -69,6 +69,11 @@ const CONTENT_SOURCES: ContentSource[] = [
lastmod: entry => stringField(entry, 'lastUpdated') ?? getGitLastmod(REPO_ROOT, `src/${entry.stem}.md`),
images: entry => [stringField(entry, 'image')].filter((path): path is string => Boolean(path)),
},
+ {
+ collection: 'stories',
+ fileRoot: 'src',
+ images: entry => [stringField(entry, 'image')].filter((path): path is string => Boolean(path)),
+ },
{
collection: 'ebooks',
fileRoot: 'nuxt/content',
diff --git a/nuxt/server/middleware/legacy.ts b/nuxt/server/middleware/legacy.ts
index 37f799bcd7..8b57aa189e 100644
--- a/nuxt/server/middleware/legacy.ts
+++ b/nuxt/server/middleware/legacy.ts
@@ -10,7 +10,7 @@ const NUXT_ROUTES = new Set(['/terms', '/privacy-policy', '/integrations', '/res
const NUXT_ROUTE_PREFIXES = ['/integrations/', '/raw/']
// Route prefixes handled by Nuxt (all paths starting with these are served by Nuxt).
-const NUXT_PREFIXES = ['/handbook', '/ebooks', '/whitepaper', '/pricing', '/docs', '/changelog', '/application-guide', '/blog', '/product']
+const NUXT_PREFIXES = ['/handbook', '/ebooks', '/whitepaper', '/pricing', '/docs', '/changelog', '/application-guide', '/blog', '/product', '/customer-stories']
export default defineEventHandler(async (event) => {
if (process.env.NODE_ENV !== 'development') return
diff --git a/src/_includes/stories-block.njk b/src/_includes/stories-block.njk
index c428d2bebd..51f62a61ef 100644
--- a/src/_includes/stories-block.njk
+++ b/src/_includes/stories-block.njk
@@ -2,6 +2,6 @@
{% from "stories/customer-story.njk" import storyTile %}
{%- for item in collections.stories | excludeCurrent(page.url) | shuffle | limit(3) -%}
- {{ storyTile(title=item.data.title, url=item.url, brand=item.data.story.brand, logo=item.data.logo, image=item.data.image) }}
+ {{ storyTile(title=item.data.title, url="/customer-stories/" ~ item.fileSlug ~ "/", brand=item.data.story.brand, logo=item.data.logo, image=item.data.image) }}
{%- endfor -%}
\ No newline at end of file
diff --git a/src/customer-stories/customer-stories.json b/src/customer-stories/customer-stories.json
index a6d71bd873..d94c082cd9 100644
--- a/src/customer-stories/customer-stories.json
+++ b/src/customer-stories/customer-stories.json
@@ -4,5 +4,6 @@
],
"layout": "layouts/story.njk",
"nav": "customer-stories",
- "searchTitle": "Customer Stories"
+ "searchTitle": "Customer Stories",
+ "permalink": false
}
diff --git a/src/customer-stories/stfi-future-of-textile-powered-by-node-red.md b/src/customer-stories/stfi-future-of-textile-powered-by-node-red.md
index a26dc7343b..81f8a7047b 100644
--- a/src/customer-stories/stfi-future-of-textile-powered-by-node-red.md
+++ b/src/customer-stories/stfi-future-of-textile-powered-by-node-red.md
@@ -25,23 +25,23 @@ Sächsisches Textilforschungsinstitut e.V. ([STFI](https://www.stfi.de/en/)) in
- (ⒸSTFI/D. Hanus)_
+ (ⒸSTFI/D. Hanus)_
The Model Factory consists of a number of different manufacturing applications, including for instance a product configurator, systems for job control, and [location based carrier management](https://youtu.be/yzK7vo6VpNU?si=YFhiaJTZpgmreU0c). Node-RED has been used for these applications to provide back end integration to different systems. For instance, the [Product Configurator](https://youtu.be/cgtHO1OVkV8?si=CmbDHoMzlkAS6Siu) uses Node-RED to manage the login information and to forward a product definition to the control system and/or the ERP system. The ability to forward the product definition to different systems makes the application flexible enough to support different types of applications.
-{data-zoomable}
+{data-zoomable}
_Figure 2: [Job Control](https://youtu.be/cgtHO1OVkV8?si=oTpLigbmfqlZ-8Bi&t=98) with State-Machine and OPC-UA-Connection to the machines like a [laser cutter](https://youtu.be/eUkZ8R1tNM4?si=uOwL-XGf0uKkXdVL) (ⒸSTFI)_
The [job control system](https://youtu.be/cgtHO1OVkV8?si=oTpLigbmfqlZ-8Bi&t=98) is a set of state machines built in Node-RED, see figure 2. Each state machine represents a different machine like a [mobile robot](https://youtu.be/Z_e6EcT2mQs?si=DfxQS0K16bcrpixi) and includes different communication nodes to the different pieces of equipment in the factory line. Node-RED was well suited for developing these state machines since state machines are well represented in flow based programming and also Node-RED provides support for the different communication protocols like OPC UA. Node-RED was also used to control the automated guided vehicle and the collaborative robot UR10e within the mobile robot, see figure 3.
-{data-zoomable}
+{data-zoomable}
_Figure 3: Within the [mobile Robot](https://youtu.be/Z_e6EcT2mQs?si=DfxQS0K16bcrpixi), control the Automated Guided Vehicle and the Universal Robot via OPC UA and Real-Time Data Exchange (ⒸSTFI)_
The Model Factory also consists of a number of different pieces of equipment. Unfortunately, not all the equipment was able to communicate with the standard protocol. Equipment like a laser cutter and a 3D printing machine are connected to the network with a proprietary protocol. The Model Factory team wanted to use OPC-UA as the standard protocol to allow for flexibility and future integration to other equipment. To overcome this problem, the research team used Node-RED to convert proprietary protocols to OPC-UA. Node-RED was deployed to the equipment desktop control machine so that it could convert the protocol inbound and outbound into OPC-UA.
With Node-RED, it is also possible to create a mobile dashboard for machines with just a few clicks and watch it within a [Hololens](https://youtu.be/T5BNb0-2D7o?feature=shared). Furthermore, systems for [process declaration input](https://youtu.be/rNAgmsZoh7g?t=243) (such as setup, cleaning, repair) can be implemented to increase transparency on machine states in production. Furthermore, based on Node-RED you can develop [Augmented Reality Application](https://youtu.be/jElLfvJUpH0?feature=shared&t=303) and system for [worker assistance](https://youtu.be/jElLfvJUpH0?feature=shared&t=348) with the help of cordova.
-
+
_Figure 4: Augmented Reality Application based on Node-RED and cordova (ⒸSTFI)_
Andreas Boehm, industrial engineer and lead researcher for the Model Factory comments on why they use Node-RED, “Node-RED is very easy to use and quick to develop. The low-code interface makes it possible for a non-professional developer to quickly start building applications. However, the ability to drop into the function node to write JavaScript makes it very powerful for a professional developer. Node-RED allows you to focus on developing the important things you want to create and not worry about a lot of the lower level infrastructure you need to connect data from different sources.”
diff --git a/src/landing/tulip.njk b/src/landing/tulip.njk
index d640b8ebae..73883197a2 100644
--- a/src/landing/tulip.njk
+++ b/src/landing/tulip.njk
@@ -181,7 +181,7 @@ hubspot:
{%- for item in collections.stories | sort(attribute='item.date') | reverse | limit(3) -%}