diff --git a/index.js b/index.js
index 174bffe..629aec0 100644
--- a/index.js
+++ b/index.js
@@ -6,4 +6,9 @@
  * @typedef {import('./lib/index.js').UrlTransform} UrlTransform
  */
 
-export {Markdown as default, defaultUrlTransform} from './lib/index.js'
+export {
+  MarkdownAsync,
+  MarkdownHooks,
+  Markdown as default,
+  defaultUrlTransform
+} from './lib/index.js'
diff --git a/lib/index.js b/lib/index.js
index 529639c..c88a5a0 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,9 +1,10 @@
 /**
  * @import {Element, ElementContent, Nodes, Parents, Root} from 'hast'
+ * @import {Root as MdastRoot} from 'mdast'
  * @import {ComponentProps, ElementType, ReactElement} from 'react'
  * @import {Options as RemarkRehypeOptions} from 'remark-rehype'
  * @import {BuildVisitor} from 'unist-util-visit'
- * @import {PluggableList} from 'unified'
+ * @import {PluggableList, Processor} from 'unified'
  */
 
 /**
@@ -95,6 +96,7 @@ import {unreachable} from 'devlop'
 import {toJsxRuntime} from 'hast-util-to-jsx-runtime'
 import {urlAttributes} from 'html-url-attributes'
 import {Fragment, jsx, jsxs} from 'react/jsx-runtime'
+import {createElement, useEffect, useState} from 'react'
 import remarkParse from 'remark-parse'
 import remarkRehype from 'remark-rehype'
 import {unified} from 'unified'
@@ -149,26 +151,99 @@ const deprecations = [
 /**
  * Component to render markdown.
  *
+ * This is a synchronous component.
+ * When using async plugins,
+ * see {@linkcode MarkdownAsync} or {@linkcode MarkdownHooks}.
+ *
  * @param {Readonly<Options>} options
  *   Props.
  * @returns {ReactElement}
  *   React element.
  */
 export function Markdown(options) {
-  const allowedElements = options.allowedElements
-  const allowElement = options.allowElement
-  const children = options.children || ''
-  const className = options.className
-  const components = options.components
-  const disallowedElements = options.disallowedElements
+  const processor = createProcessor(options)
+  const file = createFile(options)
+  return post(processor.runSync(processor.parse(file), file), options)
+}
+
+/**
+ * Component to render markdown with support for async plugins
+ * through async/await.
+ *
+ * Components returning promises are supported on the server.
+ * For async support on the client,
+ * see {@linkcode MarkdownHooks}.
+ *
+ * @param {Readonly<Options>} options
+ *   Props.
+ * @returns {Promise<ReactElement>}
+ *   Promise to a React element.
+ */
+export async function MarkdownAsync(options) {
+  const processor = createProcessor(options)
+  const file = createFile(options)
+  const tree = await processor.run(processor.parse(file), file)
+  return post(tree, options)
+}
+
+/**
+ * Component to render markdown with support for async plugins through hooks.
+ *
+ * This uses `useEffect` and `useState` hooks.
+ * Hooks run on the client and do not immediately render something.
+ * For async support on the server,
+ * see {@linkcode MarkdownAsync}.
+ *
+ * @param {Readonly<Options>} options
+ *   Props.
+ * @returns {ReactElement}
+ *   React element.
+ */
+export function MarkdownHooks(options) {
+  const processor = createProcessor(options)
+  const [error, setError] = useState(
+    /** @type {Error | undefined} */ (undefined)
+  )
+  const [tree, setTree] = useState(/** @type {Root | undefined} */ (undefined))
+
+  useEffect(
+    /* c8 ignore next 7 -- hooks are client-only. */
+    function () {
+      const file = createFile(options)
+      processor.run(processor.parse(file), file, function (error, tree) {
+        setError(error)
+        setTree(tree)
+      })
+    },
+    [
+      options.children,
+      options.rehypePlugins,
+      options.remarkPlugins,
+      options.remarkRehypeOptions
+    ]
+  )
+
+  /* c8 ignore next -- hooks are client-only. */
+  if (error) throw error
+
+  /* c8 ignore next -- hooks are client-only. */
+  return tree ? post(tree, options) : createElement(Fragment)
+}
+
+/**
+ * Set up the `unified` processor.
+ *
+ * @param {Readonly<Options>} options
+ *   Props.
+ * @returns {Processor<MdastRoot, MdastRoot, Root, undefined, undefined>}
+ *   Result.
+ */
+function createProcessor(options) {
   const rehypePlugins = options.rehypePlugins || emptyPlugins
   const remarkPlugins = options.remarkPlugins || emptyPlugins
   const remarkRehypeOptions = options.remarkRehypeOptions
     ? {...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions}
     : emptyRemarkRehypeOptions
-  const skipHtml = options.skipHtml
-  const unwrapDisallowed = options.unwrapDisallowed
-  const urlTransform = options.urlTransform || defaultUrlTransform
 
   const processor = unified()
     .use(remarkParse)
@@ -176,6 +251,19 @@ export function Markdown(options) {
     .use(remarkRehype, remarkRehypeOptions)
     .use(rehypePlugins)
 
+  return processor
+}
+
+/**
+ * Set up the virtual file.
+ *
+ * @param {Readonly<Options>} options
+ *   Props.
+ * @returns {VFile}
+ *   Result.
+ */
+function createFile(options) {
+  const children = options.children || ''
   const file = new VFile()
 
   if (typeof children === 'string') {
@@ -188,11 +276,27 @@ export function Markdown(options) {
     )
   }
 
-  if (allowedElements && disallowedElements) {
-    unreachable(
-      'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
-    )
-  }
+  return file
+}
+
+/**
+ * Process the result from unified some more.
+ *
+ * @param {Nodes} tree
+ *   Tree.
+ * @param {Readonly<Options>} options
+ *   Props.
+ * @returns {ReactElement}
+ *   React element.
+ */
+function post(tree, options) {
+  const allowedElements = options.allowedElements
+  const allowElement = options.allowElement
+  const components = options.components
+  const disallowedElements = options.disallowedElements
+  const skipHtml = options.skipHtml
+  const unwrapDisallowed = options.unwrapDisallowed
+  const urlTransform = options.urlTransform || defaultUrlTransform
 
   for (const deprecation of deprecations) {
     if (Object.hasOwn(options, deprecation.from)) {
@@ -212,26 +316,28 @@ export function Markdown(options) {
     }
   }
 
-  const mdastTree = processor.parse(file)
-  /** @type {Nodes} */
-  let hastTree = processor.runSync(mdastTree, file)
+  if (allowedElements && disallowedElements) {
+    unreachable(
+      'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
+    )
+  }
 
   // Wrap in `div` if there’s a class name.
-  if (className) {
-    hastTree = {
+  if (options.className) {
+    tree = {
       type: 'element',
       tagName: 'div',
-      properties: {className},
+      properties: {className: options.className},
       // Assume no doctypes.
       children: /** @type {Array<ElementContent>} */ (
-        hastTree.type === 'root' ? hastTree.children : [hastTree]
+        tree.type === 'root' ? tree.children : [tree]
       )
     }
   }
 
-  visit(hastTree, transform)
+  visit(tree, transform)
 
-  return toJsxRuntime(hastTree, {
+  return toJsxRuntime(tree, {
     Fragment,
     // @ts-expect-error
     // React components are allowed to return numbers,
diff --git a/package.json b/package.json
index ccf0e05..4ab537f 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
   ],
   "dependencies": {
     "@types/hast": "^3.0.0",
+    "@types/mdast": "^4.0.0",
     "devlop": "^1.0.0",
     "hast-util-to-jsx-runtime": "^2.0.0",
     "html-url-attributes": "^3.0.0",
@@ -65,12 +66,14 @@
     "@types/react": "^19.0.0",
     "@types/react-dom": "^19.0.0",
     "c8": "^10.0.0",
+    "concat-stream": "^2.0.0",
     "esbuild": "^0.25.0",
     "eslint-plugin-react": "^7.0.0",
     "prettier": "^3.0.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "rehype-raw": "^7.0.0",
+    "rehype-starry-night": "^2.0.0",
     "remark-cli": "^12.0.0",
     "remark-gfm": "^4.0.0",
     "remark-preset-wooorm": "^11.0.0",
diff --git a/readme.md b/readme.md
index 05080a9..d1ff26c 100644
--- a/readme.md
+++ b/readme.md
@@ -32,6 +32,8 @@ React component to render markdown.
 * [Use](#use)
 * [API](#api)
   * [`Markdown`](#markdown)
+  * [`MarkdownAsync`](#markdownasync)
+  * [`MarkdownHooks`](#markdownhooks)
   * [`defaultUrlTransform(url)`](#defaulturltransformurl)
   * [`AllowElement`](#allowelement)
   * [`Components`](#components)
@@ -166,7 +168,10 @@ createRoot(document.body).render(
 
 ## API
 
-This package exports the following identifier:
+This package exports the identifiers
+[`MarkdownAsync`][api-markdown-async],
+[`MarkdownHooks`][api-markdown-hooks],
+and
 [`defaultUrlTransform`][api-default-url-transform].
 The default export is [`Markdown`][api-markdown].
 
@@ -174,6 +179,47 @@ The default export is [`Markdown`][api-markdown].
 
 Component to render markdown.
 
+This is a synchronous component.
+When using async plugins,
+see [`MarkdownAsync`][api-markdown-async] or
+[`MarkdownHooks`][api-markdown-hooks].
+
+###### Parameters
+
+* `options` ([`Options`][api-options])
+  — props
+
+###### Returns
+
+React element (`JSX.Element`).
+
+### `MarkdownAsync`
+
+Component to render markdown with support for async plugins
+through async/await.
+
+Components returning promises are supported on the server.
+For async support on the client,
+see [`MarkdownHooks`][api-markdown-hooks].
+
+###### Parameters
+
+* `options` ([`Options`][api-options])
+  — props
+
+###### Returns
+
+Promise to a React element (`Promise<JSX.Element>`).
+
+### `MarkdownHooks`
+
+Component to render markdown with support for async plugins through hooks.
+
+This uses `useEffect` and `useState` hooks.
+Hooks run on the client and do not immediately render something.
+For async support on the server,
+see [`MarkdownAsync`][api-markdown-async].
+
 ###### Parameters
 
 * `options` ([`Options`][api-options])
@@ -779,6 +825,10 @@ abide by its terms.
 
 [api-markdown]: #markdown
 
+[api-markdown-async]: #markdownasync
+
+[api-markdown-hooks]: #markdownhooks
+
 [api-options]: #options
 
 [api-url-transform]: #urltransform
diff --git a/test.jsx b/test.jsx
index ccc0b7b..a8bc328 100644
--- a/test.jsx
+++ b/test.jsx
@@ -7,21 +7,29 @@
 
 import assert from 'node:assert/strict'
 import test from 'node:test'
-import {renderToStaticMarkup} from 'react-dom/server'
-import Markdown from 'react-markdown'
+import concatStream from 'concat-stream'
+import {renderToPipeableStream, renderToStaticMarkup} from 'react-dom/server'
+import Markdown, {MarkdownAsync, MarkdownHooks} from 'react-markdown'
 import rehypeRaw from 'rehype-raw'
+import rehypeStarryNight from 'rehype-starry-night'
 import remarkGfm from 'remark-gfm'
 import remarkToc from 'remark-toc'
 import {visit} from 'unist-util-visit'
 
-test('react-markdown', async function (t) {
+const decoder = new TextDecoder()
+
+test('react-markdown (core)', async function (t) {
   await t.test('should expose the public api', async function () {
     assert.deepEqual(Object.keys(await import('react-markdown')).sort(), [
+      'MarkdownAsync',
+      'MarkdownHooks',
       'default',
       'defaultUrlTransform'
     ])
   })
+})
 
+test('Markdown', async function (t) {
   await t.test('should work', function () {
     assert.equal(renderToStaticMarkup(<Markdown children="a" />), '<p>a</p>')
   })
@@ -1078,3 +1086,85 @@ test('react-markdown', async function (t) {
     }
   })
 })
+
+test('MarkdownAsync', async function (t) {
+  await t.test('should support `MarkdownAsync` (1)', async function () {
+    assert.throws(function () {
+      renderToStaticMarkup(<MarkdownAsync children={'a'} />)
+    }, /A component suspended while responding to synchronous input/)
+  })
+
+  await t.test('should support `MarkdownAsync` (2)', async function () {
+    return new Promise(function (resolve, reject) {
+      renderToPipeableStream(<MarkdownAsync children={'a'} />)
+        .pipe(
+          concatStream({encoding: 'u8'}, function (data) {
+            assert.equal(decoder.decode(data), '<p>a</p>')
+            resolve()
+          })
+        )
+        .on('error', reject)
+    })
+  })
+
+  await t.test(
+    'should support async plugins w/ `MarkdownAsync` (`rehype-starry-night`)',
+    async function () {
+      return new Promise(function (resolve) {
+        renderToPipeableStream(
+          <MarkdownAsync
+            children={'```js\nconsole.log(3.14)'}
+            rehypePlugins={[rehypeStarryNight]}
+          />
+        ).pipe(
+          concatStream({encoding: 'u8'}, function (data) {
+            assert.equal(
+              decoder.decode(data),
+              '<pre><code class="language-js"><span class="pl-en">console</span>.<span class="pl-c1">log</span>(<span class="pl-c1">3.14</span>)\n</code></pre>'
+            )
+            resolve()
+          })
+        )
+      })
+    }
+  )
+})
+
+// Note: hooks are not supported on the “server”.
+test('MarkdownHooks', async function (t) {
+  await t.test('should support `MarkdownHooks` (1)', async function () {
+    assert.equal(renderToStaticMarkup(<MarkdownHooks children={'a'} />), '')
+  })
+
+  await t.test('should support `MarkdownHooks` (2)', async function () {
+    return new Promise(function (resolve, reject) {
+      renderToPipeableStream(<MarkdownHooks children={'a'} />)
+        .pipe(
+          concatStream({encoding: 'u8'}, function (data) {
+            assert.equal(decoder.decode(data), '')
+            resolve()
+          })
+        )
+        .on('error', reject)
+    })
+  })
+
+  await t.test(
+    'should support async plugins w/ `MarkdownHooks` (`rehype-starry-night`)',
+    async function () {
+      return new Promise(function (resolve) {
+        renderToPipeableStream(
+          <MarkdownHooks
+            children={'```js\nconsole.log(3.14)'}
+            rehypePlugins={[rehypeStarryNight]}
+          />
+        ).pipe(
+          concatStream({encoding: 'u8'}, function (data) {
+            assert.equal(decoder.decode(data), '')
+            resolve()
+          })
+        )
+      })
+    }
+  )
+})