Sergio Xalambrí

Parse Markdown with Markdoc in Remix

Markdoc is this new Markdown parser by Stripe, and it's a simple to use yet extendable library we can use in our Remix applications.

Markdown service

First, we can create our server-side service to parse Markdown.

// app/services/markdown.server.ts
import { parse, transform, type RenderableTreeNodes } from "@markdoc/markdoc";

export function markdown(markdown: string): RenderableTreeNodes {
  return transform(parse(markdown));
}

Markdown component

Now, we can create a Markdown component to get that RenderableTreeNodes (the JSON) to React elements.

// app/components/markdown.tsx
import { renderers, type RenderableTreeNodes } from "@markdoc/markdoc";
import * as React from "react";

type Props = { content: RenderableTreeNodes };

export function Markdown({ content }: Props) {
  return <>{renderers.react(content, React)}</>;
}

Usage in a route

Finally, we can use both on our route.

// app/routes/articles.$article.tsx
import { json, type LoaderArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Markdown } from "~/components/markdown";
import { parseMarkdown } from "~/services/markdown.server";

export function loader({ params }: LoaderArgs) {
  let markdown = await getArticleContent(params.article);
  return json({ content: parseMarkdown(markdown) });
}

export default function Index() {
  let { content } = useLoaderData<typeof loader>();
  return <Markdown content={content} />;
}

Adding custom tags and components

Like MDX, Markdoc let us use custom React components we can then use in our Markdown content. We do this by extending both the service and component we created.

Let's say we want to add a counter inside the Markdown. We could create a component like this one and export a scheme object to use server-side.

import { useState } from "react";

type CounterProps = {
  initialValue: number;
};

export function Counter({ initialValue }: CounterProps) {
  let [count, setCount] = useState(initialValue);
  return (
    <div>
      <output>{count}</output>
      <button onClick={() => setCount((current) => current + 1)} type="button">
        Increment
      </button>
      <button onClick={() => setCount((current) => current - 1)} type="button">
        Decrement
      </button>
    </div>
  );
}

export let scheme = {
  render: Counter.name,
  description: "Displays a counter with the initial value provided",
  children: [],
  attributes: {
    initialValue: {
      type: Number,
      default: 0,
    },
  },
};

Now we can change our Markdown service to use the scheme.

import { parse, transform, type RenderableTreeNodes } from "@markdoc/markdoc";
import { scheme as counter } from "~/components/counter";

export function parseMarkdown(markdown: string): RenderableTreeNodes {
  return transform(parse(markdown), {
    tags: { counter },
  });
}

And our Markdown component includes the Counter.

import { renderers, type RenderableTreeNodes } from "@markdoc/markdoc";
import * as React from "react";
import { Counter } from "./counter";

type Props = { content: RenderableTreeNodes };

export function Markdown({ content }: Props) {
  return <>{renderers.react(content, React, { components: { Counter } })}</>;
}

With this, our Markdown can reference the Counter using the {% counter /%} tag. We can even provide the value for the props.

{% counter initialValue=10 /%}

And the best thing is that we don't have to change our route!