@42flowsdotcom/nuxt-content
v0.10.1
Published
Receive 42flows-generated MDC content in your Nuxt Content site
Maintainers
Readme
@42flowsdotcom/nuxt-content
Receive 42flows-generated MDC content in your Nuxt Content site.
One module. Four modes. Zero per-customer adaptation. You pick the contract that fits your existing app; we honor it.
Install
npm install @42flowsdotcom/nuxt-content @nuxt/contentIn nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@nuxt/content', '@42flowsdotcom/nuxt-content'],
})In content.config.ts (at repo root):
import { defineContentConfig, defineCollection } from '@nuxt/content'
export default defineContentConfig({
collections: {
blog: defineCollection({
type: 'page',
source: 'blog/**/*.md',
}),
},
})That's it. By default the module registers /blog/:slug and renders any markdown 42flows delivers to content/blog/.
Built-in discovery + presentation (v0.10.0+)
Every mode ships, zero-config:
- Per-article SEO head — canonical, meta description, full Open Graph (incl
og:url), Twitter card, and an Article JSON-LD fallback (deduped when the article body already embeds schema). No more homepage tags leaking into article heads. /sitemap-42flows.xml— a sitemap for the delivered collection. When you run@nuxtjs/sitemap, delivered URLs are also merged into YOUR sitemap automatically./llms.txt— generated from the collection for AI answer engines. Your own staticpublic/llms.txtalways wins.- Index page at
<blogRoutePrefix>— lists the collection with CollectionPage JSON-LD. Your own file-based index page always wins. - Theme-agnostic styling — article typography and all MDC components derive their colors
from your theme's
currentColor(mid-tone accents + transparent tints), so delivered content is legible on light AND dark themes with no configuration and no Tailwind dependency. - Native hooks —
flows42.layout: '<name>'renders the auto-page inside your named layout;useFlows42List()lets your own listing pages merge delivered articles;useFlows42Page({ seo: true })adds the full SEO head in headless mode.
Disable individually with flows42: { sitemap: false, llmsTxt: false, indexPage: false }.
Pick a rendering mode
You get four contracts. Pick one based on what your app already has.
| Mode | Use when | What happens |
|---|---|---|
| auto (default) | Greenfield app, no existing blog page | Module registers /blog/:slug. Renders 42flows content out-of-the-box. |
| takeover | You have an old pages/blog/[slug].vue you want replaced by ours | Module replaces your page with 42flows auto-page. Your file stays on disk but is unused. |
| headless | You have a pages/blog/[slug].vue with custom layout / SEO / schema you want to keep | Module skips auto-page. Your page calls useFlows42Page() to render 42flows content with your chrome. |
| dual-source | You have an existing DB-backed blog and want 42flows to coexist alongside it | Same as headless, semantically clarified. Your page tries useFlows42Page() first, falls back to your existing DB. |
Mode 1: auto (default)
export default defineNuxtConfig({
modules: ['@nuxt/content', '@42flowsdotcom/nuxt-content'],
// No flows42 block needed — auto is default
})If pages/blog/[slug].vue exists in your repo, Nuxt's file-based router prefers it over our auto-page. You'll see a build warning telling you to pick takeover, headless, or delete the page.
Mode 2: takeover
export default defineNuxtConfig({
modules: ['@nuxt/content', '@42flowsdotcom/nuxt-content'],
flows42: {
takeover: true,
},
})Forces our auto-page to win even if you have a colliding file. Your pages/blog/[slug].vue stays in the repo (we don't delete files) but won't render. Use when you want 42flows to fully own /blog/* and you don't want to manually delete an old template.
Mode 3: headless
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/content', '@42flowsdotcom/nuxt-content'],
flows42: {
mode: 'headless',
},
})<!-- pages/blog/[slug].vue (you keep your own page) -->
<script setup lang="ts">
const { page } = await useFlows42Page()
if (!page.value) throw createError({ statusCode: 404, fatal: true })
useSeoMeta({
title: (page.value as any).meta_title,
description: (page.value as any).meta_description,
})
</script>
<template>
<article class="my-custom-layout">
<header>
<h1>{{ (page as any).title }}</h1>
<p class="byline">Published {{ (page as any).published_at }}</p>
</header>
<ContentRenderer :value="page" />
<MyCustomFooter />
</article>
</template>useFlows42Page() is auto-imported. By default it queries the blog collection at the current route's path. Override with useFlows42Page({ collection: 'posts', path: '/posts/my-slug' }).
The composable also emits the 42flows delivery marker in <head> — required so the 42flows backend can verify the URL serves your delivered content.
Mode 4: dual-source (coexistence with existing DB blog)
For customers with an existing DB-backed blog (admin UI, SEO equity, hundreds of posts) who want 42flows to coexist rather than replace.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/content', '@42flowsdotcom/nuxt-content'],
flows42: {
mode: 'dual-source',
},
})<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
// Try 42flows first
const { page: flows42Page } = await useFlows42Page()
// Fall back to your existing data source if 42flows didn't have it
const { data: dbPost } = await useFetch(
flows42Page.value ? null : `/api/blog/posts/${route.params.slug}`,
)
const post = computed(() => flows42Page.value || dbPost.value)
if (!post.value) throw createError({ statusCode: 404, fatal: true })
</script>
<template>
<article class="my-blog-layout">
<ContentRenderer v-if="flows42Page" :value="flows42Page" />
<LegacyBlogPost v-else :post="dbPost" />
</article>
</template>42flows-delivered articles render via ContentRenderer. Existing DB articles continue rendering through your existing component. Both share /blog/:slug. URL ownership is decided per-slug by which source has the article.
Migrating an existing blog page to headless mode
You probably have something like this today:
<!-- BEFORE: pages/blog/[slug].vue -->
<script setup>
const route = useRoute()
const { data: post } = await useFetch(`/api/blog/posts/${route.params.slug}`)
if (!post.value) throw createError({ statusCode: 404, fatal: true })
useSeoMeta({ title: post.value.title, description: post.value.metaDescription })
</script>
<template>
<article class="my-blog-layout">
<h1>{{ post.title }}</h1>
<div v-html="post.content" />
</article>
</template>After moving to 42flows + headless mode:
<!-- AFTER: pages/blog/[slug].vue -->
<script setup>
const { page } = await useFlows42Page()
if (!page.value) throw createError({ statusCode: 404, fatal: true })
useSeoMeta({
title: (page.value as any).meta_title,
description: (page.value as any).meta_description,
})
</script>
<template>
<article class="my-blog-layout">
<h1>{{ (page as any).title }}</h1>
<ContentRenderer :value="page" />
</article>
</template>Two changes: replace the useFetch(...) line with useFlows42Page(), replace the v-html body with <ContentRenderer>. Your wrapper, classes, layout, and SEO logic don't move.
Module options reference
flows42: {
// Mode (per modes table above) — default 'auto'
mode: 'auto' | 'headless' | 'dual-source',
// In 'auto' mode only: replace customer's colliding [slug].vue with ours
takeover: false,
// Where 42flows writes .md files (default 'content/blog')
contentPath: 'content/blog',
// Public URL prefix for blog post pages (default '/blog')
blogRoutePrefix: '/blog',
// Nuxt Content v3 collection name to query (default 'blog')
collectionName: 'blog',
// Receiver endpoint prefix (default '/api/42flows')
routePrefix: '/api/42flows',
// Register the 5 built-in MDC components (default true)
components: true,
// Show "Powered by 42flows" footer (default true on Starter; false on Premium)
showPoweredBy: true,
// Shared secret for receiver POST auth (falls back to FLOWS42_API_KEY env var)
apiKey: '',
// (Legacy) skip auto-page registration. Prefer mode: 'headless' instead.
autoPage: true,
}Built-in MDC components
The module auto-registers these globally. They render 42flows MDC content without any extra setup:
| Component | MDC Syntax | Purpose |
|---|---|---|
| HookStatistic | ::hook-statistic | Opening callout banner |
| QuickAnswer | ::quick-answer | "In Brief" answer box at top of article |
| SummarizeButtons | ::summarize-buttons | "Summarize with ChatGPT / Claude / Perplexity" row |
| Faq / FaqItem | ::faq / ::faq-item | FAQ section |
| Sources | ::sources | Citations list |
| ComparisonTable | ::comparison-table | Comparison flow output |
| StrategyList | ::strategy-list | Strategy flow output |
| Definition | ::definition | Concept article opener |
| ProblemSolution | ::problem-solution | Problem-solution article opener |
| OriginalInsight | ::original-insight | Original-insight callout |
| JsonLd | ::json-ld | Article + FAQ schema.org markup |
| PoweredBy | ::powered-by | "Content by 42flows" attribution footer |
To override any component's styling, create a file with the same name in your project's components/content/ directory. Nuxt's component resolution prefers yours.
To disable all built-in components: flows42: { components: false }.
Server endpoints
| Route | Method | Purpose |
|---|---|---|
| /api/42flows/content | POST | Receive MDC content + write .md file. Auth via Bearer ${flows42.apiKey}. |
| /api/42flows/health | GET | Health check for the 42flows backend's connection test. |
Direct-receiver mode (POST endpoint) is used when you connect to 42flows without GitHub. For GitHub delivery, the backend commits markdown directly to your repo and these endpoints aren't called.
Compatibility
- Nuxt 3 + Nuxt 4
@nuxt/contentv3 (v2 is not supported)
License
MIT
