DEV Community

Cover image for Rendering Remote Content in Astro Using React Components
Kate Bartolo
Kate Bartolo

Posted on

Rendering Remote Content in Astro Using React Components

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 astro-mdx-remote 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 <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 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 render(). 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 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(),
    }),
  }),
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 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: {} },
          },
        });
      }
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 component mounted inside the wrapper.

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, so hydrateRoot has nothing valid to attach to. It will throw a mismatch warning and recover by re-rendering, which is exactly the same outcome as createRoot.

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:

  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 the remote case.
  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:

import { compile, run } from '@mdx-js/mdx';
import * as runtime from 'react/jsx-runtime';
import type { MDXContent } from 'mdx/types';

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;
}
Enter fullscreen mode Exit fullscreen mode

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

You can't pass CodeBlock directly because MDX compilation gives the pre a children prop (the nested code element) rather than the code and language props that CodeBlock expects. Below, PreWrapper bridges that gap by extracting what it needs from the children:

// 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} />;
}
Enter fullscreen mode Exit fullscreen mode

The compileMdx function creates a component that takes a components map, which you can use to map those compiled pre elements to your PreWrapper function:

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

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

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 onClick 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 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 },
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

See it live: Finally, no flash, and the Copy button works! Inspecting the page should show the "remote-island" divs wrapping the CodeBlock 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.

  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.

Two constraints to keep in mind:

  1. You can only render components with serializable props.
  2. 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: 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

Top comments (0)