JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 473
  • Score
    100M100P100Q93465F

Use MDX to render high quality LLM prompts

Package Exports

  • mdx-prompt
  • mdx-prompt/components
  • mdx-prompt/components/html
  • mdx-prompt/render

Readme

mdx-prompt

mdx-prompt logo

mdx-prompt is a lightweight library enabling React (and MDX) developers to write large language model (LLM) prompts as JSX components. By defining your prompts in JSX or MDX, you can achieve a high degree of composability, reusability, and clarity for both simple and complex prompt designs.

Many developers end up piecing together a big template string for an LLM prompt. mdx-prompt offers an alternative by letting React generate those strings using familiar patterns: you create React components (or MDX files) that produce the final prompt. This approach can make your AI-driven features more maintainable, more testable, and more fun to code!


Table of Contents

  1. Features
  2. Installation
  3. Core Concepts
  4. Default Components and Usage
  5. Rendering Prompts
  6. Creating Your Own Components
  7. JSDoc Reference and Examples
  8. Example: Inline MDX Prompt in a Scorer
  9. Challenges and Caveats
  10. Real-World Usage: Excerpts from bragdoc.ai
  11. Contributing
  12. License

Features

  • Write prompts in JSX or MDX: Use React components to build dynamic, structured, or even XML-like prompts in a readable way.
  • Composable prompt fragments: Compose your prompts from smaller components, passing data around via props.
  • Use MDX files or inline React for your prompts; get the best of both worlds.
  • Works well in Next.js, Node.js CLIs, or custom React render flows: If you can run React on the server to produce a string, you can run mdx-prompt.
  • Easily testable: Thanks to the structured approach, you can feed your prompts mock data in tests and Evals.
  • Lightweight, minimal dependencies: A small wrapper around react-dom/server plus some MDX support from next-mdx-remote.

Installation

npm install mdx-prompt
# or
yarn add mdx-prompt
# or
pnpm add mdx-prompt

You will also need to ensure you have React 18+ installed (as a peer dependency).


Core Concepts

1. Write prompts in React / MDX

Instead of building prompts with big strings, you define them in JSX or MDX:

<Prompt>
  <Purpose>Generate a summary of the user's achievements.</Purpose>
  <Instructions>
    <Instruction>Be concise but comprehensive.</Instruction>
    <Instruction>Focus on the user's documented successes.</Instruction>
  </Instructions>
  <Variables>
    <UserInput>{data.userMessage}</UserInput>
    {/* You can add custom tags or use custom components, too */}
  </Variables>
</Prompt>

Then, mdx-prompt transforms that JSX/MDX into a final string suitable for sending to an LLM API.

2. Composability

mdx-prompt encourages a component-based approach. If you have repeated logic for (say) outputting user achievements, project metadata, or instructions, you can encapsulate them as separate React components. For instance:

export function Company({ company }: { company: CompanyType }) {
  return (
    <company>
      <id>{company.id}</id>
      <name>{company.name}</name>
      ...
    </company>
  );
}

Then in your prompt:

<Variables>
  <Company company={data.company} />
</Variables>

3. Mix structured and unstructured text

Your prompt can combine free text with structured sections (like XML or custom tags). This makes it easier to reason about your prompt, or pass it to another LLM for meta-processing.


Default Components and Usage

mdx-prompt ships with a small set of default “prompt structure” components that you can optionally use. They are all exported from mdx-prompt/src/components/prompt.tsx (or just from the top-level package if you do import { Prompt, Purpose, Instructions, ... } from 'mdx-prompt';).

Below is an overview of each built-in component, how it renders, and typical usage. These are all extremely simple wrappers that produce custom tags, so feel free to customize or create your own.

<Prompt>

Signature:

export function Prompt({ children }: { children: React.ReactNode }) {
  return children;
};

A simple root component that doesn't transform or wrap your content in extra tags, but conceptually indicates "this is the entire prompt." You can nest <Purpose>, <Instructions>, <Variables>, and other sections inside it.

<Purpose>

Signature:

export function Purpose({ children }: { children: React.ReactNode }) {
  return <purpose>{children}</purpose>;
}

Usually near the top, it renders as <purpose> ... </purpose>. Use it to give an overall goal or summary of the prompt.

<Instructions>

Signature:

export function Instructions({
  instructions = [],
  children,
}: {
  instructions?: string[];
  children?: React.ReactNode;
}) {
  return (
    <instructions>
      {instructions.map((instruction) => (
        <Instruction
          key={instruction.replace(/\s/g, '')}
          dangerouslySetInnerHTML={{ __html: instruction }}
        />
      ))}
      {children}
    </instructions>
  );
}

Encapsulates a list of instructions. If you have a dynamic set of instructions (as an array of strings), pass them in via the instructions prop. Otherwise, you can nest <Instruction> children.

<Instruction>

Signature:

export function Instruction({
  children,
  dangerouslySetInnerHTML,
}: {
  children?: React.ReactNode;
  dangerouslySetInnerHTML?: { __html: string };
}) {
  return (
    <instruction dangerouslySetInnerHTML={dangerouslySetInnerHTML}>
      {children}
    </instruction>
  );
}

Renders <instruction>...</instruction>. Typically used within <Instructions>.

<UserInput>

Signature:

export function UserInput({ children }: { children: React.ReactNode }) {
  return <user-input>{children}</user-input>;
}

Marks the user-supplied text or data to be processed by the LLM. Often a message or user string.

<Example> and <Examples>

Signature:

export function Example({ children }: { children: React.ReactNode }) {
  return <example>{children}</example>;
}

export function Examples({
  examples = [],
  children,
}: {
  examples?: string[];
  children?: React.ReactNode;
}) {
  return (
    <examples>
      {examples.map((example, i) => (
        <example key={i} dangerouslySetInnerHTML={{ __html: example }} />
      ))}
      {children}
    </examples>
  );
}

Used to provide reference examples or few-shot content. <Examples> can contain one or more <Example> items, or pass an array of strings to examples.

<InputFormat>

Signature:

export function InputFormat({
  children,
  title = 'You are provided with the following inputs:',
}: {
  children: React.ReactNode;
  title?: string;
}) {
  return <input-format title={title}>{children}</input-format>;
}

Renders as <input-format title="...">...</input-format>. Typically used to show the user (or the LLM) what input fields or data have been provided.

<OutputFormat>

Signature:

export function OutputFormat({
  children,
  title = 'Your response should be formatted as:',
  format = '',
}: {
  children?: React.ReactNode;
  title?: string;
  format?: string;
}) {
  return (
    <output-format title={title}>
      {children} {format}
    </output-format>
  );
}

Renders as <output-format>. Often used to specify how you want the LLM’s answer shaped or structured.

<ChatHistory>

Signature:

export function ChatHistory({ messages }: { messages: any[] }) {
  return (
    <chat-history>
      {messages?.map(({ role, content }) => (
        <message key={content.replace(/\s/g, '')}>
          {role}: {content}
        </message>
      ))}
    </chat-history>
  );
}

Simple utility to dump an array of messages into <chat-history>.... You can adapt as needed (for example, if you want each message to be <assistant> or <user> tags, etc.)

<Variables>

Signature:

export function Variables({ children }: { children: React.ReactNode }) {
  return <variables>{children}</variables>;
}

A container for any input data you want to pass, especially if it doesn’t neatly fit in other sections. Sometimes you might place <ChatHistory> inside <Variables>, or custom React components that render your domain data.


Rendering Prompts

Using renderMDX (Inline Usage)

If you’re already in a .ts or .tsx file and want to define your prompt inline (rather than in a .mdx file), you can:

  1. Create a React component that represents your prompt.
  2. Pass that component to renderMDX(...).

Example (adapted from a small CLI script):

import { renderMDX } from 'mdx-prompt';
import {
  Prompt, Purpose, Instructions
} from 'mdx-prompt';

(async () => {
  const MyPrompt = () => {
    return (
      <Prompt>
        <Purpose>To do something interesting</Purpose>
        <Instructions instructions={[
          'List key findings',
          'Prioritize bullet points by significance'
        ]} />
      </Prompt>
    );
  };

  const outputString = await renderMDX(<MyPrompt />);
  console.log(outputString);
})();

Result (approximate):

<purpose>To do something interesting</purpose>
<instructions>
  <instruction>List key findings</instruction>
  <instruction>Prioritize bullet points by significance</instruction>
</instructions>

Using renderMDXPromptFile (MDX File Usage)

Often, you’ll want your LLM prompts in .mdx files, allowing for:

  • Clear separation of text and code
  • More sophisticated MDX flows
  • Reuse in a variety of contexts (like test/Eval data, partial prompts, etc.)

Example .mdx file (extract-achievements.mdx):

<Prompt>
  <Purpose>
    You are a careful and attentive assistant who extracts work achievements
    from user messages. Follow the instructions carefully.
  </Purpose>

  <Instructions>
    <Instruction>Include a clear, action-oriented title for each achievement.</Instruction>
    <Instruction>Do not speculate or invent details.</Instruction>
  </Instructions>

  <InputFormat>{data.message}</InputFormat>
  <Variables>
    <UserInput>{data.message}</UserInput>
  </Variables>
</Prompt>

Your answer:

Then in your code:

import { renderMDXPromptFile } from 'mdx-prompt';
import * as customElements from './my-custom-elements'; 
import path from 'path';

const promptPath = path.resolve(__dirname, './extract-achievements.mdx');

async function renderExtractAchievementsPrompt(data: {
  message: string;
  userId: string;
}) {
  const rendered = await renderMDXPromptFile({
    filePath: promptPath,
    data,
    components: {
      ...customElements,  // your custom React components, e.g. <Company />
    },
  });

  return rendered;
}

// usage
(async () => {
  const promptString = await renderExtractAchievementsPrompt({ 
    message: 'I deployed 3 new microservices last week', 
    userId: '123' 
  });
  console.log(promptString);
})();

Full Example in a Next.js Route

Below is a sketch of how you might do this in a Next.js 15 route. Suppose you have a route at app/api/prompts/[id]/route.ts that returns rendered prompts. You can do:

import { NextResponse } from 'next/server';
import { renderMDXPromptFile } from 'mdx-prompt';
import * as components from '@/app/prompts/elements';
import path from 'path';

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  // e.g. /api/prompts/extract-achievements
  const { id } = params;
  let promptPath: string;
  let data: any = {};

  if (id === 'extract-achievements') {
    promptPath = path.resolve('./prompts/extract-achievements.mdx');
    data = {
      message: 'I built a new payment integration this week.'
    };
  } else {
    return NextResponse.json({ error: 'Prompt not found' }, { status: 404 });
  }

  const promptString = await renderMDXPromptFile({
    filePath: promptPath,
    data,
    components
  });

  return new NextResponse(promptString, { status: 200, headers: {
    'Content-Type': 'text/plain'
  }});
}

Then open [YourApp]/api/prompts/extract-achievements in the browser and see the final rendered text.


Creating Your Own Components

To embed your own domain logic, define React components that output custom tags or text. For instance, if you need a <Company> tag repeated for each company:

// my-custom-elements.ts
import React from 'react';

export function Company({ company }: { company: CompanyType }) {
  return (
    <company>
      <id>{company.id}</id>
      <name>{company.name}</name>
      <role>{company.role}</role>
      {/* ... More fields */}
    </company>
  );
}

export function Companies({ companies }: { companies: CompanyType[] }) {
  return (
    <companies>
      {companies.map((c) => (
        <Company key={c.id} company={c} />
      ))}
    </companies>
  );
}

In your .mdx or JSX:

<Variables>
  <Companies companies={data.companies} />
</Variables>

At runtime, React will convert these components into strings, ultimately producing something like:

<companies>
  <company>
    <id>ACME-123</id>
    <name>ACME Corp</name>
    <role>Lead Engineer</role>
  </company>
  ...
</companies>

JSDoc Reference and Examples

Below are snippets of the JSDoc that you can find throughout the mdx-prompt codebase. We encourage you to scan the source for more details (the code is minimal and quite approachable).

1. renderMDX()

From src/render.ts:

/**
 * Renders MDX content using the default render function and returns the pretty-printed HTML.
 * @param {React.ReactElement} mdxSource - The MDX source element.
 * @returns {string} - The pretty-printed HTML output.
 */
export async function renderMDX(mdxSource: React.ReactElement) {
  return prettyPrompt(renderToStaticMarkup(mdxSource));
}
  • Description: Renders a React element to static markup (using react-dom/server) and then runs it through a formatter so it’s more readable.
  • Use Case: Inline usage or small prompts that are directly declared as React components in the same file.

2. renderMDXPromptFile()

From src/render.ts:

/**
 * Compiles a MDX prompt file and renders it using the default render function.
 * @param {RenderOptions} options - Options for rendering including filePath, and optional data/components.
 * @returns {Promise<string>} - The rendered and pretty-printed HTML string.
 */
export async function renderMDXPromptFile(options: RenderOptions) {
  const { filePath, data, components } = options;
  const content = await compileMDXPromptFile(filePath, data, components);
  return renderMDX(content);
}
  • Description: The main entry point for reading an .mdx file off disk, injecting data into its scope, and returning the final string.
  • Use Case: More robust or multi-file setups, or for prompts that heavily rely on Markdown/MDX.

3. prettyPrompt()

From src/utils.ts:

/**
 * Makes the rendered text + XML prompt a little prettier
 * using rehype-format.
 * @param {string} rawHtml - The raw HTML string to format.
 * @returns {string} - The formatted HTML string.
 */
export function prettyPrompt(rawHtml: string): string {
  const file = unified()
    .use(rehypeParse, { fragment: true })
    .use(rehypeFormat)
    .use(rehypeStringify)
    .processSync(rawHtml);

  return String(file).replace(/^\n/, '');
}
  • Description: Uses the rehype ecosystem to prettify the resulting HTML/XML. This is purely cosmetic to help debug or log your final prompts.
  • Use Case: Typically called from renderMDX() or renderMDXPromptFile() internally. You won’t often call it directly.

Example: Inline MDX Prompt in a Scorer

Below is an example of embedding an inline <Prompt> inside a TypeScript function that uses renderMDX to compare two sets of data with an LLM. This excerpt is from a real-world “LLM as a judge” scenario:

import { LLMClassifierFromSpec, type Score } from 'autoevals';
import { renderMDX } from 'mdx-prompt';
import {
  Prompt,
  Purpose,
  Instructions,
  InputFormat,
  OutputFormat,
  Variables
} from 'mdx-prompt/components';

const outputFormat = `
(A) Perfect match
(B) Good but missing details
(C) Minor issues
(D) Major issues
(E) Completely wrong
`;

const instructions = [
  'Compare the arrays carefully and decide how close they match.',
  'Do not add anything beyond what the user said.'
];

function EvaluatePromptComparison({
  expected,
  output,
}: {
  expected: any;
  output: any;
}) {
  return (
    <Prompt>
      <Purpose>
        Compare the extracted achievements with the expected results.
      </Purpose>
      <Instructions instructions={instructions} />
      <InputFormat>
        <expected>All items that should have been extracted</expected>
        <extracted>All items that were actually extracted</extracted>
      </InputFormat>
      <OutputFormat format={`Answer with a single letter: ${outputFormat}`} />
      <Variables>
        <expected>{JSON.stringify(expected, null, 4)}</expected>
        <extracted>{JSON.stringify(output, null, 4)}</extracted>
      </Variables>
    </Prompt>
  );
}

export async function AchievementsScorer(args: any): Promise<Score> {
  const prompt = await renderMDX(
    <EvaluatePromptComparison
      expected={args.expected}
      output={args.output}
    />
  );

  return LLMClassifierFromSpec('AchievementsScorer', {
    prompt,
    choice_scores: {
      A: 1.0,
      B: 0.8,
      C: 0.6,
      D: 0.3,
      E: 0.0,
    },
  })(args);
}

Here, we define a small React component <EvaluatePromptComparison> that is rendered via renderMDX(...). The final string is then passed to an LLM for classification. This pattern allows your prompt to be just as composable as any other piece of React code.


Challenges and Caveats

React / Next.js Compatibility

  • Partial RSC Incompatibility: In Next.js 15 with React Server Components (RSC), trying to render your MDX or JSX prompt to a string inside an RSC can be tricky or disallowed. A common workaround is to fetch your prompt text via a route or a separate function and feed that into your RSC.
  • Version Mismatches: If your Next.js app uses a locked React version, you might run into version conflicts. Double-check your react/react-dom versions.

TypeScript Chores

  • Custom Tags: If you use XML-like tags (<company>, <project>, etc.), TypeScript will complain that these are not standard HTML elements. You must add them as IntrinsicElements in a .d.ts file. A typical approach:
    // global.d.ts
    import React from 'react';
    type CustomTags = 'purpose' | 'instructions' | 'instruction' 
                     | 'company' | 'project' | 'user-input' /* etc. */;
    
    declare module 'react' {
      namespace JSX {
        interface IntrinsicElements extends Record<CustomTags, any> {}
      }
    }
  • No Perfect Type Checking for .mdx: Because we compile .mdx dynamically, references to data.foo might not get fully type-checked. Usually you can mitigate by carefully matching your known shape in code, but it’s not “strict typed.”

HTML DOM Tag Hoisting Quirks

React’s rendering logic occasionally hoists or merges certain tags (like <title>, <meta>). If you try to use these in an MDX prompt, the final string might differ from what you expect. Use custom tags instead (<doc-title> or <prompt-title>) to avoid special React DOM behaviors.

Non-Standard Import Flow

You typically have to read .mdx from the filesystem (e.g., fs.readFileSync(...)) or rely on renderMDXPromptFile(...). Some bundlers or dev environments may not be aware that .mdx files are required, potentially skipping them. Usually a direct path reference solves it, but it’s a pattern worth noting.


Real-World Usage: Excerpts from bragdoc.ai

The bragdoc.ai project uses mdx-prompt for:

  • Extracting achievements from plain text (extract-achievements.mdx).
  • Extracting achievements from git commits (extract-commit-achievements.mdx).
  • Generating weekly/monthly documents (generate-document.mdx).

Within bragdoc.ai, each .mdx file is accompanied by a TypeScript “orchestrator” that:

  1. Fetches relevant data (projects, companies, user settings).
  2. Renders the .mdx file with those data.
  3. Sends the final string to the LLM (like GPT-4).
  4. Receives structured JSON back, updates the DB.

This pattern highlights how you can combine:

  • Clear separation of “prompt definition” in .mdx
  • Domain data loading in a separate function
  • Reusable React components that produce XML-like prompt sections

The result is a well-organized codebase and easily tested flows.


Contributing

We welcome contributions in the form of bug reports, feature requests, or pull requests. Feel free to open an issue or PR in our GitHub repository.

Steps to get started developing locally:

  1. Clone the repo
  2. npm install or pnpm install
  3. Make changes
  4. Run npm run build to ensure builds pass
  5. Submit a PR

License

MIT License

MIT License

Copyright (c) 2025 Ed Spencer

Permission is hereby granted, free of charge...

We hope mdx-prompt makes your LLM prompts more enjoyable to write, maintain, and test! If you have any questions or find interesting use cases, please share an issue or discussion on GitHub.

Happy prompting!