Implementing Advanced Syntax Highlighting with Line Numbers and Copy Functionality in Next.js Blog
Software development
12 September 2025

Implementing Advanced Syntax Highlighting with Line Numbers and Copy Functionality in Next.js Blog

A comprehensive guide on adding professional code syntax highlighting with line numbers and copy-to-clipboard functionality to a Next.js blog using Prism.js, including solutions for hydration issues and responsive design.

When building a technical blog, one of the most important features is proper code syntax highlighting. After struggling with readability issues in my blog's code blocks, I decided to implement a comprehensive solution that includes syntax highlighting, line numbers, and copy functionality. Here's how I built it and the challenges I overcame.

The Problem

My Next.js blog was displaying code blocks with basic styling, but lacked:

  • Syntax highlighting for different programming languages
  • Line numbers for better code reference
  • Copy-to-clipboard functionality for user convenience
  • Proper formatting that preserves indentation and line breaks
  • Responsive design that works across different screen sizes

Additionally, I encountered hydration mismatch errors when trying to implement client-side enhancements.

The Solution: A Client-Side Syntax Highlighter

I created a comprehensive solution using Prism.js that processes code blocks after hydration, avoiding server-client mismatches while providing rich functionality.

Step 1: Installing Dependencies

First, I installed the necessary packages:

pnpm install prismjs

Step 2: Creating the SyntaxHighlighter Component

The core component handles client-side code enhancement:

"use client";

import React, { useEffect, useRef, useState } from "react";

interface SyntaxHighlighterProps {
  children: React.ReactNode;
}

export function SyntaxHighlighter({ children }: SyntaxHighlighterProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [isLoaded, setIsLoaded] = useState(false);
  const [isMounted, setIsMounted] = useState(false);

  // Prevent hydration mismatch
  useEffect(() => {
    setIsMounted(true);
  }, []);

  useEffect(() => {
    if (!isMounted) return;
    
    let prism: any = null;

    const loadPrism = async () => {
      try {
        // Dynamically import Prism
        prism = await import('prismjs');
        
        // Load additional languages with error handling
        const languageImports = [
          () => import('prismjs/components/prism-bash'),
          () => import('prismjs/components/prism-python'),
          () => import('prismjs/components/prism-javascript'),
          // TypeScript extends JavaScript; ensure JSX/TSX are available too
          () => import('prismjs/components/prism-typescript')
            .then(() => import('prismjs/components/prism-jsx'))
            .catch(() => {}),
          () => import('prismjs/components/prism-tsx'),
          () => import('prismjs/components/prism-json'),
          () => import('prismjs/components/prism-yaml'),
        ];

        // Load languages that are available
        for (const loadLanguage of languageImports) {
          try {
            await loadLanguage();
          } catch (err) {
            console.debug('Language component not available:', err);
          }
        }

        setIsLoaded(true);
        // Slight delay to ensure DOM is hydrated before processing
        setTimeout(processCodeBlocks, 250);
      } catch (error) {
        console.error('Failed to load Prism:', error);
      }
    };

    const processCodeBlocks = () => {
      if (!containerRef.current || !prism || typeof window === 'undefined') return;

      const codeBlocks = containerRef.current.querySelectorAll('pre code');
      
      codeBlocks.forEach((codeBlock) => {
        const pre = codeBlock.parentElement as HTMLPreElement;
        if (!pre) return;

        // Skip if already processed
        if (pre.querySelector('.code-header')) return;

        const code = codeBlock.textContent || '';
        const className = codeBlock.className;
        
        // Extract language
        const languageMatch = className.match(/language-(\w+)/);
        let language = languageMatch ? languageMatch[1] : 'text';
        // Handle common aliases used in Markdown fences
        if (language === 'ts') language = 'typescript';
        if (language === 'js') language = 'javascript';
        
        // Create enhanced code block structure
        const wrapper = createCodeWrapper(code, language, prism);
        pre.parentNode?.replaceChild(wrapper, pre);
      });
    };

    loadPrism();
  }, [isMounted]);

  return (
    <div ref={containerRef}>
      {children}
    </div>
  );
}

Step 3: Building the Enhanced Code Block Structure

The key to this implementation is creating a structured layout with header, line numbers, and content areas:

function createCodeWrapper(code, language, prism) {
  // Create main wrapper
  const wrapper = document.createElement('div');
  wrapper.className = 'relative group mb-6';
  
  // Create header with language and copy button
  const header = document.createElement('div');
  header.className = 'code-header flex items-center justify-between px-4 py-2 bg-gray-800 text-gray-200 text-sm font-mono rounded-t-lg border-b border-gray-700';
  
  // Language label
  const langSpan = document.createElement('span');
  langSpan.className = 'text-gray-400';
  langSpan.textContent = language;
  
  // Copy button with functionality
  const copyBtn = createCopyButton(code);
  
  header.appendChild(langSpan);
  header.appendChild(copyBtn);
  
  // Create code container with line numbers
  const codeContainer = createCodeContainer(code, language, prism);
  
  wrapper.appendChild(header);
  wrapper.appendChild(codeContainer);
  
  return wrapper;
}

Step 4: Implementing Line Numbers

One of the trickiest parts was getting line numbers to align properly:

function createLineNumbers(code) {
  const lines = code.split('\n');
  // Remove only the last line if it's empty (common with markdown code blocks)
  if (lines.length > 1 && lines[lines.length - 1].trim() === '') {
    lines.pop();
  }
  
  const lineNumbers = document.createElement('div');
  lineNumbers.className = 'flex flex-col text-right text-gray-500 text-sm font-mono leading-6 pr-4 pl-4 py-4 select-none bg-gray-800/50 border-r border-gray-700';
  
  lines.forEach((_, index) => {
    const lineSpan = document.createElement('span');
    lineSpan.className = 'block';
    lineSpan.textContent = (index + 1).toString();
    lineNumbers.appendChild(lineSpan);
  });
  
  return lineNumbers;
}

Step 5: Adding Copy-to-Clipboard Functionality

The copy button provides visual feedback and handles the clipboard API:

function createCopyButton(code) {
  const copyBtn = document.createElement('button');
  copyBtn.className = 'flex items-center gap-1 h-8 px-2 text-gray-400 hover:text-gray-200 hover:bg-gray-700 rounded transition-colors';
  
  copyBtn.innerHTML = `
    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 012 2v2h2a2 2 0 012-2V5a2 2 0 00-2-2H10a2 2 0 00-2 2z"/>
    </svg>
    <span>Copy</span>
  `;
  
  copyBtn.addEventListener('click', async () => {
    try {
      await navigator.clipboard.writeText(code);
      // Show success feedback
      copyBtn.innerHTML = `
        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
        </svg>
        <span>Copied!</span>
      `;
      setTimeout(() => {
        // Reset button after 2 seconds
        copyBtn.innerHTML = originalHTML;
      }, 2000);
    } catch (err) {
      console.error('Failed to copy:', err);
    }
  });
  
  return copyBtn;
}

Step 6: Styling with CSS

I added comprehensive CSS for the Dracula theme and proper formatting:

/* Prism.js Syntax Highlighting Theme */
code[class*="language-"],
pre[class*="language-"] {
  color: #f8f8f2;
  background: none;
  text-shadow: 0 1px rgba(0, 0, 0, 0.3);
  font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
  font-size: 1em;
  text-align: left;
  white-space: pre;
  word-spacing: normal;
  word-break: normal;
  word-wrap: normal;
  line-height: 1.5;
  -moz-tab-size: 4;
  -o-tab-size: 4;
  tab-size: 4;
  -webkit-hyphens: none;
  -moz-hyphens: none;
  -ms-hyphens: none;
  hyphens: none;
}

/* Token styles (Dracula theme) */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
  color: #6272a4;
}

.token.punctuation {
  color: #f8f8f2;
}

.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
  color: #ff79c6;
}

.token.boolean,
.token.number {
  color: #bd93f9;
}

.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
  color: #50fa7b;
}

.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string,
.token.variable {
  color: #f8f8f2;
}

.token.atrule,
.token.attr-value,
.token.function,
.token.class-name {
  color: #f1fa8c;
}

.token.keyword {
  color: #8be9fd;
}

.token.regex,
.token.important {
  color: #ffb86c;
}

Important: Align line heights for numbers vs. code

By default I render the line-number gutter with Tailwind's leading-6 (1.5rem). To prevent drift between the gutter and the code content, force the code line-height to the same value in rem units:

/* Keep code and gutter perfectly aligned */
code[class*="language-"] {
  line-height: 1.5rem !important; /* matches Tailwind leading-6 */
}

Step 7: Integration with Blog Posts

Finally, I wrapped the blog content with the SyntaxHighlighter component:

// In blog post page
import { SyntaxHighlighter } from "@/components/syntax-highlighter";

export default function BlogPost({ post }) {
  return (
    <div className="glass-card rounded-xl p-8 mb-8">
      <SyntaxHighlighter>
        <div
          className="prose prose-lg max-w-none"
          dangerouslySetInnerHTML={{ __html: post.content }}
        />
      </SyntaxHighlighter>
    </div>
  );
}

Challenges and Solutions

1. Hydration Mismatch Error

Problem: React hydration errors when modifying DOM on client-side.

Solution: Added proper mounting checks and delays:

const [isMounted, setIsMounted] = useState(false);

useEffect(() => {
  setIsMounted(true);
}, []);

useEffect(() => {
  if (!isMounted) return;
  // Process code blocks only after mounting
}, [isMounted]);

2. Line Break Preservation

Problem: Code formatting was breaking, with lines running together.

Solution: Proper whitespace handling and matched line height:

pre[class*="language-"] {
  white-space: pre;
  line-height: 1.5;
}
enhancedCode.style.whiteSpace = 'pre';
enhancedCode.style.display = 'block';
// Ensure CSS sets line-height to 1.5rem to match gutter

3. Line Number Mismatch (blank lines and unit mismatch)

Problem: Some visual lines had no line number, or numbers drifted out of sync with code.

Causes:

  • Filtering out blank lines when generating the gutter (numbers skipped empty rows).
  • Unit mismatch between gutter (leading-6 = 1.5rem) and code (line-height: 1.5 unitless).

Solution:

  • Preserve internal blank lines; only trim a single trailing empty line common in fenced blocks.
  • Force code line-height to 1.5rem to match the gutter.
// Preserve internal blanks and drop only a single trailing empty line
const raw = code.replace(/\r\n?/g, '\n').split('\n');
const lines = raw[raw.length - 1].trim() === '' ? raw.slice(0, -1) : raw;

3. Missing Language Components

Problem: Prism.js throwing errors for unavailable language components.

Solution: Graceful error handling:

for (const loadLanguage of languageImports) {
  try {
    await loadLanguage();
  } catch (err) {
    console.debug('Language component not available:', err);
  }
}

Features Delivered

The final implementation provides:

Syntax highlighting for 6+ programming languages
Line numbers with proper alignment
Copy-to-clipboard functionality with visual feedback
Language detection and display
Dark theme with professional colors
Responsive design that works on all screen sizes
No hydration errors with proper SSR handling
Performance optimized with dynamic imports

Performance Considerations

  • Lazy loading: Prism.js is loaded only when needed
  • Dynamic imports: Language components are loaded on-demand
  • Client-side processing: No impact on server-side rendering performance
  • Small bundle: Only loads languages that are actually used

Usage

To use this in your own Next.js blog:

  1. Install prismjs
  2. Create the SyntaxHighlighter component
  3. Add the CSS styles to your global stylesheet
  4. Wrap your blog content with the component
  5. Use standard markdown code blocks with language identifiers
\`\`\`javascript
console.log('Hello, World!');
\`\`\`

This implementation has significantly improved the readability and user experience of my technical blog posts, making code examples much more accessible and professional-looking.

The solution is flexible, maintainable, and can be easily extended to support additional languages or features as needed.