Create a CodeBlock Component for MDX
Last updated
When you write a fenced code block in MDX — created by containing your code within 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 note walks through building a CodeBlock component and wiring it into next-mdx-remote so every fence in a note or case study gets the same treatment.
Stack:
- Next.js App Router
next-mdx-remote/rsc- highlight.js 11
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.
Pick a syntax highlighter
You need something that turns a raw code string plus a language id into marked-up HTML. Common lightweight options:
- highlight.js — register only the languages you use; large ecosystem of themes
- Prism — similar model, popular in static sites and many theme ports
- Shiki — TextMate grammars, very accurate colors, heavier setup
This site uses highlight.js. Install it:
npm install highlight.jsImport from highlight.js/lib/core and register only the languages you actually use rather than importing the full library.
Add a highlight helper
Create src/lib/highlight.ts. Register languages once, map common fence aliases (sh -> bash, ts -> typescript), and export a single function the component can call:
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
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 keeping:
- 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_ALIASESrather than guessing in the component.
Build CodeBlock
Create src/components/ui/CodeBlock/CodeBlock.tsx. This is a Server Component — no "use client". It trims the fence string, calls highlightCode, and renders the result:
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>
);
}Why dangerouslySetInnerHTML? highlight.js returns an HTML string with <span class="hljs-keyword"> markup. React will not parse that as elements if you pass it as {highlighted} inside <code>. The innerHTML path is the standard approach for pre-rendered highlight output.
Why trim? MDX fences often include a trailing newline. Trimming keeps the copy button from copying an extra blank line.
Add a copy button
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>
);
}CodeBlock (server) renders CopyButton (client). Next.js draws the boundary automatically — you do not wrap anything in a provider. Swap the placeholder characters above for SVG icons when you polish the UI.
Wire it through compileMDX
Notes and case studies on this site are MDX files on disk — not React pages. Something has to read that string and turn it into renderable output. That job belongs to compileMDX from next-mdx-remote/rsc, which runs on each note and work detail page:
import { compileMDX } from "next-mdx-remote/rsc";
import { getMDXComponents } from "@/lib/mdxComponents";
const { content: MDXContent } = await compileMDX({
source: content,
components: getMDXComponents(),
});compileMDX does two things at once:
- Parse the MDX string into a React tree — headings, paragraphs, fenced code, and JSX like
<Aside>all become elements you can render as{MDXContent}. - Apply your component map — swap default HTML elements and register custom components before that tree is returned.
CodeBlock is only one entry in that map. The same getMDXComponents() also wires <Aside>, <Figure>, and custom heading components. You are not calling compileMDX because of CodeBlock specifically; you need it for any MDX content page. CodeBlock is just the override that replaces the default <pre> output for fenced code.
Override pre, not code: MDX nests them, and the language class and raw string both live on the child.
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 MDX rendering for the site.
Style the block
Split styling across three layers:
- Layout —
CodeBlock.module.cssfor spacing, border, monospace font, horizontal scroll. - Copy button —
CopyButton.module.cssfor the button itself; position it from the wrapper (absolute top-right, reveal on hover). - Token colors — highlight.js theme rules scoped under
[data-theme="light"]and[data-theme="dark"]in something likesrc/styles/hljs-themes.css, imported fromglobals.css.
The .hljs class on <code> is what highlight.js targets. Your theme file sets colors on .hljs-keyword, .hljs-string, and so on. The block's background and border come from design tokens (--color-code-bg, --color-code-border) so light and dark mode stay in sync with the rest of the site.
Minimal wrapper styles to start:
.wrapper {
position: relative;
margin-block: var(--space-6);
}
.copyButton {
position: absolute;
top: var(--space-2);
right: var(--space-2);
z-index: 1;
}
.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);
overflow-x: auto;
}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. That note adds an opt-in wide flag on the fence, a remark plugin to carry it through, and the layout CSS to make it work without breaking a sticky sidebar.