ADR-002
ID:ADR-002Status:accepted

Framework Readiness Investigation: Next.js, Eleventy, Nuxt

Context

Refrakt targets SvelteKit as its only framework. The Astro readiness investigation confirmed that the architecture is ready for a second framework. This document extends the analysis to three more: Next.js (React), Eleventy (vanilla SSG), and Nuxt (Vue) — covering the three largest remaining ecosystems for content-heavy sites.

Verdicts

FrameworkVerdictDifficultyEstimated New Code
Next.js (App Router + RSC)ReadyLow~170 lines
Eleventy (11ty v3)ReadyLow~145 lines
Nuxt (Vue 3 + Nitro)ReadyLow-Medium~220 lines

No blockers for any framework. No core architectural changes needed. Compare ~170-220 lines of new adapter code to ~15,000+ lines of framework-agnostic code being reused.


What's Already Framework-Agnostic (reusable by all three)

Same table as the Astro investigation — nothing has changed:

PackageStatusKey Exports
@refrakt-md/typesFully agnosticSerializedTag, RendererNode, ThemeConfig, RefraktConfig
@refrakt-md/transformFully agnosticcreateTransform(), layoutTransform(), renderToHtml()
@refrakt-md/runesFully agnostic52 rune schemas, Markdoc tag specs
@refrakt-md/contentFully agnosticloadContent(), ContentTree, Router, layout cascade
@refrakt-md/behaviorsFully agnosticinitRuneBehaviors(), initLayoutBehaviors(), registerElements(), 4 web components, RfContext
@refrakt-md/highlightFully agnosticShiki-based syntax highlighting transform
@refrakt-md/theme-baseCore agnosticbaseConfig (74 rune configs), defaultLayout, docsLayout, blogArticleLayout
@refrakt-md/lumina CSSFully agnosticDesign tokens (base.css), 48 per-rune CSS files, index.css bundle

Key architectural wins already in place

  1. Empty component registry — All 74 runes render through identity transform. Interactive runes use web components or vanilla JS behaviors. Zero Svelte components needed for rendering.

  2. renderToHtml() exists (packages/transform/src/html.ts) — A pure-JS function that renders SerializedTag → HTML string. Handles void elements, data-codeblock raw HTML, attribute escaping. This is the rendering strategy for all three frameworks.

  3. Layout transform implemented (packages/transform/src/layout.ts) — Declarative LayoutConfig objects replace Svelte layout components. layoutTransform() produces a complete page tree — any framework can render it.

  4. Behaviors are vanilla JSinitRuneBehaviors() discovers elements by [data-rune] attributes, wires DOM event listeners, returns cleanup functions. No framework lifecycle required.

  5. Web components are standard custom elementsrf-diagram, rf-nav, rf-map, rf-sandbox use connectedCallback, read from RfContext static properties. Work in any HTML environment.


Shared Prerequisites (all frameworks)

Before building any adapter, extract two utilities from @refrakt-md/svelte that are already framework-agnostic:

1. serialize()@refrakt-md/transform

Currently at packages/svelte/src/serialize.ts (24 lines). Converts Markdoc Tag class instances to plain SerializedTag objects. Zero Svelte imports — depends only on @markdoc/markdoc (already a transform dependency).

Move to packages/transform/src/serialize.ts. Re-export from @refrakt-md/svelte for backward compatibility.

2. matchRouteRule()@refrakt-md/transform

Currently at packages/svelte/src/route-rules.ts (31 lines). Pure pattern matching against RouteRule[]. Zero Svelte imports — depends only on RouteRule type from @refrakt-md/types.

Move to packages/transform/src/route-rules.ts. Re-export from @refrakt-md/svelte for backward compatibility.

Estimated effort: ~20 lines of re-export glue


Next.js (App Router + React Server Components)

Verdict: Ready

The architecture maps naturally onto Next.js App Router. The key insight: since renderToHtml() exists and the component registry is empty, React Server Components can render all content as static HTML with zero hydration cost. No recursive React component tree needed.

1. Rendering: RSC + renderToHtml()

React Server Components run on the server only and can call any pure function. renderToHtml() is synchronous, pure, and produces complete HTML. The output is injected via dangerouslySetInnerHTML — no hydration, no client JS for content.

This completely sidesteps React's well-known issues with custom elements, since renderToHtml() outputs <rf-diagram>, <rf-nav>, etc. as raw HTML strings that React never processes as components.

// packages/next/src/RefraktContent.tsx (Server Component — no 'use client')
import { renderToHtml } from '@refrakt-md/transform';
import type { RendererNode } from '@refrakt-md/types';

export function RefraktContent({ tree }: { tree: RendererNode }) {
  const html = renderToHtml(tree);
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Alternative: A recursive Renderer.tsx using React.createElement() (~80 lines, equivalent to Renderer.svelte). Only needed if the component registry becomes non-empty in the future.

2. App Router Integration

Catch-all route at app/[...slug]/page.tsx. Content loading in the server component via loadContent().

// app/[...slug]/page.tsx
import { loadContent } from '@refrakt-md/content';
import { createTransform, layoutTransform, renderToHtml } from '@refrakt-md/transform';
import { serializeTree } from '@refrakt-md/transform';
import { baseConfig, defaultLayout } from '@refrakt-md/theme-base';
import { RefraktContent } from '@refrakt-md/next';
import { BehaviorInit } from '@refrakt-md/next/client';

export async function generateStaticParams() {
  const site = await loadContent('./content');
  return site.pages
    .filter(p => !p.draft)
    .map(p => ({ slug: p.url.split('/').filter(Boolean) }));
}

export default async function Page({ params }: { params: { slug: string[] } }) {
  const site = await loadContent('./content');
  const url = '/' + params.slug.join('/');
  const page = site.pages.find(p => p.url === url);

  const transform = createTransform(baseConfig);
  const tree = layoutTransform(defaultLayout, page, 'rf');

  return (
    <>
      <RefraktContent tree={tree} />
      <BehaviorInit pages={site.pages} currentUrl={url} />
    </>
  );
}

3. SEO: generateMetadata()

Maps directly to the SEO data already produced by the content system. Replaces <svelte:head> in ThemeShell.svelte (lines 94-122).

// app/[...slug]/page.tsx (alongside the component)
import type { Metadata } from 'next';

export async function generateMetadata({ params }): Promise<Metadata> {
  const page = /* load page */;
  return {
    title: page.seo?.og.title ?? page.title,
    description: page.seo?.og.description ?? page.description,
    openGraph: {
      title: page.seo?.og.title,
      description: page.seo?.og.description,
      images: page.seo?.og.image ? [page.seo.og.image] : undefined,
      url: page.seo?.og.url,
      type: page.seo?.og.type,
    },
  };
}

4. Behavior Initialization

Behaviors and web components need client-side DOM access. A thin client component handles this:

// packages/next/src/BehaviorInit.tsx
'use client';
import { useEffect } from 'react';
import { initRuneBehaviors, initLayoutBehaviors, registerElements, RfContext } from '@refrakt-md/behaviors';

export function BehaviorInit({ pages, currentUrl }: { pages: any[]; currentUrl: string }) {
  useEffect(() => {
    RfContext.pages = pages;
    RfContext.currentUrl = currentUrl;
    registerElements();
    const cleanupRunes = initRuneBehaviors();
    const cleanupLayout = initLayoutBehaviors();
    return () => { cleanupRunes(); cleanupLayout(); };
  }, [currentUrl]);

  return null;
}

5. CSS Injection

Import Lumina CSS in the root layout. No virtual modules needed — Next.js handles global CSS imports natively.

// app/layout.tsx
import '@refrakt-md/lumina';  // index.css with tokens + all rune styles

export default function RootLayout({ children }) {
  return <html><body>{children}</body></html>;
}

CSS tree-shaking (importing only CSS for used runes) can be added in Phase 2 as a custom Webpack/Turbopack plugin, reusing the analyzeRuneUsage() logic from the SvelteKit plugin.

6. Content HMR

The SvelteKit plugin uses Vite's server.watcher API (packages/sveltekit/src/content-hmr.ts). Next.js doesn't expose Vite.

Phase 1: No custom HMR — rely on Next.js dev server. Changes to content files trigger re-rendering on next request. Acceptable for initial implementation.

Phase 2: Custom Webpack plugin watching the content directory, invalidating page modules on .md file changes. Same logic, different watcher API.

7. ISR / On-Demand Revalidation

For sites that update content without full rebuilds:

export const revalidate = 3600; // re-render every hour
// or on-demand via API route:
// revalidatePath('/docs/getting-started');

loadContent() re-reads the content directory on each call, so ISR "just works".

8. Package Shape

packages/next/
├── src/
   ├── RefraktContent.tsx     # Server Component — renderToHtml wrapper
   ├── BehaviorInit.tsx       # Client Component — behavior + web component init
   ├── metadata.ts            # generateMetadata() helper
   ├── loader.ts              # loadContent wrapper for Next.js patterns
   └── index.ts               # Public exports
├── package.json               # peer dep: next@^14.0.0 || ^15.0.0
└── tsconfig.json

Estimated New Code

ComponentLinesComplexity
RefraktContent (RSC)~15Trivial
BehaviorInit (client)~25Low
metadata.ts helper~30Low
loader.ts utility~40Low
Lumina/next adapter~25Trivial
Types~35Trivial
Total~170Low

Eleventy (11ty v3)

Verdict: Ready

Eleventy is architecturally the most different — no Vite, no bundler, template-driven. But renderToHtml() was practically designed for template engines. This is the simplest integration of all three frameworks.

1. Integration Model: Global Data + Pagination

Eleventy's data cascade is the natural integration point. A global data file calls the entire refrakt pipeline at build time:

// _data/refrakt.js
import { loadContent } from '@refrakt-md/content';
import { createTransform, layoutTransform, renderToHtml } from '@refrakt-md/transform';
import { matchRouteRule } from '@refrakt-md/transform';
import { baseConfig, defaultLayout, docsLayout } from '@refrakt-md/theme-base';

const layouts = { default: defaultLayout, docs: docsLayout };
const routeRules = [
  { pattern: 'docs/**', layout: 'docs' },
  { pattern: '**', layout: 'default' },
];

export default async function () {
  const site = await loadContent('./content');
  const transform = createTransform(baseConfig);

  return {
    pages: site.pages.filter(p => !p.draft).map(page => {
      const layoutName = matchRouteRule(page.url, routeRules);
      const tree = layoutTransform(layouts[layoutName], page, 'rf');
      return {
        url: page.url,
        title: page.title,
        description: page.description,
        seo: page.seo,
        html: renderToHtml(tree),
        pages: site.pages.map(p => ({ url: p.url, title: p.title })),
      };
    }),
  };
}

Eleventy pagination creates one page per content item:

---
pagination:
  data: refrakt.pages
  size: 1
  alias: page
permalink: "{{ page.url }}/index.html"
layout: base.njk
---

2. Rendering: Template Injection

The template receives pre-rendered HTML from the data file. No recursive component, no framework renderer:

{# _includes/base.njk #}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>{{ page.seo.og.title or page.title }}</title>
  {% if page.description %}
  <meta name="description" content="{{ page.description }}" />
  {% endif %}
  <link rel="stylesheet" href="/css/refrakt.css" />
</head>
<body>
  {{ page.html | safe }}
  <script type="module">
    import { initRuneBehaviors, initLayoutBehaviors, registerElements, RfContext }
      from '/js/behaviors.js';
    RfContext.pages = {{ page.pages | dump | safe }};
    RfContext.currentUrl = '{{ page.url }}';
    registerElements();
    initRuneBehaviors();
    initLayoutBehaviors();
  </script>
</body>
</html>

3. Layout System: Complementary

Eleventy's layout chaining and refrakt's layoutTransform() operate at different levels:

  • Refrakt layouts produce the inner page structure — sidebar, content area, TOC, breadcrumbs, mobile panels. This happens at build time in the data file.
  • Eleventy layouts provide the outer HTML shell — <html>, <head>, <body>, global scripts, analytics.

No conflict. Eleventy wraps refrakt's output.

4. CSS Injection

No bundler means no CSS imports. Use Eleventy's passthrough file copy:

// eleventy.config.js
export default function (eleventyConfig) {
  eleventyConfig.addPassthroughCopy({
    'node_modules/@refrakt-md/lumina/index.css': 'css/refrakt.css',
  });
}

Referenced via <link rel="stylesheet" href="/css/refrakt.css"> in the base template.

CSS tree-shaking is a Phase 2 optimization — could use a post-build script that analyzes used rune types and generates a minimal CSS bundle.

5. Content Loading: Refrakt vs Eleventy's Markdown

Potential confusion: Eleventy normally processes .md files through its Markdown-it pipeline. Using refrakt's loadContent() bypasses this entirely.

Resolution: Content lives in a directory that Eleventy does not discover as templates. The data file approach means Eleventy treats content as pure data, not as templates to render. Refrakt handles all Markdoc parsing and transformation.

6. No HMR — Not Needed

Eleventy with --serve uses BrowserSync live reload. When content files change (if watched), Eleventy triggers a full rebuild. loadContent() re-reads the directory on each call.

For a site with ~100 pages, Eleventy v3 rebuilds in under 2 seconds. No custom HMR infrastructure needed.

7. Eleventy 3.0 Compatibility

Eleventy 3.0 is ESM-native. All refrakt packages use "type": "module". No CJS compatibility issues. The export default function pattern in data files and config works natively.

8. Behavior Initialization

Static MPA model — each page load is a fresh DOM. No cleanup needed:

<script type="module">
  import { initRuneBehaviors, initLayoutBehaviors, registerElements, RfContext }
    from '@refrakt-md/behaviors';
  // Set context for web components
  RfContext.pages = /* injected page data */;
  RfContext.currentUrl = window.location.pathname;
  registerElements();
  initRuneBehaviors();
  initLayoutBehaviors();
</script>

The @refrakt-md/behaviors package would need to be either bundled (via a separate build step) or served from node_modules. The passthrough copy approach works, or a simple esbuild step can bundle the behaviors for the browser.

9. Package Shape

packages/eleventy/
├── src/
   ├── plugin.js              # Eleventy plugin (registers data, passthrough copy)
   ├── data.js                # Global data file factory (loadContent + transform)
   └── index.js               # Public exports
├── templates/
   └── base.njk               # Example base template
├── package.json               # peer dep: @11ty/eleventy@^3.0.0
└── README.md

Estimated New Code

ComponentLinesComplexity
Global data file / factory~50Low
Eleventy plugin (config)~25Low
Base template (example)~40Low
Client script (behaviors)~15Trivial
Lumina/11ty adapter~15Trivial
Total~145Low

Nuxt (Vue 3 + Nitro)

Verdict: Ready

Nuxt is the closest to SvelteKit — Vite-based, file-system routing, SSR with Nitro. The SvelteKit Vite plugin can be substantially reused. This has the most code to write but the highest code sharing.

1. Vite Plugin Reuse

The SvelteKit Vite plugin (packages/sveltekit/src/plugin.ts) is mostly framework-agnostic:

Plugin hookFramework-specific?Nuxt reuse
config() — SSR noExternal listNo — same packagesDirect reuse
buildStart() — CSS tree-shakingNo — pure analysisDirect reuse
resolveId() / load() — virtual modulesPartially — import paths reference svelteAdapt imports for Vue
configureServer() — content HMRNo — Vite watcher APIDirect reuse

The virtual module generation (packages/sveltekit/src/virtual-modules.ts) needs adaptation: the virtual:refrakt/theme module currently imports from lumina/svelte — for Nuxt it would import from lumina/nuxt. The virtual:refrakt/tokens and virtual:refrakt/config modules are already framework-agnostic.

Content HMR (packages/sveltekit/src/content-hmr.ts, 26 lines) can be used as-is — it's pure Vite server.watcher API.

2. Nuxt Module

Nuxt modules are the idiomatic integration point — more powerful than raw Vite plugins:

// packages/nuxt/src/module.ts
import { defineNuxtModule, addVitePlugin, addImports } from '@nuxt/kit';
import { refrakt as refraktVitePlugin } from './vite-plugin';

export default defineNuxtModule({
  meta: { name: '@refrakt-md/nuxt', configKey: 'refrakt' },
  defaults: { contentDir: './content' },

  setup(options, nuxt) {
    // Register adapted Vite plugin
    addVitePlugin(refraktVitePlugin(options));

    // Inject Lumina CSS
    nuxt.options.css.push('@refrakt-md/lumina');

    // Ensure refrakt packages are transpiled by Nitro
    nuxt.options.build.transpile.push(
      '@refrakt-md/transform', '@refrakt-md/content',
      '@refrakt-md/runes', '@refrakt-md/types',
      '@refrakt-md/theme-base', '@refrakt-md/behaviors',
    );

    // Auto-import composables
    addImports([
      { name: 'useRefraktMeta', from: '@refrakt-md/nuxt/composables' },
    ]);

    // Tell Vue compiler to treat rf-* as custom elements
    nuxt.options.vue.compilerOptions.isCustomElement =
      (tag) => tag.startsWith('rf-');
  },
});

Configuration in nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['@refrakt-md/nuxt'],
  refrakt: { contentDir: './content' },
});

3. Rendering: v-html + renderToHtml()

Same strategy as Next.js and Astro — renderToHtml() produces complete HTML:

<!-- packages/nuxt/src/RefraktContent.vue -->
<script setup lang="ts">
import { renderToHtml } from '@refrakt-md/transform';
import type { RendererNode } from '@refrakt-md/types';

const props = defineProps<{ tree: RendererNode }>();
const html = computed(() => renderToHtml(props.tree));
</script>

<template>
  <div v-html="html" />
</template>

Alternative: Recursive Renderer.vue using <component :is="tag.name"> (~80 lines). Only needed if the component registry becomes non-empty.

4. SEO: useRefraktMeta() Composable

// packages/nuxt/src/composables/useRefraktMeta.ts
import { useHead } from '#imports';

interface PageSeo {
  og: { title?: string; description?: string; image?: string; type?: string; url?: string };
  jsonLd: object[];
}

export function useRefraktMeta(page: { title: string; description?: string; seo?: PageSeo }) {
  useHead({
    title: page.seo?.og.title ?? page.title,
    meta: [
      { name: 'description', content: page.seo?.og.description ?? page.description },
      { property: 'og:title', content: page.seo?.og.title },
      { property: 'og:description', content: page.seo?.og.description },
      ...(page.seo?.og.image ? [
        { property: 'og:image', content: page.seo.og.image },
        { name: 'twitter:card', content: 'summary_large_image' },
      ] : []),
      ...(page.seo?.og.url ? [{ property: 'og:url', content: page.seo.og.url }] : []),
      ...(page.seo?.og.type ? [{ property: 'og:type', content: page.seo.og.type }] : []),
    ].filter(m => m.content),
    script: page.seo?.jsonLd.map(schema => ({
      type: 'application/ld+json',
      innerHTML: JSON.stringify(schema),
    })) ?? [],
  });
}

5. Content Loading

<!-- pages/[...slug].vue -->
<script setup lang="ts">
import { loadContent } from '@refrakt-md/content';
import { createTransform, layoutTransform } from '@refrakt-md/transform';
import { matchRouteRule } from '@refrakt-md/transform';
import { baseConfig, defaultLayout, docsLayout } from '@refrakt-md/theme-base';
import RefraktContent from '@refrakt-md/nuxt/RefraktContent.vue';
import { useRefraktMeta } from '#imports';

const route = useRoute();
const layouts = { default: defaultLayout, docs: docsLayout };
const routeRules = [
  { pattern: 'docs/**', layout: 'docs' },
  { pattern: '**', layout: 'default' },
];

const { data: page } = await useAsyncData('refrakt-page', async () => {
  const site = await loadContent('./content');
  const url = '/' + (route.params.slug as string[]).join('/');
  const pageData = site.pages.find(p => p.url === url);
  const layoutName = matchRouteRule(url, routeRules);
  const tree = layoutTransform(layouts[layoutName], pageData, 'rf');
  return { ...pageData, tree, allPages: site.pages };
});

useRefraktMeta(page.value);
</script>

<template>
  <RefraktContent v-if="page" :tree="page.tree" />
</template>

6. Behavior Initialization

<!-- In the page component or a wrapper -->
<script setup lang="ts">
import { onMounted, onBeforeUnmount, watch } from 'vue';
import { initRuneBehaviors, initLayoutBehaviors, registerElements, RfContext }
  from '@refrakt-md/behaviors';

const route = useRoute();

let cleanupRunes: (() => void) | undefined;
let cleanupLayout: (() => void) | undefined;

function initBehaviors() {
  RfContext.pages = page.value.allPages;
  RfContext.currentUrl = route.path;
  registerElements();
  cleanupRunes = initRuneBehaviors();
  cleanupLayout = initLayoutBehaviors();
}

onMounted(initBehaviors);
onBeforeUnmount(() => { cleanupRunes?.(); cleanupLayout?.(); });

// Re-initialize on client-side navigation
watch(() => route.path, () => {
  cleanupRunes?.(); cleanupLayout?.();
  nextTick(initBehaviors);
});
</script>

7. Web Components in Vue

Since renderToHtml() outputs custom elements as raw HTML injected via v-html, Vue's template compiler never sees them. The isCustomElement configuration in the module is a safety net — it prevents warnings if a recursive Renderer.vue is used instead.

// In the Nuxt module:
nuxt.options.vue.compilerOptions.isCustomElement = (tag) => tag.startsWith('rf-');

8. Nitro Compatibility

Nitro bundles server-side code. Refrakt packages must not be externalized:

// In the Nuxt module:
nuxt.options.build.transpile.push(
  '@markdoc/markdoc',
  '@refrakt-md/runes', '@refrakt-md/content',
  '@refrakt-md/types', '@refrakt-md/svelte',
  '@refrakt-md/transform', '@refrakt-md/theme-base',
);

This is the same CORE_NO_EXTERNAL list from the SvelteKit plugin (packages/sveltekit/src/plugin.ts, lines 11-19).

9. Package Shape

packages/nuxt/
├── src/
   ├── module.ts              # Nuxt module (Vite plugin, CSS, auto-imports)
   ├── vite-plugin.ts         # Adapted Vite plugin (reuses virtual module logic)
   ├── RefraktContent.vue     # v-html rendering component
   ├── composables/
   └── useRefraktMeta.ts  # SEO composable wrapping useHead()
   └── index.ts               # Public exports
├── package.json               # peer dep: nuxt@^3.0.0
└── tsconfig.json

Estimated New Code

ComponentLinesComplexity
Nuxt module~50Medium (Nuxt module API)
Vite plugin adapter~40Low (wraps shared logic)
RefraktContent.vue~15Trivial
useRefraktMeta() composable~30Low
Page template example~35Low
Behavior initialization~25Low
Lumina/nuxt adapter~25Trivial
Total~220Low-Medium

Cross-Framework Comparison

ConcernSvelteKit (current)AstroNext.jsEleventyNuxt
RendererRenderer.svelte (recursive)renderToHtml()renderToHtml() + RSCrenderToHtml() + templaterenderToHtml() + v-html
SEO / <head><svelte:head>Native <head>generateMetadata()Template <head>useHead()
Behavior init$effectinitRuneBehaviors()<script>useEffect (client component)<script>onMounted()
Behavior cleanup$effect returnN/A (MPA)useEffect returnN/A (MPA)onBeforeUnmount()
CSS injectionVirtual module (virtual:refrakt/tokens)injectScript or CSS importimport in root layout<link> via passthrough copynuxt.options.css
CSS tree-shakingVite plugin buildStart()SameWebpack plugin (Phase 2)Post-build script (Phase 2)Same Vite plugin
Content HMRVite server.watcherSameWebpack plugin (Phase 2)Built-in --watchSame Vite server.watcher
IntegrationVite pluginAstro integrationnpm packageEleventy pluginNuxt module + Vite plugin
Content loading+page.server.tsgetStaticPaths()generateStaticParams() + RSCGlobal data file + paginationuseAsyncData()
SPA navigation{#key page.url}N/A (MPA)App Router (automatic)N/A (MPA)Vue Router (automatic)
Web componentsNative custom elementsNative custom elementsRaw HTML (bypasses React)Native custom elementsisCustomElement config
Build toolViteViteWebpack / TurbopackNone (or optional)Vite (via Nitro)
Estimated adapter~510 lines (existing)~205 lines~170 lines~145 lines~220 lines
DifficultyLowLowLowLow-Medium

Risks

RiskFrameworksSeverityMitigation
React hydration mismatch warningsNext.jsLowrenderToHtml() + dangerouslySetInnerHTML bypasses hydration entirely
Nuxt Content module confusionNuxtLowDocument that @refrakt-md/nuxt replaces Nuxt Content, not supplements it
Eleventy Markdown-it collisionEleventyLowData file approach means Eleventy never processes .md files through its own pipeline
Content HMR latency in Next.js devNext.jsLowFull page reload on content change is acceptable initially; custom plugin later
CSS bundle size without tree-shakingAllLow~48 rune CSS files total; optimize later
Bundling @refrakt-md/behaviors for EleventyEleventyLowSimple esbuild step or passthrough copy from node_modules

No architectural blockers for any framework.


Why this order: Nuxt → Next.js → Eleventy

  1. Nuxt — Closest to SvelteKit (Vite-based, same plugin patterns). Highest code sharing from the existing adapter. Expanding beyond Svelte to Vue proves the multi-framework story and captures a large ecosystem.

  2. Next.js — Largest potential audience. RSC + renderToHtml() is a clean fit. Different build tooling (Webpack/Turbopack) means less code reuse from the Vite plugin, but a simpler integration model (no virtual modules needed).

  3. Eleventy — Simplest integration (just functions + templates). More niche audience but ideal for documentation sites wanting minimal JS. Good validation that the architecture works with a non-Vite, non-framework tool.

Phased Implementation

Phase 0: Shared utility extraction (prerequisite for all)

  • Move serialize() and matchRouteRule() to @refrakt-md/transform
  • Re-export from @refrakt-md/svelte for backward compatibility
  • ~20 lines

Phase 1: @refrakt-md/nuxt

  • Nuxt module + adapted Vite plugin
  • RefraktContent.vue component
  • useRefraktMeta() composable
  • Lumina/nuxt adapter
  • Example Nuxt site using site/content/
  • ~220 lines

Phase 2: @refrakt-md/next

  • RefraktContent RSC + BehaviorInit client component
  • generateMetadata() helper + content loader
  • Lumina/next adapter
  • Example Next.js site
  • ~170 lines

Phase 3: @refrakt-md/eleventy

  • Eleventy plugin + global data factory
  • Base template example
  • Lumina/11ty adapter
  • Example Eleventy site
  • ~145 lines

Total new code across all three: ~555 lines + ~20 lines shared extraction = ~575 lines

Compare to ~15,000+ lines of framework-agnostic code being reused across all adapters.