Syntax highlighting for MDX

December 4th, 20247 min read89 views

Let me introduce you to a Rehype plugin called rehype-pretty and a syntax highlighter called shiki. These are the two tools we will be using to add beautiful syntax highlighting to our MDX files.

Table of contents#

  1. Installation
  2. Using the plugin
    1. next-mdx-remote
    2. @next/mdx
    3. unified
    4. Other ways
  3. Declaring a code block
  4. Styling
    1. Theming
    2. Handling dark and light themes
      1. Using classes to store the preferred theme
      2. Using media queries to determine the preferred theme
    3. Achieving a similar look to my blog
  5. Line numbers
  6. Highlighting
    1. Lines
    2. Words
  7. That's it!

Installation#

First we need to install the required packages into our project.

sh
npm install rehype-pretty-code shiki

Using the plugin#

next-mdx-remote#

Using the next-mdx-remote package.

tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import rehypePrettyCode from "rehype-pretty-code";
 
/** @type {import('rehype-pretty-code').Options} */
const options = {};
 
export function Content({ source }: { source: string }) {
  return (
    <MDXRemote
      source={source}
      options={{
        mdxOptions: {
          ...
          rehypePlugins: [[rehypePrettyCode, options]],
        },
      }}
      components={...}
    />
  );
}

@next/mdx#

Using the @next/mdx package.

The code below can be found at rehype-pretty.pages.dev/#mdx.

ts
import rehypePrettyCode from "rehype-pretty-code";
 
import nextMDX from "@next/mdx";
 
/** @type {import('rehype-pretty-code').Options} */
const options = {};
 
const withMDX = nextMDX({
  extension: /\.mdx?$/,
  options: {
    remarkPlugins: [],
    rehypePlugins: [[rehypePrettyCode, options]],
  },
});
 
/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: true };
 
export default withMDX(nextConfig);

unified#

Using the unified package.

The code below can be found at rehype-pretty.pages.dev/#usage.

ts
import rehypePrettyCode from "rehype-pretty-code";
import rehypeStringify from "rehype-stringify";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
 
async function main() {
  const file = await unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypePrettyCode, {
      // options
    })
    .use(rehypeStringify)
    .process("`const numbers = [1, 2, 3]{:js}`");
 
  console.log(String(file));
}
 
main();

Other ways#

If you're using another way of rendering your Markdown files, you just need to add the plugin to the array of rehype plugins, this can be found in the documentation of the package you're using.

Declaring a code block#

Now that we have the plugin installed and configured, we can start declaring our code blocks. You can declare a code block using 3 backticks (`), like this:

md
```js
function add(a, b) {
  return a + b;
}
```

The code above will look something like this when rendered:

js
function add(a, b) {
  return a + b;
}

Styling#

By default this plugin is unstyled, meaning the styles need to be provided by us.

Theming#

The default theme is github-dark-dimmed, though shiki provides some themes out of the box, which can be defined in the options the following way:

ts
const options = {
  theme: "one-dark-pro",
};

Handling dark and light themes#

The following code will set the --shiki-dark and --shiki-light variables to the respective themes, which will be used to style the code block.

ts
const options = {
  theme: {
    dark: "github-dark",
    light: "github-light",
  },
};

Using classes to store the preferred theme#

css
code[data-theme*=" "],
code[data-theme*=" "] span {
  color: var(--shiki-light);
  background-color: var(--shiki-light-bg);
}
 
html.dark code[data-theme*=" "],
html.dark code[data-theme*=" "] span {
  color: var(--shiki-dark);
  background-color: var(--shiki-dark-bg);
}

Using media queries to determine the preferred theme#

css
code[data-theme*=" "],
code[data-theme*=" "] span {
  color: var(--shiki-light);
  background-color: var(--shiki-light-bg);
}
 
@media (prefers-color-scheme: dark) {
  code[data-theme*=" "],
  code[data-theme*=" "] span {
    color: var(--shiki-dark);
    background-color: var(--shiki-dark-bg);
  }
}

Achieving a similar look to my blog#

To achieve a similar look to the one you see on my blog, you can use the following CSS:

css
pre {
  overflow-x: auto !important;
}
 
pre [data-line] {
  padding-left: 1rem;
  padding-right: 1rem;
}

This adds a scrollbar to the code block when it overflows and adds padding to each line.

Now onto configuring the <pre> component in the markdown-components.tsx file.

tsx
import { MDXComponents } from "mdx/types";
import { reactToText } from "react-to-text";
 
import { CopyButton } from "path/to/copy-button";
 
export const mdxComponents: MDXComponents = {
  ...,
  pre: (props: JSX.IntrinsicElements["pre"] & { "data-language"?: string }) => (
    <>
      <figcaption className="flex items-center justify-between rounded-t-lg border-x border-t border-zinc-200 bg-zinc-100 px-4 py-2 dark:border-zinc-800 dark:bg-zinc-900">
        <span className="text-sm text-zinc-700 dark:text-zinc-300">
          {props["data-language"]}
        </span>
        <CopyButton text={reactToText(props.children)} />
      </figcaption>
      <pre
        className="relative rounded-b-lg rounded-t-none border border-zinc-200 bg-zinc-100 px-0 py-4 text-zinc-900 dark:border-zinc-800 dark:bg-zinc-900 dark:text-[#abb2bf]"
        {...props}
      />
    </>
  ),
  ...
}

Warning If you're using the title="" attribute for your code block, rehype-pretty will create a new <figcaption> component. This will cause 2 <figcaption> components to be rendered. To avoid this, please move the aforementioned component you see in the example above, to figcaption: (props) => () inside of markdown-components.tsx. Though now, you need to make some style adjustments to the component, along with getting the content of the code block for the <CopyButton>.

Feel free to style the components in any way you want. In this example I am using TailwindCSS.

This code uses the react-to-text package to convert the children of the <pre> component to a string and then passes that string to the <CopyButton> component which you need to create yourself.

How it works is actually pretty simple. You just need to provide the text prop to the <CopyButton> component and listen to an onClick event to copy the text to the clipboard.

data-language is the language you've specified in the code block of your MDX file.

The reason why a react fragment (<></>) is used, is because each code block gets wrapped in a <figure> component. Thus the resulting code will look something like this:

html
<figure>
  <figcaption></figcaption>
  <pre>
    <code></code>
  </pre>
</figure>

Line numbers#

CSS counters can be used to add line numbers to your code blocks.

css
pre [data-line] {
  padding-left: 1rem;
  padding-right: 1rem;
}
 
code[data-line-numbers] {
  counter-reset: line;
}
 
code[data-line-numbers] > [data-line]::before {
  counter-increment: line;
  content: counter(line);
  display: inline-block;
  width: 0.75rem;
  margin-right: 1rem;
  text-align: right;
  color: gray;
}
 
code[data-line-numbers-max-digits="2"] > [data-line]::before {
  width: 1.25rem;
}
 
code[data-line-numbers-max-digits="3"] > [data-line]::before {
  width: 1.75rem;
}
 
code[data-line-numbers-max-digits="4"] > [data-line]::before {
  width: 2.25rem;
}

You can then use showLineNumbers to add line numbers to your code blocks.

md
```js showLineNumbers
function add(a, b) {
  return a + b;
}
```
js
function add(a, b) {
  return a + b;
}

Highlighting#

Lines#

You can highlight lines using {2,7}, this will highlight lines 2 and 7. A range can also be specified using {2-7}, this will highlight lines 2 through 7.

md
```js {2,7}
function add(a, b) {
  return a + b;
}
 
const sum = add(1, 2);
 
console.log(sum);
```
js
function add(a, b) {
  return a + b;
}
 
const sum = add(1, 2);
 
console.log(sum);

Words#

To highlight the word add you can use /add/.

md
```js /add/
function add(a, b) {
  return a + b;
}
 
const sum = add(1, 2);
 
console.log(sum);
```
js
function add(a, b) {
  return a + b;
}
 
const sum = add(1, 2);
 
console.log(sum);

That's it!#

Now you can add syntax highlighting to your MDX files in Next.js. If you have any questions or feedback, please don't hesitate to reach out, I'm always happy to help and improve this guide. Almost everything in this guide can be found in the official documentation of rehype-pretty, make sure to check it out.

Enjoyed the article?