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 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 to solve this problem automatically.
But read on to find out how it all works!
Code samples use Astro 5.0+ Content Layer APIs.
The code is simplified for clarity.
You may need to adapt the loader and schema to match your content source.
Why Not Astro Islands?
Astro Islands are Astro's built-in hydration system.
Astro Islands work by scanning your component references at build time.
When you write , 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 fetch the dev.to articles and store them using an Astro content loader.
The baseline means using Astro's pipeline to load the remote Markdown, store it as HTML in a content collection, then render it with Astro's .
This works fine for plain Markdown content.
There is no component control, but your posts are live on your site.
The following example creates a loader that fetches articles and renders the raw Markdown to HTML using Astro's : You can then create a dynamic router to render the fetched Markdown in an Astro layout.
Astro will automatically use the HTML data: See it live: 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 attributes and then mount React components into them.
Instead of using Astro's , you can create your own client island.
First, in your loader, inject a custom rehype plugin () that adds attributes to elements: Here's what the might look like for just the element: This adds attributes to your HTML by mapping to and setting the code language string (in this case "js"): 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 .
From your route, get the new collection, and add a client-side script: See it live: Inspect the page to look for the attributes.
The component renders, but there is a significant flash where you see the HTML first, then the component mounted inside the wrapper.
This works, but discards the server-rendered HTML and remounts from scratch, causing a visible flash.
Switching to will not fix the flash. expects to find HTML that already matches the component's output, since it attaches event listeners to existing markup rather than replacing it.
But that contract requires the server to have rendered the component in the first place.
In this experiment, the server only produced plain HTML, so has nothing valid to attach to.
It will throw a mismatch warning and recover by re-rendering, which is exactly the same outcome as .
Finding: The flash is a server-rendering problem.
To fix the flash, the React component's HTML needs to be in the page before the client loads.
The Real Problem: Hydration Requires Server-Rendered HTML To get component HTML into the page without a flash, the React component needs to be rendered on the server before it's mounted on the client.
That's what hydration is: the server renders the HTML first, the client attaches to it.
Hydration in our case requires: A way to compile a raw MDX string at runtime.
Since Astro's pipeline requires files on disk at build time, we cannot use it for the remote case.
A way to intercept the component rendering on the server to wrap each one in a hydration island.
Astro's gives you a component, but you can't intercept it to server-render each component individually.
The solution is to bypass Astro's MDX pipeline and do the render yourself.
Experiment 2: MDX Compiler Idea: Instead of using Astro's pipeline, we can compile the raw Markdown string at runtime using itself: This requires telling the server how to map the and HTML to the component.
You can't pass directly because MDX compilation gives the a prop (the nested code element) rather than the and props that expects.
Below, bridges that gap by extracting what it needs from the children: The function creates a component that takes a map, which you can use to map those compiled elements to your function: See it live: 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 are 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 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: The replaces , which only mapped props and returned the component directly.
The function does the same mapping but also returns an HTML element wrapper with and attributes, to name the component and serialize the props explicitly.
Note that each this creates is given the class name "remote-island".
Important Caveat: Children are passed to so the server can produce the initial HTML, but they're excluded from 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: The client script can get all divs by class "remote-island", read the props, and call with the component and props: See it live: Finally, no flash, and the Copy button works!
Inspecting the page should show the "remote-island" divs wrapping the code.
Finding: This works, but note that you have to manually import every component on both server and client, which could be hard to maintain if you have more than 1 or 2 components.
Conclusion The flash is a server-rendering problem.
The solution is to server render the component first, then hydrate it.
From the server, mark an element so the client knows it needs to become interactive.
Make sure the component's initial HTML is already in the page (server-rendered) before the client loads.
Reattach the React component to that existing HTML on the client, without removing and re-rendering from scratch.
Two constraints to keep in mind: You can only render components with serializable props.
You have to import each component on both the server and client sides.
For this to work in practice on more than a few components, you would need a Vite virtual module to handle passing components to both the server and client sides.
I built a package to handle that: .
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 co