
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.5unitless).
Solution:
- Preserve internal blank lines; only trim a single trailing empty line common in fenced blocks.
- Force code line-height to
1.5remto 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:
- Install
prismjs - Create the
SyntaxHighlightercomponent - Add the CSS styles to your global stylesheet
- Wrap your blog content with the component
- 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.