Create a CodeBlock Component for MDX
Last updated
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.jsImport 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, "&")
.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 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_ALIASESrather 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.