Note

Create a CodeBlock Component for MDX

When you write a fenced code block in MDX — 3 backticks — the compiler turns it into a plain <pre><code> pair. That works, but it looks like raw monospace text: no syntax colors, no copy button, no consistent spacing with the rest of the site.

This article walks through building a CodeBlock component and wiring it into next-mdx-remote so every MDX file gets the same treatment.

What MDX gives you by default

A fence like this in MDX — three backticks (`) above and below:

echo "hello"

...compiles to something equivalent to:

<pre><code class="language-bash">echo "hello"
</code></pre>

The language tag becomes a language-* class on the inner <code>. MDX does not apply syntax highlighting or add UI — it just passes the string through. To get colors and a copy button, you intercept that <pre> and render your own component instead.

Adding a syntax highlighter

I went with highlight.js. I liked all theme options it offered:

npm install highlight.js

Import from highlight.js/lib/core and register only the languages you actually use rather than importing the full library.

Create a helper

Register languages once and export a single function the component can call.

Create src/lib/highlight.ts:

import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import javascript from "highlight.js/lib/languages/javascript";
import typescript from "highlight.js/lib/languages/typescript";
// ...register others as you need them

hljs.registerLanguage("bash", bash);
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);

const LANG_ALIASES: Record<string, string> = {
  sh: "bash",
  shell: "bash",
  js: "javascript",
  ts: "typescript",
  plaintext: "plaintext",
};

function escapeHtml(code: string): string {
  return code
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

export function highlightCode(code: string, lang: string): string {
  const resolved = LANG_ALIASES[lang] ?? lang;

  if (resolved === "plaintext" || !hljs.getLanguage(resolved)) {
    return escapeHtml(code);
  }

  return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value;
}

Two details worth noting:

  • Fallback: unknown or unsupported languages render as escaped plain text instead of throwing.
  • Aliases: fence labels do not always match highlight.js ids. Map them once in LANG_ALIASES rather than guessing in the component (sh -> bash, js -> javascript).

CodeBlock component

This is a Server Component — no "use client". It trims the fence string, calls highlightCode, and renders the result. In this component, we also add a CopyButton which is detailed in the following section.

Create src/components/ui/CodeBlock/CodeBlock.tsx:

import { highlightCode } from "@/lib/highlight";
import { CopyButton } from "./CopyButton";
import styles from "./CodeBlock.module.css";

export interface CodeBlockProps {
  code: string;
  lang?: string;
}

export function CodeBlock({ code, lang }: CodeBlockProps) {
  const trimmed = code.trim();
  const language = lang ?? "plaintext";
  const highlighted = highlightCode(trimmed, language);

  return (
    <div className={styles.wrapper}>
      <CopyButton code={trimmed} className={styles.copyButton} />
      <pre className={`${styles.pre} language-${language}`}>
        <code
          className={`hljs language-${language}`}
          dangerouslySetInnerHTML={{ __html: highlighted }}
        />
      </pre>
    </div>
  );
}

CopyButton component

CodeBlock (server) renders CopyButton (client).

Copying requires browser APIs (navigator.clipboard) and click state, so the button lives in a separate Client Component:

"use client";

import { useEffect, useRef, useState } from "react";
import styles from "./CopyButton.module.css";

export interface CopyButtonProps {
  code: string;
  className?: string;
}

export function CopyButton({ code, className }: CopyButtonProps) {
  const [copied, setCopied] = useState(false);
  const timeoutRef = useRef<number | null>(null);

  useEffect(() => {
    return () => {
      if (timeoutRef.current !== null) {
        window.clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  async function handleCopy() {
    try {
      await navigator.clipboard.writeText(code);
      setCopied(true);
      if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
      timeoutRef.current = window.setTimeout(() => setCopied(false), 1600);
    } catch {
      setCopied(false);
    }
  }

  return (
    <button
      type="button"
      className={`${styles.button} ${className ?? ""}`}
      onClick={handleCopy}
      aria-label={copied ? "Copied" : "Copy code"}
    >
      {copied ? "✓" : "⎘"}
    </button>
  );
}

Wire it through compileMDX

import { compileMDX } from "next-mdx-remote/rsc";
import { getMDXComponents } from "@/lib/mdxComponents";

const { content: MDXContent } = await compileMDX({
  source: content,
  components: getMDXComponents(),
});

That call lives on the route that renders the MDX page.

You are not calling compileMDX because of CodeBlock specifically; you need to import it for any MDX content page. CodeBlock is just the override that replaces the default <pre> output for fenced code.

Create src/lib/mdxComponents.tsx:

import type { ReactElement } from "react";
import { CodeBlock } from "@/components/ui/CodeBlock/CodeBlock";
import type { MDXComponents } from "mdx/types";

type CodeChildProps = {
  className?: string;
  children?: string;
};

export function getMDXComponents(): MDXComponents {
  return {
    pre: ({ children }) => {
      const child = children as ReactElement<CodeChildProps>;
      const className = child?.props?.className ?? "";
      const lang = className.replace("language-", "") || "plaintext";
      const code = child?.props?.children ?? "";

      return <CodeBlock code={String(code)} lang={lang} />;
    },
  };
}

Every fenced block in that MDX file now flows through CodeBlock instead of the default <pre>.

Add other overrides (h2, Aside, Figure) to the same return object as you build them — one place controls all custom MDX rendering for the site. In the same object as the pre override above:

return {
  a: (props) => <MdxLink {...props} />,
  h2: (props) => <MdxHeading as="h2" {...props} />,
  h3: (props) => <MdxHeading as="h3" {...props} />,
  Aside,
  Figure,
};

Until that runs in the page file, CodeBlock and getMDXComponents() never touch the rendered note.

Styling

Split styling across three layers: layout, copy button, and highlight.js theme.

Layout

Minimal wrapper styles to start in src/components/ui/CodeBlock/CodeBlock.module.css:

.wrapper {
  position: relative;
  margin-block: var(--space-6);
}

/* Placement + hover-reveal live here because they depend on .wrapper.
   Appearance is CopyButton.module.css; this class is passed in as className. */
.copyButton {
  position: absolute;
  top: var(--space-2);
  right: var(--space-2);
  z-index: 1;
  opacity: 1;
}

@media (hover: hover) {
  .copyButton {
    opacity: 0;
    pointer-events: none;
  }

  .wrapper:hover .copyButton,
  .wrapper:focus-within .copyButton {
    opacity: 1;
    pointer-events: auto;
  }
}

/* Layout overrides — colors come from scoped hljs themes in hljs-themes.css */
.pre:global([class*="language-"]) {
  padding: var(--space-6);
  border-radius: var(--radius-sm);
  border: 1px solid var(--color-code-border);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  line-height: var(--leading-normal);
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.pre code {
  font-family: inherit;
  font-size: inherit;
  line-height: inherit;
}

Copy button

Basic styling in src/components/ui/CodeBlock/CopyButton.module.css for the button itself; position it from the wrapper (absolute top-right, reveal on hover).

.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  padding: 0;
  background: var(--color-code-bg);
  border: 1px solid var(--color-code-border);
  border-radius: var(--radius-sm);
  color: var(--color-text-tertiary);
  cursor: pointer;
  transition:
    opacity var(--duration-fast) var(--ease-base),
    color var(--duration-fast) var(--ease-base),
    border-color var(--duration-fast) var(--ease-base);
}

.button:hover {
  color: var(--color-text-primary);
  border-color: var(--color-border-2);
}

highlight.js theme

The .hljs class on <code> is what highlight.js targets within the CodeBlock.tsx component. Your theme file sets colors on .hljs-keyword, .hljs-string, and so on.

highlight.js theme rules are scoped under [data-theme="light"] and [data-theme="dark"] in something like src/styles/hljs-themes.css. Start by copying the light or dark library within node_modules/highlight.js/styles into that file under the light / dark declarations and curate to your specs. You then import that file from a global stylesheet higher up in your app.

[data-theme="light"] {
  & pre[class*="language-"] {
    background: var(--color-code-bg);
  }
  //...
}

[data-theme="dark"] {
  & pre[class*="language-"] {
    background: var(--color-code-bg);
  }
  //...
}

Follow-up: wide blocks

The baseline CodeBlock stays inside the prose column. If you want certain fences to extend into the space beside the main column — over a gap or sidebar as the reader scrolls — see Wide MDX CodeBlocks.