Plan
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>