Code snippets are a big part of technical blogs. If WordPress (or any CMS) is feeding HTML into a Next.js app, it’s simple to add client-side syntax highlighting, a copy-to-clipboard button, and a small language badge on each block. This post shows a minimal, production-ready approach using Highlight.js.
What we’ll build
- Syntax highlighting for
<pre><code>blocks coming from a CMS - A “Copy” button that turns into a check icon when copied, then reverts
- A language badge (e.g., tsx, js, html) in the corner of each block
- Clean integration with Next.js App Router
Why client-side?
When content is delivered as HTML (e.g., via dangerouslySetInnerHTML), the simplest approach is to highlight after hydration. This also avoids modifying CMS output and keeps Markdown/blocks flexible.
Install and theme
- Install Highlight.js:
npm install highlight.js
- Import a theme globally (in app/layout.tsx before your globals.css):
import "highlight.js/styles/github-dark.css";
import "./globals.css";
You can swap the theme later (atom-one-dark.css, stackoverflow-dark.css, etc.).
Create a reusable component
This client component:
- Injects HTML from the CMS
- Highlights all code blocks
- Adds a copy button and language badge to each block
Create components/blog/HighlightedContent.tsx:
"use client";
import { useEffect, useRef } from "react";
import hljs from "highlight.js";
type Props = { html: string };
const LANG_LABELS: Record<string, string> = {
js: "js", javascript: "js",
ts: "ts", typescript: "ts",
tsx: "tsx", jsx: "jsx,
html: "html", xml: "html",
css: "css", json: "json",
bash: "bash", shell: "sh", sh: "sh",
python: "py", go: "go", java: "java",
c: "c", cpp: "cpp", csharp: "c#",
php: "php", ruby: "rb", swift: "swift",
kotlin: "kt", sql: "sql", plaintext: "text",
};
function extractLanguageLabel(codeEl: Element): string | null {
const langClass = Array.from(codeEl.classList).find(c => c.startsWith("language-"));
if (!langClass) return null;
const id = langClass.replace("language-", "").toLowerCase();
return LANG_LABELS[id] ?? id;
}
export default function HighlightedContent({ html }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// 1) Highlight all code blocks
container.querySelectorAll("pre code").forEach((block) => {
const el = block as HTMLElement;
if (![...el.classList].some(c => c.startsWith("language-"))) {
el.classList.add("language-plaintext");
}
hljs.highlightElement(el);
});
// 2) Enhance each pre: add copy button and language badge
container.querySelectorAll("pre").forEach((pre) => {
const preEl = pre as HTMLElement;
// Ensure room for controls
const cs = window.getComputedStyle(preEl);
if (cs.position === "static") preEl.style.position = "relative";
if (parseFloat(cs.paddingTop || "0") < 28) preEl.style.paddingTop = "2rem";
// Copy button (clipboard icon -> check icon -> clipboard)
if (!pre.querySelector(".copy-btn")) {
const COPY_ICON = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z"/>
</svg>`;
const CHECK_ICON = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 16.17l-3.88-3.88L3.71 13.7 9 19l12-12-1.41-1.41z"/>
</svg>`;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "copy-btn absolute top-2 right-2 rounded-full bg-neutral-800/80 text-white text-xs p-2 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-indigo-500";
btn.setAttribute("aria-label", "Copy code to clipboard");
btn.innerHTML = COPY_ICON;
preEl.appendChild(btn);
btn.addEventListener("click", async () => {
const codeEl = pre.querySelector("code");
const text = codeEl?.textContent ?? "";
const originalHTML = btn.innerHTML;
const showCheck = () => {
btn.classList.add("bg-green-700");
btn.innerHTML = CHECK_ICON;
setTimeout(() => {
btn.classList.remove("bg-green-700");
btn.innerHTML = originalHTML;
}, 1200);
};
try {
await navigator.clipboard.writeText(text);
showCheck();
} catch {
// Fallback for older browsers
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
showCheck();
}
});
}
// Language badge (top-left)
if (!pre.querySelector(".lang-badge")) {
const codeEl = pre.querySelector("code");
if (codeEl) {
const label = extractLanguageLabel(codeEl);
if (label) {
const badge = document.createElement("div");
badge.className =
"lang-badge absolute top-2 left-2 text-[10px] uppercase tracking-wide " +
"rounded-md bg-neutral-800/80 text-neutral-200 px-2 py-0.5 " +
"border border-neutral-700";
badge.textContent = label;
pre.appendChild(badge);
}
}
}
});
}, [html]);
return (
<article
ref={containerRef}
className="prose prose-invert prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
Use it in a page
Keep the page server-rendered; pass the CMS HTML to the client component:
// app/blog/[slug]/page.tsx
import HighlightedContent from "@/components/blog/HighlightedContent";
// fetch post via your CMS code...
export default async function Page() {
const post = await getPostSomehow(); // your data fetching
return (
<div className="container max-w-4xl mx-auto px-4 py-24">
<h1 className="text-4xl font-bold mb-6">{post.title}</h1>
<HighlightedContent html={post.content} />
</div>
);
}
Style touch-ups
Add these to your global CSS for consistent spacing and visibility:
.prose pre,
pre {
position: relative;
padding-top: 2rem; /* room for badge + copy button */
border-radius: 10px;
overflow: auto;
}
.copy-btn { z-index: 2; }
.lang-badge { z-index: 1; }
To change the block background or token colors, either switch the Highlight.js theme or override the theme styles after import:
/* Example override */
pre code.hljs,
.hljs {
background: #0b1220 !important;
color: #e6edf3 !important;
}
Testing the setup
Paste a post body that includes:
- Plain code:
<pre><code class="language-js">
function greet(name) {
console.log(`Hello, ${name}!`);
}
</code></pre>
- HTML as code (escaped), labeled as markup:
<pre><code class="language-html">
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Test</title></head>
<body><h1>Hello</h1></body>
</html>
</code></pre>
- Blocks without a language class (auto-detection will try; you can default to plaintext).
You should see:
- Highlighted code
- A small “tsx/js/html” badge in the top-left
- A copy icon that becomes a check on click, then reverts
Tips and variations
- Smaller bundle: import “highlight.js/lib/core” and register only needed languages (javascript, typescript, xml, css).
- Line numbers: add a lightweight plugin or render them with CSS counters.
- MDX: If using MDX instead of CMS HTML, you can swap to a build-time highlighter (e.g., Shiki) for SSR highlighting—but the client approach here is great for CMS-driven HTML.
That’s it. With this pattern, any WordPress or CMS-rendered code block in Next.js gets highlighted, copyable, and clearly labeled—without changing the CMS content itself.
