Build-time scoped component overrides for MDX
A compile-time Remark plugin for context-aware MDX rewrites. It moves
component override decisions from global runtime mappings to scoped AST
transforms, so standard tags (p, br) and custom components can be rewritten
based on nesting context.
- 🧭 True Scoped Overrides: Rewrites run only inside configured scope components, not globally across all MDX.
- 🧱 Nested Scope Isolation: Rules stay inside their nearest configured scope, so parent behavior does not bleed into nested scopes.
- 🏷️ Tag + Component Rewrites: Rewrite standard tags (
p,br) and custom JSX components throughrenameFlow. - 🛡️ Typed Authoring API:
transform -> flow -> toenforces valid registry keys and target wiring at authoring time. - 🔒 Prop Inference from Source Components:
component.propsis inferred from the original component declarations viadefineEntry. - ⚙️ Compiler-Ready Registry Bridge:
deriveMdxTransformRegistryconverts typed component definitions into plugin-ready config. - 🧩 Runtime Behavior Context (Optional, Advanced): Use
createDefineEntryandcreateLoaderUtilsto attach and consume app-specific runtime flags (for exampleinjectSelection/requireWrapper) in a typed resolver pipeline.
Default MDX component mapping is usually global. If you remap p or br, the
change applies everywhere. This plugin provides a compile-time escape hatch for
context-specific behavior.
| Aspect | Global Mapping (Default) | Scoped Rewrites (This Plugin) |
|---|---|---|
| Override scope | One mapping affects all occurrences | Rules run only inside configured scope components |
| Type safety | Manual matching between names and props | Registry-constrained names + inferred component.props |
| Execution stage | Runtime provider mapping | Compile-time remark transform metadata |
| Node focus | Renderer output level | MDX JSX flow nodes (mdxJsxFlowElement) |
npm install remark-scoped-mdx unified react
# or
pnpm add remark-scoped-mdx unified reactIf you use next/dynamic in the runtime resolver examples, also install next.
AlertParagraph is declared once, and its prop type is reused automatically in
transform authoring.
import type { FC, ReactNode } from 'react';
export type AlertParagraphProps = {
children?: ReactNode;
variant: 'red' | 'blue' | undefined;
};
export const AlertParagraph: FC<AlertParagraphProps> = ({ children, variant }) => {
return (
<p data-alert-paragraph-variant={variant}>{children}</p>
);
};import { defineComponents, defineEntry } from 'remark-scoped-mdx';
import { AlertParagraph } from './AlertParagraph';
import { ArticleScope } from './ArticleScope';
export const scopeRegistry = defineComponents(
{
ArticleScope: defineEntry({
component: ArticleScope
}),
AlertParagraph: defineEntry({
component: AlertParagraph
})
},
ctx => ({
ArticleScope: ctx.transform(rule =>
rule.flow(target => ({
p: target.to({
component: {
name: 'AlertParagraph',
props: { variant: 'red' } // 🟢 PASS
},
transformOptions: { childrenPolicy: 'preserve' }
})
}))
)
})
);// In the same transform config:
props: { variant: 'green' } // 🔴 ERRORType '"green"' is not assignable to type '"red" | "blue" | undefined'.ts(2322)
AlertParagraph.tsx(8, 3): The expected type comes from property 'variant'
which is declared here on type 'TransformPropsFor<AlertParagraphProps>'Declaration principle:
- Declare component props in one place (the component itself).
defineEntrycaptures that type.target.to({ component: { name, props } })is checked against that captured type.
Important: This plugin requires three configuration steps: define your component rules, register the plugin with your MDX compiler, then build the runtime component map for rendering. Steps 1-2 handle compile-time rewrites; Step 3 enables scoped component rendering at runtime.
Define your typed component registry first, then derive the transform registry.
import {
defineComponents,
defineEntry,
deriveMdxTransformRegistry
} from 'remark-scoped-mdx';
export const scopeRegistry = defineComponents(
{
ArticleScope: defineEntry({ component: ArticleScope }),
AlertParagraph: defineEntry({ component: AlertParagraph })
},
ctx => ({
ArticleScope: ctx.transform(rule =>
rule.flow(target => ({
p: target.to({
component: { name: 'AlertParagraph' }
})
}))
)
})
);
export const scopeTransformRegistry =
deriveMdxTransformRegistry(scopeRegistry);Minimal registration preview (full options are shown in Step 2):
import { remarkScopedMdx } from 'remark-scoped-mdx';
const mdxOptions = {
remarkPlugins: [[remarkScopedMdx, scopeTransformRegistry]]
};Pass your rule registry to remarkScopedMdx in your MDX compilation
configuration.
import { compile, type CompileOptions } from '@mdx-js/mdx';
import { remarkScopedMdx } from 'remark-scoped-mdx';
import { scopeTransformRegistry } from './scopeRegistry';
const mdxOptions: CompileOptions = {
remarkPlugins: [[remarkScopedMdx, scopeTransformRegistry]],
providerImportSource: '@mdx-js/react'
};
const compiled = await compile(mdxSource, mdxOptions);// next.config.mjs
import createMDX from '@next/mdx';
import { remarkScopedMdx } from 'remark-scoped-mdx';
import { scopeTransformRegistry } from './src/mdx/scopeRegistry.js';
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx']
};
const withMDX = createMDX({
options: {
remarkPlugins: [[remarkScopedMdx, scopeTransformRegistry]]
}
});
export default withMDX(nextConfig);import dynamic from 'next/dynamic';
import {
createLoaderUtils,
type LoaderResolverInput
} from 'remark-scoped-mdx';
const resolveEntry = <Props extends object>(
entry: LoaderResolverInput<Props, {}>
) => {
if ('loader' in entry && entry.loader) {
return dynamic(entry.loader, { ...(entry.dynamicOptions ?? {}) });
}
return entry.component;
};
const { createComponentSet, getLoadableComponentsFromSet } =
createLoaderUtils(resolveEntry);
const scopedComponentSet = createComponentSet(scopeRegistry);import type { FC } from 'react';
import { MDXProvider } from '@mdx-js/react';
import type { MDXContentProps } from 'mdx/types';
import { expandHydratedComponentNames } from 'remark-scoped-mdx';
// Assumes Step 3a already exists in this module:
// - getLoadableComponentsFromSet
// - scopedComponentSet
// - scopeRegistry
export const createScopedMdxContent = (
Component: FC<MDXContentProps>,
hydratedComponents: Array<string>
) => {
const hydratedSet = new Set(hydratedComponents);
const expanded = expandHydratedComponentNames(
hydratedSet,
scopeRegistry
);
const scopedLoadableComponents = getLoadableComponentsFromSet(
scopedComponentSet,
expanded
);
return (props: MDXContentProps) => (
<MDXProvider components={scopedLoadableComponents} disableParentContext>
<Component {...props} />
</MDXProvider>
);
};// Page-level usage example
const ScopedContent = createScopedMdxContent(Content, hydratedComponents);
return <ScopedContent />;import {
remarkScopedMdx,
deriveMdxTransformRegistry,
expandHydratedComponentNames,
defineComponents,
defineEntry,
createDefineEntry,
createLoaderUtils
} from 'remark-scoped-mdx';
import type { LoaderResolverInput } from 'remark-scoped-mdx';| Export | Kind | Purpose | Typical step |
|---|---|---|---|
remarkScopedMdx |
remark plugin | Applies scoped renameFlow rewrites during MDX compile. |
Step 2 |
defineComponents |
authoring helper | Defines the typed scope registry and transform rules. | Step 1 |
defineEntry |
authoring helper | Registers static/dynamic entries with inferred component props (default runtime config). | Step 1 |
createDefineEntry |
authoring helper factory | Creates a project-specific defineEntry with typed runtime flags (advanced). |
Advanced Step 1 |
deriveMdxTransformRegistry |
registry adapter | Converts typed component definitions into plugin-ready transform config. | Step 1 |
createLoaderUtils |
runtime loader factory | Binds a resolver and returns runtime helpers (createComponentSet, getLoadableComponents, getLoadableComponentsFromSet). |
Step 3a |
expandHydratedComponentNames |
runtime registry adapter | Adds transform-introduced component names to hydrated names before runtime map resolution. | Step 3b |
LoaderResolverInput |
type export | Types the resolver input entry (component or loader plus runtime config flags). |
Step 3a / Advanced Step 3a |
defineEntry: register one component/loader and capture its source props for downstream inference.defineComponents: define one registry plus scoped transform rules in one place.ctx.transform(...): author one rule per scope component.rule.flow(...): map source flow tags (p,br, etc.) to target rewrites.target.to(...): set{ component: { name, props } }with name/props linkage checked at compile time.
In this example, AlertParagraph must be declared in the registry first.
Selecting name: 'AlertParagraph' in to(...) then activates prop inference
from AlertParagraphProps.
childrenPolicy is part of renameFlow target transformOptions and controls
how children are handled on the renamed MDX JSX flow element.
childrenPolicy value |
Plugin behavior | Typical use |
|---|---|---|
'preserve' or omitted |
Keeps existing children when renaming. | Container replacements like p -> AlertParagraph. |
'clear' |
Clears children (element.children = []) after rename. |
Marker/void-style replacements like br -> MessageBlankLine. |
Examples:
// preserve (default)
<p>Hello</p>
// -> <AlertParagraph>Hello</AlertParagraph>
// clear
<br />
// -> <MessageBlankLine />Notes:
- This option applies only to scoped
renameFlowrewrites. - It affects MDX JSX flow nodes only (
MdxJsxFlowElement). - It does not affect inline JSX text nodes or markdown
paragraphnodes.
- Outer traversal discovers scope roots:
MDX JSX flow elements whose
nameexists in the registry and declaresmdxTransform.renameFlow. - Inner traversal runs within each discovered scope subtree: matching MDX JSX flow elements are renamed and target props are emitted.
Nested configured scopes are boundaries. Parent-scope rewrites do not cross into nested scope subtrees.
- ✅ Supported: MDX JSX flow elements (
MdxJsxFlowElement)- Standalone flow JSX:
<br /> - Flow JSX in flow content:
<div> <br /> Some text </div>
- Standalone flow JSX:
- ❌ Not supported: MDX JSX text/inline elements (
MdxJsxTextElement)- Inline JSX in text:
Hello <br /> world - Inline JSX in paragraph phrasing content:
<p>Hello<br />world</p>
- Inline JSX in text:
- ✅ Rewritten: explicit JSX
<p>flow elements- Example (inside a scope):
<ArticleScope> <p>Hello</p> </ArticleScope>
- The
<p>above is an MDX JSX flow node and can be renamed byrenameFlow.
- Example (inside a scope):
- ❌ Not rewritten: Markdown paragraph nodes (
type: "paragraph")- Example:
<ArticleScope> Hello world </ArticleScope>
- This becomes a markdown paragraph node, not an
MdxJsxFlowElement.
- Example:
This is a separate advanced pattern focused on runtime behavior control. It is not required for the basic scoped rewrite flow above.
import type { FC, ReactNode } from 'react';
import {
createDefineEntry,
defineComponents
} from 'remark-scoped-mdx';
type BehaviorScopeProps = {
children?: ReactNode;
};
type BehaviorParagraphProps = {
children?: ReactNode;
tone: 'info' | 'warning';
};
const BehaviorScope: FC<BehaviorScopeProps> = ({ children }) => children;
const BehaviorParagraph: FC<BehaviorParagraphProps> = ({ children, tone }) => (
<p data-behavior-tone={tone}>{children}</p>
);
export type RuntimeConfig = {
requireWrapper?: boolean;
injectSelection?: boolean;
};
export const defineEntry = createDefineEntry<RuntimeConfig>();
export const behaviorScopedComponents = defineComponents(
{
BehaviorScope: defineEntry({
component: BehaviorScope,
injectSelection: true
}),
BehaviorParagraph: defineEntry({
component: BehaviorParagraph
})
},
ctx => ({
BehaviorScope: ctx.transform(rule =>
rule.flow(target => ({
p: target.to({
component: {
name: 'BehaviorParagraph',
props: { tone: 'info' }
},
transformOptions: { childrenPolicy: 'preserve' }
})
}))
)
})
);import {
deriveMdxTransformRegistry,
remarkScopedMdx
} from 'remark-scoped-mdx';
export const behaviorScopedTransformRegistry =
deriveMdxTransformRegistry(behaviorScopedComponents);
const mdxOptions = {
remarkPlugins: [[remarkScopedMdx, behaviorScopedTransformRegistry]]
};import type { ComponentType } from 'react';
import dynamic from 'next/dynamic';
import {
createLoaderUtils,
type LoaderResolverInput
} from 'remark-scoped-mdx';
function getBaseComponent<Props extends object>(
entry: LoaderResolverInput<Props, RuntimeConfig>
) {
if ('loader' in entry && entry.loader) {
const { loader, dynamicOptions } = entry;
return dynamic(loader, { ...(dynamicOptions ?? {}) });
}
return entry.component;
}
function withSelection<Props extends object>(
BaseComponent: ComponentType<Props>,
requireWrapper: boolean
) {
return (props: Props) => (
<SelectionBranch
Base={BaseComponent}
baseProps={props}
requireWrapper={requireWrapper}
/>
);
}
function withRequiredWrapper<Props extends object>(
BaseComponent: ComponentType<Props>
) {
return (props: Props) => {
const Wrapper = pickWrapper(true);
return (
<Wrapper>
<BaseComponent {...props} />
</Wrapper>
);
};
}
const resolveEntry = <Props extends object>(
entry: LoaderResolverInput<Props, RuntimeConfig>
) => {
const BaseComponent = getBaseComponent(entry);
if (entry.injectSelection) {
return withSelection(BaseComponent, !!entry.requireWrapper);
}
if (entry.requireWrapper) {
return withRequiredWrapper(BaseComponent);
}
return BaseComponent;
};
export const {
createComponentSet,
getLoadableComponents,
getLoadableComponentsFromSet
} = createLoaderUtils(resolveEntry);
export const behaviorScopedComponentSet =
createComponentSet(behaviorScopedComponents);import type { FC } from 'react';
import { MDXProvider } from '@mdx-js/react';
import type { MDXContentProps } from 'mdx/types';
import { expandHydratedComponentNames } from 'remark-scoped-mdx';
// Assumes Advanced Step 3a already exists in this module:
// - getLoadableComponentsFromSet
// - behaviorScopedComponentSet
// - behaviorScopedComponents
export const createBehaviorScopedMdxContent = (
Component: FC<MDXContentProps>,
hydratedComponents: Array<string>
) => {
const hydratedSet = new Set(hydratedComponents);
const expandedBehaviorSet = expandHydratedComponentNames(
hydratedSet,
behaviorScopedComponents
);
const behaviorLoadableComponents = getLoadableComponentsFromSet(
behaviorScopedComponentSet,
expandedBehaviorSet
);
return (props: MDXContentProps) => (
<MDXProvider components={behaviorLoadableComponents} disableParentContext>
<Component {...props} />
</MDXProvider>
);
};// Page-level usage example
const ScopedContent = createBehaviorScopedMdxContent(Content, hydratedComponents);
return <ScopedContent />;Note
SelectionBranch,pickWrapper,BehaviorScope, andBehaviorParagraphare placeholders in this advanced example. Replace them with the matching wrappers/components from your own project.