MDX Code Syntax Highlighting with Shiki

@1chooo | Jul 23, 2025 | 4 min read

... views

There was one feature that bothered me while building my portfolio site 1chooo.com: how to support GitHub-style code syntax highlighting in code blocks. I tried many solutions like sugar-high, highlight.js, and react-syntax-highlighter, but I finally found a simpler and more elegant approach — using Shiki.

In this post, I'll walk you through how I implemented syntax highlighting for MDX code blocks using Shiki, in combination with next-mdx-remote 1.

1. Install Dependencies

First, make sure you've installed all the necessary packages.

Info

I use pnpm as my package manager. Make sure it's installed before running the commands below. Or you can also use npm or yarn if you prefer.

pnpm add @shikijs/rehype next-mdx-remote

2. Create a Custom MDX Component

Now let's declare a CustomMDX component to render the MDX content with custom components and MDX options.

import { MDXRemote } from "next-mdx-remote/rsc";

function CustomMDX(props) {
  return (
    <MDXRemote
      {...props}
      components={{ ...components, ...props.components }}
      options={{}} // Add MDX options later
    />
  );
}

3. Build the CodeBlock Component

Next, we'll create a CodeBlock component that supports both syntax highlighting and a copy-to-clipboard button.

"use client";

import React, { useRef, useState } from "react";

interface CodeBlockProps extends React.HTMLAttributes<HTMLElement> {
  children?: React.ReactNode;
}

export function CodeBlock({ className, children, ...props }: CodeBlockProps) {
  const preRef = useRef<HTMLPreElement>(null);

  const getCodeText = () => {
    if (preRef.current) {
      const codeElement = preRef.current.querySelector("code");
      return codeElement?.textContent || "";
    }
    return "";
  };

  return (
    <div className="relative group">
      <pre ref={preRef} data-line-numbers className={className} {...props}>
        {children}
      </pre>
      <div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
        <CopyButton text={getCodeText()} />
      </div>
    </div>
  );
}

interface CopyButtonProps {
  text: string;
  className?: string;
}

export function CopyButton({ text, className }: CopyButtonProps) {
  const [copied, setCopied] = useState(false);

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      setTimeout(() => setCopied(false), 3000);
    } catch (err) {
      console.error("Failed to copy text: ", err);
    }
  };

  const buttonClassName = [
    "absolute top-2 right-2 px-2 py-1 text-xs font-medium",
    "bg-gray-800 text-gray-200 rounded border border-gray-600",
    "hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500",
    "transition-all duration-200",
    copied ? "bg-green-600 hover:bg-green-600" : "",
    className || "",
  ]
    .filter(Boolean)
    .join(" ");

  return (
    <button
      onClick={handleCopy}
      className={buttonClassName}
      aria-label="Copy code to clipboard"
    >
      {copied ? "Copied!" : "Copy"}
    </button>
  );
}

4. Configure Shiki with @shikijs/rehype

Now let's add Shiki support inside the CustomMDX component using the rehype plugin.

We'll use both github-light and github-dark themes as examples. You can browse more Shiki themes here.

import { MDXRemote } from "next-mdx-remote/rsc";
import rehypeShiki from "@shikijs/rehype";

let components = {
  code: (props: ComponentPropsWithoutRef<"code">) => (
    <code className={styles.code} {...props} />
  ),
  pre: ({ ...props }: React.HTMLAttributes<HTMLElement>) => (
    <CodeBlock className={cn(styles.pre)} {...props} />
  ),
  ... // other components
};

function CustomMDX(props) {
  return (
    <MDXRemote
      {...props}
      components={{ ...components, ...props.components }}
      options={{
        mdxOptions: {
          rehypePlugins: [
            [
              rehypeShiki,
              {
                themes: {
                  light: "github-light",
                  dark: "github-dark",
                },
                addLanguageClass: true,
                defaultColor: false,
              },
            ],
          ],
        },
      }}
    />
  );
}

5. Set Up light/dark mode

To make the code blocks responsive to light/dark mode, add the following CSS (in your global stylesheet or a CSS Module):

@media (prefers-color-scheme: dark) {
  pre code span {
    color: var(--shiki-dark) !important;
  }
  pre {
    background-color: var(--shiki-dark-bg) !important;
  }
}

@media (prefers-color-scheme: light) {
  pre code span {
    color: var(--shiki-light) !important;
  }
  pre {
    background-color: var(--shiki-light-bg) !important;
  }
}

This ensures that the proper theme colors are applied automatically based on the user's system preference.

6. Try It Out

You can now test your MDX syntax highlighting by adding the following block:

```ts
console.log("hello MDX");
```

Here's how it renders:

<div class="relative group">
  <pre
    data-line-numbers
    class="shiki shiki-themes github-light github-dark"
    style="--shiki-light: #24292e; --shiki-dark: #e1e4e8; --shiki-light-bg: #fff; --shiki-dark-bg: #24292e;"
  >
    <code class="language-ts">
      <span class="line">
        <span>console.</span>
        {...}
      </span>
    </code>
  </pre>
</div>

If your code block contains the shiki and shiki-themes github-light github-dark classes, then your syntax highlighting setup is working correctly. You'll also see CSS variables like --shiki-light, --shiki-dark, etc., applied to your styles.

Conclusion

This setup gives you clean, GitHub-style syntax highlighting with minimal configuration — and it's fully compatible with MDX, dark mode, and custom components. If you're looking for a beautiful and maintainable way to render code blocks in your blog or portfolio, Shiki is a great choice.

Footnotes

  1. 记一次迁移到Shiki的尝试