Here's the problem: You want to pull remote Markdown into your site, but you want components with interactivity like syntax highlighting or copy buttons, not plain HTML. On Astro, this means you're on your own, since there's no built-in way to use custom components with remote Markdown or MDX.
I hit this wall when trying to fetch posts from dev.to and render them using custom components, like a CodeBlock component, without triggering a layout flash.
The source content for this post is my dev.to blog, but the problem applies to any remote Markdown: a CMS, a Jekyll blog, a GitHub wiki.
Note: I built are Astro's built-in hydration system.
Astro Islands work by scanning your component references at build time. When you write <MyReactComponent client:load />, Astro knows exactly which component to hydrate and bundles it accordingly. But remote content arrives at runtime as a string, so there's nothing for Astro to scan. Any components aren't referenced anywhere in the source tree, which means Astro can't manage the hydration for them.
To make this work, you can't use Astro components in your MDX (Astro will only compile those at build time), and you have to bypass Astro's MDX pipeline.
The Baseline: Plain HTML Rendering
The following is the basic shape the other experiments branch off of. You must : This is just HTML, no fancy code blocks yet.
Finding: This is good enough if you don't need components. But what happens when you do?
Experiment 1: Client Islands
Idea: Add data-component attributes and then mount React components into them.
Instead of using Astro's renderMarkdown, you can create your own client island. First, in your loader, inject a custom rehype plugin (rehypeComponentMarkers) that adds data-component attributes to elements:
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import rehypeComponentMarkers from './plugins/rehype-component-markers';
export function devToLoaderRehype(username: string): Loader {
return {
name: 'devto-loader-rehype',
load: async ({ store, parseData, generateDigest }) => {
const articles = await fetchDevToArticles(username);
...
for (const article of articles) {
// ...
// Extract body content (gray-matter strips frontmatter)
const { content } = matter(article.body_markdown);
// Use rehype to add component markers
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeComponentMarkers)
.use(rehypeStringify)
.process(content);
// Set the custom HTML in the collection store directly
store.set({
id,
data,
digest,
rendered: {
html: String(file),
metadata: { headings: [], imagePaths: [], frontmatter: {} },
},
});
}
},
};
}
Here's what the rehypeComponentMarkers might look like for just the pre element:
import { visit } from 'unist-util-visit';
import type { Root, Element } from 'hast';
export default function rehypeComponentMarkers() {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
if (node.tagName === 'pre') {
const codeChild = node.children.find(
(child): child is Element =>
child.type === 'element' && child.tagName === 'code',
);
const lang =
codeChild?.properties?.className
?.toString()
.replace('language-', '') ?? 'text';
node.properties = {
...node.properties,
'data-component': 'code-block',
'data-language': lang,
};
}
// handle other elements
This adds attributes to your HTML by mapping pre to code-block and setting the code language string (in this case "js"):
<pre data-component="code-block" data-language="js">...</pre>
Rehype can enrich the HTML but can't inject server-rendered components itself. Rehype operates on string/AST transformations during the loader phase, outside React's runtime.
On the client side, you can query the DOM after load, and then mount React components into the data attributes using createRoot.
From your [...slug].astro route, get the new collection, and add a client-side script:
// [...slug].astro
---
export async function getStaticPaths() {
const posts = await getCollection('devToRehype');
...
---
...
<script>
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import CodeBlock from '../components/CodeBlock';
document.querySelectorAll('[data-component="code-block"]').forEach((node) => {
const code = node.querySelector('code')?.textContent ?? '';
const language = node.getAttribute('data-language') ?? undefined;
const root = createRoot(node);
root.render(createElement(CodeBlock, { code, language }));
});
</script>
: The component should render with no flash! But the Copy button doesn't work, since the server rendered the component's HTML, but React hasn't attached to it yet. Event listeners like the Copy button's
onClickare never added.
Experiment 3: Server Render + Hydration Islands
With Experiment 2, the server and client HTML now match, preventing the flash. To make interactive components like CodeBlock work, the client now has to attach to the existing HTML.
First, this requires an island wrapper function to add data attributes (like the rehype example above) that tell the client JS where and how to hydrate the server-rendered HTML:
// Server-side function
function renderIsland(name: string, Component: ComponentType<any>, props: Record<string, unknown>) {
// Children aren't serializable, so we pass them to renderToString but not data-props
const { children, ...serializableProps } = props;
const staticHtml = renderToString(createElement(Component, props));
return createElement('div', {
className: 'remote-island',
'data-component': name,
'data-props': JSON.stringify(serializableProps),
// Make sure you trust the HTML source
dangerouslySetInnerHTML: { __html: staticHtml },
});
}
The renderIsland replaces PreWrapper, which only mapped props and returned the component directly.
The renderIsland function does the same mapping but also returns an HTML element wrapper with data-component and data-props attributes, to name the component and serialize the props explicitly. Note that each div this creates is given the class name "remote-island".
Important Caveat: Children are passed to renderToString so the server can produce the initial HTML, but they're excluded from data-props because React elements aren't JSON-serializable. Only plain props like strings, numbers, or booleans go into the data attribute for the client to read back. So this method only works with serializable props.
You can now use this function in the component map to ensure your component is wrapped in an island div:
// [...slug].astro
---
import CodeBlock from '../components/CodeBlock';
import type { ComponentType, ReactElement } from 'react';
interface CodeElementProps {
children?: string;
className?: string;
}
...
const pageComponents = {
pre: (props: Record<string, unknown>) => {
const children = props.children as ReactElement<CodeElementProps> | undefined;
return renderIsland('CodeBlock', CodeBlock, {
code: children?.props?.children ?? '',
language: children?.props?.className?.replace('language-', '') ?? '',
});
}
};
---
<BlogPost {...post.data}>
<MDXContent components={pageComponents} />
</BlogPost>
The client script can get all divs by class "remote-island", read the props, and call hydrateRoot with the component and props:
<script>
// import { createElement } from 'react', etc.
import CodeBlock from '../components/CodeBlock';
const components: Record<string, ComponentType<any>> = { CodeBlock };
document.querySelectorAll('.remote-island').forEach((node) => {
const name = node.getAttribute('data-component');
const props = JSON.parse(node.getAttribute('data-props') || '{}');
const Component = components[name!];
if (!name || !Component) return;
hydrateRoot(node as HTMLElement, createElement(Component, props));
});
</script>
. It handles the virtual module, runtime MDX compilation, server-side island wrapping, and client hydration automatically. You can register your components once, and the package handles the rest!
Are you fetching remote content from dev.to for your blog site or another source? I'd love to hear about it.
Cover photo by
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
SOCIAL SHARE CARD GENERATOR