AdaptersNext.js Adapter

Next.js Adapter

The Next.js adapter (@refrakt-md/next) connects refrakt.md to Next.js App Router. It renders content as static HTML via React Server Components with zero client-side hydration for content — only interactive rune behaviors load JavaScript.

Installation

npm install @refrakt-md/next @refrakt-md/content @refrakt-md/runes @refrakt-md/transform @refrakt-md/highlight @refrakt-md/types @markdoc/markdoc

Configuration

Set target to "next" in refrakt.config.json:

{
  "contentDir": "./content",
  "theme": "@refrakt-md/lumina",
  "target": "next",
  "routeRules": [
    { "pattern": "**", "layout": "default" }
  ]
}

NextTheme Interface

The Next.js adapter uses the NextTheme interface — structurally identical to HtmlTheme:

interface NextTheme {
  manifest: ThemeManifest;
  layouts: Record<string, LayoutConfig>;
}

All runes render through the identity transform + CSS. Interactive runes get their behavior from @refrakt-md/behaviors via the BehaviorInit client component.

Project Structure

A Next.js refrakt.md site uses a catch-all route in the App Router:

app/
├── layout.tsx              # Root layout — imports theme CSS
├── [[...slug]]/
   └── page.tsx            # Server Component — loads + renders content
content/
├── index.md
├── docs/
   ├── _layout.md
   └── getting-started.md
refrakt.config.json

The [[...slug]]/page.tsx is an optional catch-all route that handles both the root / path and all nested paths like /docs/getting-started.

Content Loading

Load content using createRefraktLoader from @refrakt-md/content (or the convenience wrapper createNextLoader from @refrakt-md/next) in a Server Component. The loader handles config loading, plugin merging, theme assembly, and caching automatically. Use generateStaticParams for static export:

// Either the shared loader directly:
import { createRefraktLoader } from '@refrakt-md/content';
const loader = createRefraktLoader({
  variables: { version: '1.0.0' }, // Markdoc {% $version %} interpolation
  security: 'strict',              // sanitise untrusted author content
});

// Or the typed Next.js shorthand:
import { createNextLoader } from '@refrakt-md/next';
const loader = await createNextLoader({ variables: { version: '1.0.0' } });

Both accept the same four optional fields: configPath, site, variables, security.

import { createRefraktLoader } from '@refrakt-md/content';
import manifest from '@refrakt-md/lumina/manifest';
import { layouts } from '@refrakt-md/lumina/layouts';
const theme = { manifest, layouts };
import { RefraktContent, buildUrlFromParams, buildMetadata, hasInteractiveRunes } from '@refrakt-md/next';
import { BehaviorInit } from '@refrakt-md/next/client';
import type { PageParams } from '@refrakt-md/next';

const loader = createRefraktLoader();

export async function generateStaticParams() {
  const site = await loader.getSite();
  return site.pages
    .filter(p => !p.route.draft)
    .map(p => ({
      slug: p.route.url === '/' ? [] : p.route.url.slice(1).split('/'),
    }));
}

export async function generateMetadata({ params }: { params: Promise<PageParams> }) {
  const resolvedParams = await params;
  const url = buildUrlFromParams(resolvedParams);
  const site = await loader.getSite();
  const page = site.pages.find(p => p.route.url === url);
  if (!page) return {};
  return buildMetadata({ title: page.frontmatter.title as string, seo: page.seo });
}

export default async function Page({ params }: { params: Promise<PageParams> }) {
  const resolvedParams = await params;
  const url = buildUrlFromParams(resolvedParams);
  const [site, transform, hl] = await Promise.all([
    loader.getSite(),
    loader.getTransform(),
    loader.getHighlightTransform(),
  ]);
  const page = site.pages.find(p => p.route.url === url);
  if (!page) return notFound();

  const renderable = hl(transform(page.renderable));
  const pages = site.pages
    .filter(p => !p.route.draft)
    .map(p => ({ url: p.route.url, title: p.frontmatter.title ?? '', draft: false }));

  const pageData = {
    renderable,
    title: page.frontmatter.title ?? '',
    url,
    pages,
    regions: {},
  };

  return (
    <>
      <RefraktContent theme={theme} page={pageData} />
      {hasInteractiveRunes(renderable) && (
        <BehaviorInit pages={pages} currentUrl={url} />
      )}
    </>
  );
}

RefraktContent Server Component

RefraktContent is a React Server Component that renders refrakt content to HTML. It calls renderPage() to produce an HTML string, then injects it with dangerouslySetInnerHTML:

import { RefraktContent } from '@refrakt-md/next';

// In a Server Component:
<RefraktContent theme={theme} page={pageData} className="rf-content" />

Why dangerouslySetInnerHTML? The identity transform produces HTML with BEM classes and rf-* custom elements. These are not React components — they are raw HTML that React should pass through untouched. Using dangerouslySetInnerHTML means React never tries to reconcile or hydrate the content tree. The HTML is rendered on the server and sent to the browser as-is.

This is the same approach as the HTML adapter, just wrapped in a React component for ergonomics.

Props

PropTypeDescription
themeNextThemeTheme manifest and layout configs
pageLayoutPageDataPage content (renderable tree, regions, title, URL, pages list)
classNamestring?Optional CSS class on the wrapper <div>

BehaviorInit Client Component

Interactive runes (tabs, accordion, datatable, etc.) need client-side JavaScript. BehaviorInit is a 'use client' component that dynamically imports @refrakt-md/behaviors and initializes them:

import { BehaviorInit } from '@refrakt-md/next/client';

<BehaviorInit pages={pages} currentUrl={url} />

Lifecycle:

  1. On mount, dynamically imports @refrakt-md/behaviors
  2. Sets page context (RfContext.pages, RfContext.currentUrl)
  3. Registers custom elements (registerElements())
  4. Initializes rune behaviors (initRuneBehaviors()) and layout behaviors (initLayoutBehaviors())
  5. On unmount, runs cleanup functions returned by the init calls
  6. On client-side navigation (route change via usePathname()), re-runs the full cycle

The ./client export is a separate entry point so the 'use client' directive only applies to BehaviorInit. The main @refrakt-md/next entry point stays server-safe — it never imports React client hooks or browser APIs.

Conditional Loading

Use hasInteractiveRunes() to detect whether a page needs behaviors:

import { hasInteractiveRunes } from '@refrakt-md/next';

// Only include BehaviorInit on pages that need it
{hasInteractiveRunes(renderable) && (
  <BehaviorInit pages={pages} currentUrl={url} />
)}

This avoids loading @refrakt-md/behaviors on pages that have no interactive runes.

Metadata Helper

The buildMetadata() function transforms refrakt SEO data into a Next.js Metadata object for the App Router. Thread the four site-level fields from refrakt.config.json (siteName, baseUrl, defaultImage, logo) to enrich the output:

import { buildMetadata, buildJsonLd } from '@refrakt-md/next';
import { loadRefraktConfig, resolveSite } from '@refrakt-md/transform/node';

const { site } = resolveSite(loadRefraktConfig('refrakt.config.json'));
const seoSite = {
  siteName: site.siteName,
  baseUrl: site.baseUrl,
  defaultImage: site.defaultImage,
  logo: site.logo,
};

export async function generateMetadata({ params }) {
  const page = await loadPage(params.slug);
  return buildMetadata({
    title: page.title,
    frontmatter: page.frontmatter,
    seo: page.seo,
    ...seoSite,
  });
}

It extracts title, description, Open Graph tags, Twitter Card metadata, and JSON-LD schemas from the page's SEO data. When site-level fields are supplied, the generated metadata also sets metadataBase (so Next.js absolutizes relative URLs natively), openGraph.siteName, and an image fallback:

FieldSource
metadataBasenew URL(baseUrl)
titleseo.og.title or title
descriptionseo.og.description or frontmatter.description
openGraph.titleseo.og.title or title
openGraph.descriptionseo.og.description or frontmatter.description
openGraph.siteNamesiteName
openGraph.imagesseo.og.image or defaultImage fallback
openGraph.urlseo.og.url
openGraph.typeseo.og.type
twitter.cardsummary_large_image when an image resolves, summary otherwise
twitter.titleseo.og.title or title
twitter.descriptionseo.og.description or frontmatter.description
twitter.imagesseo.og.image or defaultImage fallback

JSON-LD Structured Data

Use buildJsonLd() to extract JSON-LD schemas from page SEO data. Pass the same site-level fields to append synthetic WebSite + Organization entries:

import { buildJsonLd } from '@refrakt-md/next';

const jsonLd = buildJsonLd({
  title: page.title,
  frontmatter: page.frontmatter,
  seo: page.seo,
  ...seoSite, // siteName + baseUrl + logo from refrakt.config.json
});

{jsonLd.map((schema, i) => (
  <script
    key={i}
    type="application/ld+json"
    dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
  />
))}

When baseUrl + siteName are supplied, buildJsonLd appends synthetic WebSite + Organization entries to the page-level JSON-LD array, matching the SvelteKit reference adapter's output.

CSS Injection

Import the theme CSS in your root layout:

import '@refrakt-md/lumina';          // Full theme CSS (index.css)
import '@refrakt-md/lumina/base.css'; // Or just the base tokens

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

Next.js automatically processes CSS imports and includes them in the build output.

Site-level token overrides

Any theme.tokens, theme.modes, theme.presets, or site.tints you declare in refrakt.config.json is composed into a :root { --rf-* } stylesheet via the getSiteTokensCss helper. Inline it in your root layout as a <style /> block — the cleanest path for Next.js's Server Component model:

import '@refrakt-md/lumina';
import { getSiteTokensCss } from '@refrakt-md/next';
import type { ReactNode } from 'react';

const siteTokensCssPromise = getSiteTokensCss();

export default async function RootLayout({ children }: { children: ReactNode }) {
  const siteTokensCss = await siteTokensCssPromise;
  return (
    <html lang="en">
      <head>
        {siteTokensCss && (
          <style dangerouslySetInnerHTML={{ __html: siteTokensCss }} />
        )}
      </head>
      <body>{children}</body>
    </html>
  );
}

The promise is captured at module-scope so the CSS is composed once per server process, not once per request. The inline <style> block ships in <head> after the theme CSS so site-level --rf-* overrides resolve last in the cascade.

Alternative — linked stylesheet. If you prefer a <link> over an inline <style>, write the result to public/site-tokens.css at build time via a next.config.mjs hook:

// next.config.mjs
import { getSiteTokensCss } from '@refrakt-md/next';
import { writeFileSync } from 'node:fs';

const css = await getSiteTokensCss();
writeFileSync('./public/site-tokens.css', css);

export default { /* ... */ };

Then add <link rel="stylesheet" href="/site-tokens.css"> to your layout after the theme CSS import.

See the design tokens contract and the scoped tint projection pages for the full token surface.

Custom Elements

The identity transform produces custom elements like <rf-icon>, <rf-diagram>, and <rf-nav>. These are web components registered by @refrakt-md/behaviors at runtime. React passes them through as unknown HTML elements — no special configuration needed.

note

React 19 has improved custom element support. For React 18 projects, custom element attributes are passed as properties on the DOM node rather than attributes, but this does not affect refrakt.md since the HTML is injected via dangerouslySetInnerHTML.

Differences from SvelteKit

FeatureSvelteKitNext.js
RenderingSvelte Renderer walks tree as componentsrenderToHtml() produces string, injected via dangerouslySetInnerHTML
Component runesSvelte components via component registryNot supported — all runes use identity transform
Element overridesTable, Pre elements replaced with Svelte componentsTable wrapping handled by CSS; code copy by behaviors
Behaviorsuse:behaviors Svelte actionBehaviorInit client component with useEffect
Build integrationVite plugin with virtual modules + HMRManual content loading in Server Components
SEO<svelte:head> in ThemeShellgenerateMetadata() + buildMetadata()
NavigationSvelteKit client-side routingNext.js App Router navigation

The content output is identical — the same Markdoc runes produce the same BEM classes, data attributes, and structural HTML. The difference is how that HTML reaches the browser.

Compatibility

DependencyVersion
Next.js14.x or 15.x
React18.x or 19.x
Node.js18+

The adapter works with both the Pages Router and App Router, but the RefraktContent Server Component and BehaviorInit client component are designed for the App Router pattern. For Pages Router usage, call renderPage() directly in getStaticProps.

Dependencies

PackageRequiredPurpose
@refrakt-md/transformYesIdentity transform engine, layout transform, renderToHtml
@refrakt-md/typesYesShared TypeScript interfaces
@refrakt-md/contentYesContent loading, routing, layout cascade
@refrakt-md/behaviorsYesClient-side progressive enhancement for interactive runes
nextPeerNext.js framework
reactPeerReact runtime

Theme Integration

Themes work with the Next.js adapter through their ./next subpath export:

import manifest from '@refrakt-md/lumina/manifest';
import { layouts } from '@refrakt-md/lumina/layouts';
const theme = { manifest, layouts };

This provides the NextTheme object with the manifest and layout configs. For CSS, import the theme's main export in your root layout.