Skip to Content
  • Website
Codsen
  • Home
  • Open Source
  • Articles
  • About

Syntax highlighting in Remix

by Roy Revelt   ·   posted on Sunday, 12 June 2022   ·   jstsreactremixmetamdx

  • the top
  • MDX SYNTAX HI…
  • NON-MDX SYNT…
  • THE FUN PART

Syntax highlighting is done using either highlight.jsopens in a new tab or prism.jsopens in a new tab. Once you’ve picked one, you’ll have to set it up for code blocks: 1. within MDX and 2. outside MDX. That’s how it works on all kinds of frameworks, not just Remix, although I’ll post examples for Remix.

This setup is mostly a trivial task; however, there are a few niggles worth mentioning.


MDX syntax highlighting

At the moment of writing, Remix.js 1.5.1 is not natively in ES modulesopens in a new tab but vast majority of MDX ecosystem packages are. This introduces extra hurdles because we have to import pure ESM packages asynchronously.

Remix documentationopens in a new tab explains pretty well how to deal with ESM and MDX sectionopens in a new tab explains how to asynchronously import MDX plugins which are in ESM, just you’ll need to glue all pieces together.

In essence, if you picked highlight.js, you’ll use Rehype plugin rehype-highlightopens in a new tab (instructionsopens in a new tab).

You’ll notice that out of the box, the Remix config is exporting all settings as a plain object:

/**
 * @type {import('@remix-run/dev/config').AppConfig}
 */
module.exports = {
  appDirectory: "app",
  assetsBuildDirectory: "public/build",
  publicPath: "/build/",
  serverBuildDirectory: "build",
  devServerPort: 8002,
  ignoredRouteFiles: [".*"],
};

Just like Remix docs MDX section hinted with export.mdx =, it’s probably the cleanest way to separate all exports from now:

exports.mdx = async () => {};
exports.appDirectory = ...;
exports.assetsBuildDirectory = ...;
exports.publicPath = ...;
exports.serverBuildDirectory = ...;
exports.devServerPort = ...;
exports.ignoredRouteFiles = ...;

The Remix documentation didn’t mention this, and I had to reference the CommonJS exports API documentation.

From here on, it’s just a matter of assembling it all up. Here’s our current remix.config.js for an example (beware it’s a Vercel deployment, mind the serverBuildTarget below):

const { remarkMdxFrontmatter } = require("remark-mdx-frontmatter");
const rehypeFigure = require("rehype-figure");

// can be an sync / async function or an object
exports.mdx = async (filename) => {
  const [rehypeHighlight, rehypeAutolinkHeadings, rehypeSlug, remarkGfm, visit] = await Promise.all([import("rehype-highlight").then((mod) => mod.default), import("rehype-autolink-headings").then((mod) => mod.default), import("rehype-slug").then((mod) => mod.default), import("remark-gfm").then((mod) => mod.default), import("unist-util-visit").then((mod) => mod.visit)]);

  const mappings = {
    bash: "terminal",
    console: "terminal",
    ini: "jinja",
    arduino: "any",
  };

  function rehypeRelCodeBlockTitles() {
    return (tree) => {
      visit(tree, "element", (node, i, parent) => {
        let retrieved;
        if (
          node.tagName === "pre" &&
          Array.isArray(node.children) &&
          node.children.length &&
          typeof node.children[0] === "object" &&
          node.children[0].tagName === "code" &&
          Array.isArray(node.children[0]?.properties?.className) &&
          node.children[0]?.properties?.className.some((className) => {
            if (!retrieved && className.startsWith("language-") && className.length > 9) {
              retrieved = className;
              return true;
            }
            return false;
          })
        ) {
          const extractedName = retrieved.slice(9);
          node.properties["data-syntax"] = mappings[extractedName] || extractedName;

          const replacement = {
            type: "element",
            tagName: "div",
            properties: {
              class: "syntax-container",
            },
            children: [node],
          };
          parent.children[i] = replacement;
        }
      });
    };
  }

  return {
    remarkPlugins: [remarkGfm, remarkMdxFrontmatter],
    rehypePlugins: [
      rehypeSlug,
      [
        rehypeAutolinkHeadings,
        {
          properties: {
            className: ["anchor"],
          },
          test: ["h2"],
        },
      ],
      [
        rehypeFigure,
        {
          className: "image-container",
        },
      ],
      rehypeHighlight,
      rehypeRelCodeBlockTitles,
    ],
  };
};

exports.cacheDirectory = "./node_modules/.cache/remix";
exports.ignoredRouteFiles = ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"];
// When running locally in development mode, we use the built in remix
// server. This does not understand the vercel lambda module format,
// so we default back to the standard build output.
exports.server = process.env.NODE_ENV === "development" ? undefined : "./server.js";
exports.serverBuildTarget = "vercel";
exports.serverDependenciesToBundle = [/^rehype-/, /^remark-/, /^unified-/, /^micromark-/, /^unist-/, /^mdast-/, /^hast-/, /^vfile-/, /^estree-/, "@codsen/data", "tsd-extract", "marked", "string-strip-html", "string-unfancy", "extract-search-index", "is-language-code"];

To sum up, notice separate parts:

  • non-ESM MDX plugins are required normally, like back in the day, they go at the very top.
  • ESM MDX plugins are imported asynchronously and assembled into Promise.all.
  • custom plugin rehypeRelCodeBlockTitles() is to wrap pre+code blocks with a div and to read and set data-syntax attribute which gets content-written into a CSS pseudo element :before (CSS-Tricksopens in a new tab style) — better than rehype-code-titlesopens in a new tab plugin.
  • serverDependenciesToBundle at the bottom is maintained manually; all pure ESM packages we use need to be listed there.

Non-MDX syntax highlighting

Our code examples (including Quick Take) come from @codsen/data package (npm pageopens in a new tab, sourceopens in a new tab, generation scriptopens in a new tab) because open source monorepo is a separate repository from this website’s repository.

The task becomes to syntax-highlight any arbitrary string, considering the challenge of keeping both MDX and non-MDX highlighting identical.

It’s probably not worth to use an npm package like react-highlightopens in a new tab because your own component will be leaner and better-typed. It’s a small component anyway:

// app/components/highlight/highlight.tsx
import hljs from "highlight.js";
import { useLoaded } from "~/utils/misc";

interface HighlightProps {
  language: "js" | "ts" | "shell";
  children: React.ReactNode;
}
export const Highlight = ({ language, children }: HighlightProps) => {
  const loaded = useLoaded();

  return loaded ? (
    <div className="syntax-container">
      <pre data-syntax={language}>
        <code
          className={`hljs language-${language}`}
          dangerouslySetInnerHTML={{
            __html: hljs.highlight(String(children), {
              language,
              ignoreIllegals: true,
            }).value,
          }}
        />
      </pre>
    </div>
  ) : null;
};

The useLoaded() React hook is an essential part:

export const useLoaded = () => {
  let [loaded, setLoaded] = useState(false);
  useEffect(() => {
    setLoaded(true);
  }, []);
  return loaded;
};

You must ensure you dangerously set inner HTML after React’s render has finished (“been committed to the screen”); otherwise, code boxes will be empty.

TIP #1: It’s possible to trim down the highlight.js to load only the languages you needopens in a new tab. Unrecognised languages would trigger TS alerts, reminding us.

TIP #2: It’s a challenge to DRY both highlighting implementations, MDX and non-MDX. We have to ensure that:

  • General HTML markup is the same (this wrapper div is custom, added on the fly in MDX via rehypeRelCodeBlockTitles() you saw above):
<div class="syntax-container">
  <pre data-syntax="ts">
<code class="hljs language-ts">
<span class="hljs-*">...</span>
</code>
</pre>
</div>
  • Ensure the label overriding is identical — for example, we display the terminal label, but actually, the syntax is called shell.
  • Also, probably not that common in Remix projects, but theoretically, if CSS-in-JS was used, for example, Styled Components, the non-MDX component would be using JS-based CSS, yet the MDX components would still reference the global CSS stylesheet. You’d have to find ways to DRY both.
  • Also, tackle the no-name code blocks. They might be processed separately if auto-detection is on.

The fun part

Once you have set up the syntax highlighting, the most fun part starts — customising your own colour scheme. But that’s for another article.

↑ back to top
older newer

Copyright

All rights reserved © Roy Revelt 2026
All our open source packages are under MIT licenceopens in a new tab

Activities

🐛 See a bug? Raise an issueopens in a new tab
💘 Check out the Indiewebopens in a new tab and Libera manifestoopens in a new tab