Easy Way to Highlight Code in Next.js (with Copy and Language Badges)

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

  1. Install Highlight.js:
npm install highlight.js
  1. 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.

Leave a Comment

Your email address will not be published. Required fields are marked *