Kate Bartolo

Rendering Remote Content in Astro with React

Here’s the problem: You want to pull remote Markdown into your site, but you want to use your React components too, not plain HTML.

I hit this wall when trying to fetch posts from dev.to and render them using custom components, like my interactive 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.

I built astro-mdx-remote to solve this problem automatically. But read on to find out how it all works!

Code samples are illustrative of the approach and are simplified for clarity. Your setup will look different.

Why Not Astro Islands?

The problem is that Astro’s Markdown pipeline serializes Markdown to HTML at build time before you can adjust it. The question is: at what point can you inject your own components?

You can’t use Astro’s built-in island system here since islands also work by letting Astro find your components at build time. When you write <MyReactComponent client:load /> in a .astro file, Astro server-renders it, bundles the JS, and manages the hydration automatically. But when fetching content remotely, your components aren’t referenced anywhere Astro can see them.

To make this work, you have to use JSX/TSX components in your content, even if they’re not interactive. And you have to bypass the Astro pipeline.

Experiment 0: Baseline

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 render(). This works fine for plain Markdown content. There is no component control, and you get whatever HTML the Markdown pipeline produces, but your posts are live on your site.

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 following example creates a loader that fetches articles, and renders the raw Markdown to HTML using Astro’s renderMarkdown:

// Loader
function devToLoaderBase(username: string): Loader {
  return {
    name: 'devto-loader-baseline',
    load: async ({ store, parseData, generateDigest, renderMarkdown }) => {
      const articles = await fetchDevToArticles(username);
      store.clear();

      for (const article of articles) {
        const { id, data, digest } = await parseArticle(article, {
          parseData,
          generateDigest,
        });
        store.set({
          id,
          data,
          digest,
          rendered: await renderMarkdown(article.body_markdown),
        });
      }
    },
  };
}

export const collections = {
  devToBaseline: defineCollection({
    loader: devToLoaderBase('username'),
    // Schema matches the dev.to data payload shape (`data` above)
    schema: z.object({
      title: z.string(),
      slug: z.string(),
      description: z.string(),
      publishedAt: z.date(),
      markdown: z.string(),
      html: z.string(),
    }),
  }),
}

You can then create a dynamic router to render the fetched Markdown in an Astro layout. Astro will automatically use the rendered HTML data:

// [...slug].astro
---
import { getCollection, render } from 'astro:content';
import Blog from '../layouts/Blog.astro';

export async function getStaticPaths() {
  const posts = await getCollection('devToBaseline');

  return posts.map((post) => ({
    params: { slug: post.data.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);
---

<Blog {...post.data}>
  <Content />
</Blog>

Finding: This is good enough if you don’t need components. But what happens when you do?

See it live: Not much going on here. It’s just HTML!

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) {
        // ...
        
        // Render the raw Markdown to HTML using gray-matter
        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:

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,
        };
      }
      ...

This adds attributes like this 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>

This works, but createRoot discards the server-rendered HTML and remounts from scratch, causing a visible flash.

Switching to hydrateRoot will not fix the flash. hydrateRoot 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 <pre> HTML from the Markdown pipeline. So hydrateRoot has nothing valid to attach to. It will throw a mismatch warning and fall back to a full remount anyway, which is exactly the same outcome as createRoot.

Finding: Client-side hydration via DOM querying works but is the wrong shape for Astro’s model. 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.

See it live: Inspect the page to look for the data-component attributes. The CodeBlock component renders, but there is a significant flash where you see the HTML first, then the mounted component.

The Real Problem

The HTML that React is trying to hydrate doesn’t match what it expects to render.

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 called hydration: the server renders the HTML first, the client attaches to it.

Hydration requires:

  1. 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 this.
  2. A way to intercept the component rendering on the server to wrap each one in a hydration island.

Astro’s render() gives you a <Content /> 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 @mdx-js/mdx itself:

async function compileMdx(content: string): Promise<MDXContent> {
  const compiled = String(
    await compile(content, { outputFormat: 'function-body' }),
  );
  const { default: MDXContent } = await run(compiled, {
    ...runtime,
    baseUrl: import.meta.url,
  });
  return MDXContent;
}

This requires telling the server how to map the pre and code HTML to the CodeBlock component:

// src/components/PreWrapper.tsx
import CodeBlock from '../components/CodeBlock';
import { type ReactElement } from 'react';

interface CodeChild {
  children?: string;
  className?: string;
}

export default function PreWrapper({ children }: { children?: ReactElement<CodeChild> }) {
  const code = children?.props?.children ?? '';
  const language = children?.props?.className?.replace('language-', '') ?? '';
  return <CodeBlock code={code} language={language} />;
}

This creates a React component you can add directly to your server-side code. You can now pass a components map to it as a prop, and the components will render on the server without a rehype plugin.

// [...slug].astro
...

const MDXContent = await compileMdx(post.data.markdown);
---
<BlogPost {...post.data}>
  <MDXContent components={{ pre: PreWrapper }} />
</BlogPost>

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 onClick are never added.

Finding: If your components have no interactivity, this is enough. The component renders on the server with no flash.

See it live: The CodeBlock component renders, but the Copy button doesn’t work yet.

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 adding an island wrapper to tell the client JS where and how to hydrate the server-rendered HTML:

// [...slug].astro
import CodeBlock from '../components/CodeBlock';

...

const pageComponents = {
  pre: (props: Record<string, unknown>) => {
    const children = props.children as { props?: { children?: string; className?: string } } | undefined;
    
    return renderIsland('CodeBlock', CodeBlock, {
      code: children?.props?.children ?? '',
      language: children?.props?.className?.replace('language-', '') ?? '',
    });
  }
};

---
<BlogPost {...post.data}>
  <MDXContent components={pageComponents} />
</BlogPost>

The renderIsland function returns an HTML element with data-component and data-props attributes. This is similar to the rehype solution above, but we name the component and serialize the props explicitly here. Note that the div this creates is given the class name “remote-island”.

// 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),
    dangerouslySetInnerHTML: { __html: staticHtml },
  });
}

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.

The client script can get all divs by class “remote-island”, read the props, and call hydrateRoot with the component and props:


<script>
// same imports as above
import CodeBlock from '../components/jsx/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!];
  hydrateRoot(node, createElement(Component, props));
});
</script>

Finally, no flash, and we have interactivity!

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.

See it live: With this experiment, the Copy button works!

Final Solution

The final solution is manual hydration islands:

  1. From the server, mark an element so the client knows it needs to become interactive.
  2. Make sure the component’s initial HTML is already in the page (server-rendered) before the client loads.
  3. Reattach the React component to that existing HTML on the client, without removing and re-rendering from scratch.

The flash isn’t a client-side problem you can hack your way out of. It’s a server-rendering problem. The solution is always to server render the component first, then hydrate it.

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. Otherwise, you need to list and map your components manually in two places. This could be a topic for a whole other post.

Rather than manually rendering MDX or maintaining duplicate component registries on client and server, you can use astro-mdx-remote. 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 Jonathan Cooper on Unsplash