LLMs have enabled us to solve a new class of problems with more flexibility than ever, but as they are language models, they are inherently text powered, which has led to AI-based UI being incredibly text heavy.
As someone who has been creating experiences with web technology my entire life, I’m not satisfied with so much UI being replaced with text. At Vetted, we have been building a shopping research assistant, and shopping is an inherently visual and UI heavy space. Products need to display images and the UI needs to present structured data to allow users to navigate between products and compare them.
Over the years we have been experimenting with new ways to incorporate rich UI components into our LLM responses. We’ve done this by supplementing our text payloads with structured product data that can be rendered as product cards and research components, but we weren’t happy with these elements being split out from the core of the text answer. So, I set out to come up with a way to allow the LLM output to incorporate UI components into our markdown output.
MDX Enhanced Dynamic Markdown
Check out react-markdown-with-mdx . It’s an HOC (Higher-Order Component) wrapper around react-markdown that enhances its markdown processing to support JSX component tags. You can register white-listed React components with it, ensuring only a safe subset of JSX component tags can be rendered. The library comes with an optional validation helper function, mdxComponent which takes your React components with a zod validator for validating the JSX attributes.
This enables you to prompt your LLM calls to generate JSX tags that can be consumed safely with a clean and easy integration. Here’s what it looks like in action in our prototype UI at Vetted:
The code looks something like this:
import ReactMarkdown from " react-markdown " import { withMdx , mdxComponent , type MdxComponents } from " react-markdown-with-mdx " const MdxReactMarkdown = withMdx ( ReactMarkdown ) interface MdxMarkdownRendererProps { markdown : string } const MdxMarkdownRenderer : React . FC < MdxMarkdownRendererProps > = ({ markdown , }) => { return ( < MdxReactMarkdown components = { components } > { markdown } MdxReactMarkdown > ) } const components : MdxComponents = { " card-carousel " : mdxComponent ( MdxCardCarousel , z . object ({ children : z . any () }) ), " editorial-card " : mdxComponent ( MdxEditorialCard , z . object ({ id : z . string (), award : z . string (). optional (), rating : z . string (). optional (), ranking : z . string (). optional (), children : z . any () }) ), " product-card " : mdxComponent ( MdxProductCard , z . object ({ name : z . string () }) ) }
Unlike projects like MCP-UI, these components aren’t loaded in externally via an iframe that needs window message passing to be integrated, and they aren’t relegated into a separate message apart from the main generated text response. These components are processed into framework-native React components that are colocated and embedded directly into the main LLM generated text. It essentially enables HTML component-like behavior in React and other JSX frameworks, allowing you to extend markdown with any UI component you can dream of!
In order to power the response streaming shown in the video, it was also necessary to balance the HTML tag tree and truncate incomplete tags to ensure that the MDX parser could be provided with valid HTML and partial tag tokens would be blocked until they were complete. To enable that I also created html-balancer-stream which allows you to auto close and balance unclosed tags or strip them out until they’re ready for true streaming support. It provides both streaming and non streaming APIs.
How it Works: Powering up AI Markdown responses with MDX
I originally experimented with this by creating a prototype using HTML components. This approach worked well and because HTML components are registered directly with the browser DOM, this meant custom HTML components could be injected as HTML tag strings directly as innerHTML with no additional render hooks. This was fine for a prototype, but I was not satisfied with using HTML injection as a production ready solution. This approach would introduce major security risks and create side effects alongside our rendering framework. I wanted to find an approach to doing this in a way that would be safely, and tightly integrated with React, which is what we use as a rendering framework at Vetted.
For Markdown rendering we already used the library react-markdown to convert the LLM generated markdown text into React components. Markdown already supports adding raw HTML into it but react-markdown rightfully does not support this. It converts HTML tags into sanitized strings as it would be incredibly dangerous to allow arbitrary HTML directly in dynamic markdown content. Also, my goal wasn’t to simply add support for HTML, or HTML components, I wanted to add support for React components in a framework native way.
To determine the best way to do that, I started looking under the hood. react-markdown is powered by the unified project which is a framework and community ecosystem for parsing and transforming syntax trees to be able to combine and convert various languages. react-markdown is actually a relatively thin wrapper around unified to parse the markdown AST, convert it into html, then convert it into React components. It does this by parsing markdown with remark which is a unified markdown processor that converts markdown into an mdast markdown AST, and then converting it into a hast HTML AST using rehype which is a unified HTML processor using a conversion library called remark-rehype . It then uses hast-util-to-jsx-runtime to safely convert the hast AST into JSX components that can be rendered by React (or other JSX based rendering frameworks).
After understanding how react-markdown works, it seemed to me that the best approach to add React component support to markdown would be to extend the remark parsing capabilities to support JSX. Thankfully the react-markdown project also mentioned this in its readme and refers developers wanting to do this to the MDX project which does exactly that, it enables putting JSX syntax directly into Markdown! Problem solved!
Well, of course it’s never that simple. MDX is a very impressive project. So impressive that not only does it allow you to put static JSX components in your Markdown, it supports the entire JSX syntax including imports, exports, and dynamic Javascript expressions, which unfortunately takes us back to square one because that would be incredibly insecure to use for dynamic LLM text output and it would open up all kinds of potential security vulnerabilities. The author of MDX is fully aware of this, which is why the MDX project is not intended for runtime rendering, it’s meant to do compile time rendering of trusted static MDX Markdown for static site generation.
Thankfully, MDX is also built on top of the unified project, and the unified project’s parsing and transformation pipeline is flexible and modular. This meant that while I could not use MDX out of the box, I could use the MDX parser, remark-mdx to build the MDX AST and then create my own parsing pipeline to add the exact subset of functionality I needed to enable static JSX component support to markdown and allow LLM markdown output to include JSX component strings that can be safely converted into JSX runtime components in a framework native way!
To do this, I needed to create a few new libraries. remark-mdx reads the remark mdast AST to detect JSX tags and then parse them into the MDX AST adding them into the mdast syntax tree so the new syntax tree has nodes from both languages. At this point, it was simply a matter of stripping out the dynamic syntax of MDX and preserving only the static syntax. I created rehype-mdx-elements to do this, which converts the static JSX tags into hast AST HTML elements which can then be parsed by the hast-util-to-jsx-runtime library to call out to JSX component functions that are registered with that library. As hast-util-to-jsx-runtime requires developers to explicitly pass in the components that are supported, this means the pipeline is safe from accidentally rendering any arbitrary HTML. A second library, remark-unravel-mdx was needed which simply removes unnecessary paragraph wrappers around MDX component nodes to produce a cleaner component tree (a similar thing is done within the MDX library as well).
Putting it all together, this powers the react-markdown-with-mdx processing pipeline, which enables the MDX to JSX runtime component transformation used in the demo.
Generating LLM JSX Output
To pair this with AI generated output you can simply prompt an LLM with your JSX component tag and attribute schema, as well as the acceptable children, and examples of their usage. LLMs do particularly well at generating static JSX tags as it shares the same syntax as XML which is a language it is very heavily trained on.
As JSX tags and display attributes can quickly get highly coupled with complex data relations, my advice is to keep the attributes the LLMs generate as simple and minimalistic as possible. We did this by having the LLM fill out local by reference ID strings that wrapper components could then lookup in a shared context or data store to populate more detailed props for display components. The only data we ask the LLM to generate are context sensitive derived data points like text snippets from research sources.
As the outputs are simply JSX tags they can conform 1:1 with your existing components meaning your existing documentation and schemas can be shared between your codebase and prompt, allowing you to keep their definitions and versions in sync.