Syntax Highlighting with Prism.js and Next.js

Syntax Highlighting with Prism.js and Next.js

Integrating syntax highlighting into a Next.js project has evolved with the latest Next.js versions (12–14) and React Server Components. This guide shows a modern, performant approach to syntax highlighting using Prism.js, including lazy highlighting, a copy-to-clipboard button, and MDX integration.

1. Install Dependencies

Install Prism.js and a tree-shakable icon library for copy buttons:

bash
Copy code
npm install prismjs
npm install https://github.com/react-icons/react-icons/releases/download/v5.4.0/react-icons-all-files-5.4.0.tgz

The @react-icons/all-files package allows importing only the icons you need, keeping bundle size small.

2. Import Prism Styles

In your app/layout.tsx (or _app.tsx if using the Pages Router), import your Prism CSS:

ts
Copy code
import "@/styles/prism.css";

Download a custom Prism CSS theme here: Prism Download. Save it under styles/prism.css.

3. Create a Syntax Highlighter Component

Highlight code only when visible using IntersectionObserver:

tsx
Copy code
// SyntaxHighlighter.tsx
"use client";

import Prism from "prismjs";
import { useEffect, useRef } from "react";

// Import only needed Prism languages
import "prismjs/components/prism-bash";
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
import "prismjs/components/prism-python";
import "prismjs/components/prism-sql";
import "prismjs/components/prism-yaml";
import "prismjs/components/prism-nginx";
import "prismjs/components/prism-git";
import "prismjs/components/prism-json";
import "prismjs/components/prism-docker";
import "prismjs/components/prism-powershell";

interface SyntaxHighlighterProps {
  language?: string;
  code?: string;
}

const SyntaxHighlighter = ({ language, code }: SyntaxHighlighterProps) => {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            Prism.highlightAllUnder(entry.target);
          }
        });
      },
      { rootMargin: "100%" }
    );

    if (ref.current) observer.observe(ref.current);
    return () => {
      if (ref.current) observer.unobserve(ref.current);
    };
  }, []);

  return (
    <div ref={ref}>
      <pre className={`language-${language}`} tabIndex={0}>
        <code className={`language-${language}`}>{code?.trim() ?? ""}</code>
      </pre>
    </div>
  );
};

export default SyntaxHighlighter;

4. Add a Copy-to-Clipboard Button

Provide instant copy feedback with icons:

tsx
Copy code
// CopyCodeButton.tsx
"use client";

import { FaCopy } from "@react-icons/all-files/fa/FaCopy";
import { FaCheck } from "@react-icons/all-files/fa/FaCheck";
import { useState } from "react";
import styles from "./CopyCodeButton.module.css";

export default function CopyCodeButton({ children }) {
  const [copied, setCopied] = useState(false);

  const handleClick = () => {
    navigator.clipboard.writeText(children.props.children);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <div className={styles.copyButton} onClick={handleClick} title="Copy code">
      <div className={styles.copyWrapper}>
        {copied ? (
          <>
            <FaCheck className={`${styles.icon} ${styles.iconCopied}`} /> Copied!
          </>
        ) : (
          <>
            <FaCopy className={`${styles.icon} ${styles.iconCopy}`} /> Copy code
          </>
        )}
      </div>
    </div>
  );
}

5. Integrate with MDX / Markdown

Override the code component in your MDX renderer:

tsx
Copy code
// renderers.tsx
import SyntaxHighlighter from "./SyntaxHighlighter";
import CopyCodeButton from "./CopyCodeButton";
import styles from "./Markdown.module.css";

export const markdownComponents = {
  code: ({ inline, className, children, ...props }) => {
    const match = /language-(\w+)/.exec(className || "");
    if (!inline && match) {
      return (
        <div className={styles.codeBlock}>
          <SyntaxHighlighter language={match[1]} code={children} />
          <CopyCodeButton>{children}</CopyCodeButton>
        </div>
      );
    }
    return (
      <code className={styles.defaultCode} {...props}>
        {children}
      </code>
    );
  },
};

6. Wrap MDX Content in a Client Component

Here’s how to pass your custom renderers to MDX using next-mdx-remote:

tsx
Copy code
// mdxWrapper.tsx
"use client";

import { MDXRemote, MDXRemoteProps } from "next-mdx-remote";
import { markdownComponents as renderers } from "../renderers/renderers";
import styles from "./mdxWrapper.module.css";

const MDXWrapper: React.FC<{ content: MDXRemoteProps }> = ({ content }) => {
  return (
    <div className={`${styles.contentWrapper} markdown`}>
      <MDXRemote {...content} components={renderers} />
    </div>
  );
};

export default MDXWrapper;

This ensures:

  • Syntax highlighting is client-side only (avoiding SSR hydration issues).
  • Custom MDX renderers (e.g., SyntaxHighlighter, CopyCodeButton) are applied.
  • Styles and copy functionality work seamlessly for all code blocks.

7. Benefits

  • Lazy highlighting improves performance.
  • Only required Prism languages are loaded.
  • Users can copy code with a single click.
  • Fully compatible with Next.js App Router + MDX.
  • Can still switch to pre-rendered highlighting (rehype-prism-plus or rehype-pretty-code) for build-time performance if desired.

This approach is fully Next.js 14 / React 18 ready, client-friendly, and keeps bundle size minimal.

Table of Contents