+ Certified nodes, backed by their authors and supported long-term.
+
+
+ Choosing a Node-RED node for production raises questions you can't always answer from a README. Is it actively maintained? Is it secure? Will the maintainer still be around in two years? Certified Nodes answer those questions.
+
+ This node has been certified by FlowFuse, ensuring it meets our standards for quality, security, and support.
+ Learn more about certified nodes.
+
+
+
+
+
+ Get Your Node Certified
+
+
Boost your node's credibility and reach by becoming FlowFuse Certified. Certification demonstrates quality, security, and reliability to the Node-RED community.
+ Explore the list of integrations and modules available for your Node-RED projects. Created (and curated) by FlowFuse and the Node-RED community.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ filtered.length }} Integrations
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/nuxt/server/api/integrations/[...id].get.ts b/nuxt/server/api/integrations/[...id].get.ts
new file mode 100644
index 0000000000..76fe20d02b
--- /dev/null
+++ b/nuxt/server/api/integrations/[...id].get.ts
@@ -0,0 +1,17 @@
+import { defineEventHandler, getRouterParam, createError } from 'h3'
+import { buildEnrichedIntegrations } from '../../utils/integrations-enrich'
+
+export default defineEventHandler(async (event) => {
+ const id = getRouterParam(event, 'id')
+ if (!id) {
+ throw createError({ statusCode: 400, statusMessage: 'Missing id' })
+ }
+
+ const all = await buildEnrichedIntegrations()
+ const node = all.find(n => n._id === id)
+ if (!node) {
+ throw createError({ statusCode: 404, statusMessage: `Integration not found: ${id}` })
+ }
+
+ return node
+})
diff --git a/nuxt/server/middleware/legacy.ts b/nuxt/server/middleware/legacy.ts
index 770d8a9037..fe847d7e42 100644
--- a/nuxt/server/middleware/legacy.ts
+++ b/nuxt/server/middleware/legacy.ts
@@ -1,8 +1,11 @@
-import { proxyRequest } from 'h3'
+import { defineEventHandler, proxyRequest } from 'h3'
// Routes that are handled by Nuxt pages (not proxied to 11ty).
// Extend this list as pages are migrated. Trailing slashes are matched automatically.
-const NUXT_ROUTES = new Set(['/terms', '/privacy-policy'])
+const NUXT_ROUTES = new Set(['/terms', '/privacy-policy', '/integrations'])
+
+// Path prefixes handled by Nuxt. Used for dynamic routes like /integrations/{id}.
+const NUXT_ROUTE_PREFIXES = ['/integrations/']
export default defineEventHandler(async (event) => {
if (process.env.NODE_ENV !== 'development') return
@@ -13,8 +16,10 @@ export default defineEventHandler(async (event) => {
if (path.startsWith('/_nuxt/') || path.startsWith('/api/') || path.startsWith('/__')) return
// Let Nuxt handle migrated pages (strip trailing slash and query string before matching)
- const normalised = path.split('?')[0].replace(/\/$/, '') || '/'
+ const pathWithoutQuery = path.split('?')[0]
+ const normalised = pathWithoutQuery.replace(/\/$/, '') || '/'
if (NUXT_ROUTES.has(normalised)) return
+ if (NUXT_ROUTE_PREFIXES.some(prefix => pathWithoutQuery.startsWith(prefix))) return
// Proxy everything else to the 11ty dev server
return proxyRequest(event, `http://localhost:8080${path}`)
diff --git a/nuxt/server/utils/build-cache.ts b/nuxt/server/utils/build-cache.ts
new file mode 100644
index 0000000000..5fc3a4fbef
--- /dev/null
+++ b/nuxt/server/utils/build-cache.ts
@@ -0,0 +1,74 @@
+/**
+ * Build-time HTTP cache.
+ *
+ * Mirrors the role of @11ty/eleventy-fetch in the old Eleventy data pipeline:
+ * persists GET responses to `.cache/integrations/` with per-call TTLs. Used by
+ * the integrations composable to avoid hammering npm / GitHub on every build
+ * and to survive GitHub's 60/hr anonymous rate limit.
+ *
+ * Built on `unstorage` (the same library Nitro uses internally) so the cache
+ * layer is idiomatic to Nuxt and trivially swappable to another driver later.
+ */
+import { createHash } from 'node:crypto'
+import { fileURLToPath } from 'node:url'
+import { dirname, resolve } from 'node:path'
+import { ofetch } from 'ofetch'
+import { createStorage } from 'unstorage'
+import fsDriver from 'unstorage/drivers/fs-lite'
+
+// Anchor the cache to the nuxt workspace so it's stable regardless of which
+// directory `nuxt generate` was launched from. Lands at nuxt/.cache/integrations/.
+const CACHE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../.cache/integrations')
+
+const storage = createStorage({
+ driver: fsDriver({ base: CACHE_DIR })
+})
+
+export type CachedResponseType = 'json' | 'text'
+
+export interface CachedFetchOptions {
+ /** Cache lifetime in milliseconds. Defaults to 1h. */
+ ttlMs?: number
+ /** Response parsing mode. */
+ type?: CachedResponseType
+ /** Extra HTTP headers (e.g. GitHub User-Agent). */
+ headers?: Record
+ /** Tag for cache key naming so different call-sites don't collide. */
+ namespace?: string
+}
+
+interface CacheEntry {
+ url: string
+ fetchedAt: number
+ data: T
+}
+
+function cacheKey (namespace: string, url: string): string {
+ const hash = createHash('sha1').update(url).digest('hex').slice(0, 16)
+ return `${namespace}:${hash}.json`
+}
+
+/**
+ * Fetch a URL with a disk-backed cache. Failed fetches are NOT cached, so a
+ * transient outage doesn't poison the cache.
+ */
+export async function cachedFetch (url: string, opts: CachedFetchOptions = {}): Promise {
+ const { ttlMs = 60 * 60 * 1000, type = 'json', headers, namespace = 'fetch' } = opts
+ const key = cacheKey(namespace, url)
+
+ const cached = await storage.getItem>(key)
+ if (cached && Date.now() - cached.fetchedAt < ttlMs) {
+ return cached.data
+ }
+
+ const data = await ofetch(url, {
+ headers,
+ // Retry transient network failures so a single blip doesn't kill the build.
+ // (Eleventy-fetch retried internally; ofetch defaults to 1.)
+ retry: 2,
+ retryDelay: 500,
+ parseResponse: type === 'text' ? ((t: string) => t) as never : undefined
+ })
+ await storage.setItem(key, { url, fetchedAt: Date.now(), data })
+ return data
+}
diff --git a/nuxt/server/utils/integrations-enrich.ts b/nuxt/server/utils/integrations-enrich.ts
new file mode 100644
index 0000000000..d6ec967a5d
--- /dev/null
+++ b/nuxt/server/utils/integrations-enrich.ts
@@ -0,0 +1,318 @@
+///
+import MarkdownIt from 'markdown-it'
+import MarkdownItAnchor from 'markdown-it-anchor'
+import MarkdownItFootnote from 'markdown-it-footnote'
+import MarkdownItAttrs from 'markdown-it-attrs'
+// Relative paths (not `~/` aliases) because this file is also imported by
+// nuxt.config.ts via jiti, which doesn't resolve Nuxt's path aliases.
+import type {
+ Integration,
+ IntegrationCatalogEntry,
+ IntegrationExample
+} from '../../types/integrations'
+import { INTEGRATIONS_API } from '../../types/integrations'
+import { cachedFetch } from './build-cache'
+
+const MAX_EXAMPLES_PER_NODE = 20
+
+// Cache durations match the previous Eleventy data pipeline:
+// - catalog/npm: change frequently (new versions daily) → 1h
+// - GitHub directory listings + flow file contents: change rarely → 6h
+const TTL_CATALOG_MS = 60 * 60 * 1000
+const TTL_NPM_MS = 60 * 60 * 1000
+const TTL_GITHUB_MS = 6 * 60 * 60 * 1000
+
+const GITHUB_HEADERS = {
+ 'User-Agent': 'FlowFuse-Website',
+ Accept: 'application/vnd.github+json'
+}
+
+// Markdown-it configured to match the Eleventy markdownLib (line ~1366 of .eleventy.js):
+// html: true, plus anchor + footnote + attrs. (The code-clipboard plugin is Eleventy-
+// runtime specific and intentionally not ported.)
+const md = new MarkdownIt({ html: true })
+ .use(MarkdownItAnchor, { permalink: MarkdownItAnchor.permalink.headerLink() })
+ .use(MarkdownItFootnote)
+ .use(MarkdownItAttrs)
+
+/**
+ * Build the enriched node list for detail pages.
+ *
+ * Mirrors src/_data/integrations.js: top-50 by weekly downloads + all
+ * ffCertified nodes, each augmented with README + GitHub examples.
+ *
+ * Memoised at module level so the ~67 detail pages share a single fetch
+ * during `nuxt generate`. On rejection the cache is cleared so dev sessions
+ * can recover without a full restart.
+ */
+let _enrichedCache: Promise | null = null
+export function buildEnrichedIntegrations (): Promise {
+ if (!_enrichedCache) {
+ _enrichedCache = _buildEnrichedIntegrations()
+ _enrichedCache.catch(() => { _enrichedCache = null })
+ }
+ return _enrichedCache
+}
+
+async function _buildEnrichedIntegrations (): Promise {
+ interface ApiResponse { catalogue: IntegrationCatalogEntry[] }
+ const data = await cachedFetch(INTEGRATIONS_API, {
+ ttlMs: TTL_CATALOG_MS,
+ namespace: 'catalog'
+ })
+ const catalogue = data.catalogue ?? []
+
+ // Top 50 by weekly downloads
+ const topNodes = [...catalogue]
+ .sort((a, b) => (b.downloads?.week ?? 0) - (a.downloads?.week ?? 0))
+ .slice(0, 50)
+
+ const topNodesMap = new Map(
+ topNodes.map(node => [node._id, node])
+ )
+
+ // Ensure all certified nodes are included even if outside the top 50
+ for (const node of catalogue) {
+ if (node.ffCertified && !topNodesMap.has(node._id)) {
+ topNodes.push(node)
+ topNodesMap.set(node._id, node)
+ }
+ }
+
+ const enriched = await Promise.all(topNodes.map(node => enrichNode(node)))
+ return enriched.sort(sortCertifiedThenDownloads)
+}
+
+function sortCertifiedThenDownloads (a: Integration, b: Integration): number {
+ if (a.ffCertified && !b.ffCertified) return -1
+ if (!a.ffCertified && b.ffCertified) return 1
+ return (b.downloads?.week ?? 0) - (a.downloads?.week ?? 0)
+}
+
+async function enrichNode (entry: IntegrationCatalogEntry): Promise {
+ const node: Integration = { ...entry }
+
+ if (!node.categories) node.categories = []
+
+ // Mirror Eleventy's "catalogue_" prefix + "catalogue" group
+ node.categories = node.categories.map(c => c.includes('catalogue') ? c : `catalogue_${c}`)
+ if (!node.categories.includes('catalogue')) node.categories.push('catalogue')
+
+ try {
+ const npm = await cachedFetch(
+ `https://registry.npmjs.org/${node._id}`,
+ { ttlMs: TTL_NPM_MS, namespace: 'npm' }
+ )
+
+ node.author = npm.author ?? node.author
+ node.maintainers = npm.maintainers ?? []
+ node.homepage = npm.homepage
+ node.bugs = npm.bugs
+ node.repository = npm.repository
+ node.time = npm.time
+ node.lastUpdated = npm.time?.modified ?? npm.time?.[node.version]
+ node.created = npm.time?.created
+ node.license = npm.license ?? npm.versions?.[node.version]?.license
+
+ if (npm.repository?.url) {
+ const repoUrl = cleanGitUrl(npm.repository.url)
+ const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/)
+ if (match && match[1] && match[2]) {
+ const owner = match[1]
+ const repo = match[2]
+ node.githubOwner = owner
+ node.githubRepo = repo
+ node.examples = await fetchExamples(owner, repo)
+ }
+ }
+
+ if (npm.readme) {
+ const withAbsoluteAssets = rewriteRelativeAssets(npm.readme, node.githubOwner, node.githubRepo)
+ const rendered = md.render(withAbsoluteAssets)
+ const linked = rewriteIntegrationLinks(rendered, node)
+ // Strip /
-{% endif %}
-{% endfor %}
-
-
-
-{% endif %}
diff --git a/src/integrations/index.njk b/src/integrations/index.njk
deleted file mode 100644
index b597bae11a..0000000000
--- a/src/integrations/index.njk
+++ /dev/null
@@ -1,471 +0,0 @@
----
-layout: default
-sitemapPriority: 0.9
-title: Integrations
-description:
- Explore the list of integrations and modules available for your Node-RED projects. Created (and curated) by FlowFuse and the Node-RED community.
-meta:
- title: Integrations
----
-
-{% extends 'layouts/catalog.njk' %}
-
-{% block title %}
-Integrations
-{% endblock %}
-
-{% block description %}
-Explore the list of integrations and modules available for your Node-RED projects. Created (and curated) by FlowFuse and the Node-RED community.
-{% endblock %}
-
-{% block content %}
-
-
-
-
-
- FlowFuse Certified
-
Certified nodes, backed by their authors and supported long-term.
-
- Choosing a Node-RED node for production raises questions you can't always answer from a README. Is it actively maintained? Is it secure? Will the maintainer still be around in two years? Certified Nodes answer those questions.
-