Prism on a Next.js Blog: Line Numbers, Highlights, and Copy Buttons
Code snippets are a given on any developer blog. With Next.js and Prism.js, you can get more than basic syntax highlighting working on a statically generated site—line numbers, line highlighting, and copy-to-clipboard buttons are all achievable with a bit of configuration and some client-side code.
This guide assumes a Next.js blog starter with Remark for Markdown processing. The same principles generally carry over to other frameworks, but the examples here target that setup.
Installing and Wiring Up Prism
Start by installing the remark-prism plugin, which bridges Remark's Markdown output to Prism's highlighting engine:
npm i remark-prism
Next, open the markdownToHtml file in the /lib folder and enable the plugin:
import remarkPrism from "remark-prism";
// later ...
.use(remarkPrism, { plugins: ["line-numbers"] })
Depending on your version of remark-html, you may also need to set sanitize: false in its options. The complete module should look like this:
import { remark } from "remark";
import html from "remark-html";
import remarkPrism from "remark-prism";
export default async function markdownToHtml(markdown) {
const result = await remark()
.use(html, { sanitize: false })
.use(remarkPrism, { plugins: ["line-numbers"] })
.process(markdown);
return result.toString();
}
Now add Prism's stylesheet and a theme in pages/_app.js. The "Tomorrow Night" theme is used here, alongside an empty prism-overrides.css file for later tweaks:
import "prismjs/themes/prism-tomorrow.css";
import "prismjs/plugins/line-numbers/prism-line-numbers.css";
import "../styles/prism-overrides.css";
With those imports in place, this Markdown:
```js
class Shape {
draw() {
console.log("Uhhh maybe override me");
}
}
class Circle {
draw() {
console.log("I'm a circle! :D");
}
}
```
…renders with proper syntax highlighting:

Adding Line Numbers
The remark-prism plugin supports line numbers, but it doesn't enable them automatically. As the plugin's README notes, you must include the appropriate CSS—and you must force the .line-numbers class onto the generated <pre> tag. Remark allows this via a custom plugin:

That yields numbered code blocks:

Depending on your Prism version and theme, you may need a small override in prism-overrides.css to align the numbers correctly:
.line-numbers span.line-numbers-rows {
margin-top: -1px;
}
Highlighting Specific Lines
Prism's line-highlight plugin inspects the DOM to place highlights. That approach doesn't work during static generation because no DOM exists when the markup is produced. The workaround: declare highlights declaratively with a data attribute, then apply styles via JavaScript on the client.
Base CSS for Highlights
In prism-overrides.css, define custom properties for the highlight's background and width, then make line-number spans relative so a pseudo-element can fill the row:
:root {
--highlight-background: rgb(0 0 0 / 0);
--highlight-width: 0;
}
.line-numbers span.line-numbers-rows > span {
position: relative;
}
.line-numbers span.line-numbers-rows > span::after {
content: " ";
background: var(--highlight-background);
width: var(--highlight-width);
position: absolute;
top: 0;
}
Declaring Highlighted Lines
Add a data-line attribute to the <pre> tag using the same remark plugin approach as the line-numbers class. The attribute accepts comma-separated numbers or ranges:

This renders a data-line="3,8-10" attribute, meaning line 3 and lines 8 through 10 get highlighted.
Reading the Attribute in JavaScript
In components/post-body.tsx, add a ref to the root element:
import { useEffect, useRef } from "react";
const rootRef = useRef<HTMLDivElement>(null);
<div ref={rootRef} className="max-w-2xl mx-auto">
Then use an effect to scan the rendered content for <pre> elements, check for a line-numbers container and the data-line attribute, and set up a highlighting routine:
useEffect(() => {
const allPres = rootRef.current.querySelectorAll("pre");
const cleanup: (() => void)[] = [];
for (const pre of allPres) {
const code = pre.firstElementChild;
if (!code || !/code/i.test(code.tagName)) {
continue;
}
const highlightRanges = pre.dataset.line;
const lineNumbersContainer = pre.querySelector(".line-numbers-rows");
if (!highlightRanges || !lineNumbersContainer) {
continue;
}
const runHighlight = () =>
highlightCode(pre, highlightRanges, lineNumbersContainer);
runHighlight();
const ro = new ResizeObserver(runHighlight);
ro.observe(pre);
cleanup.push(() => ro.disconnect());
}
return () => cleanup.forEach(f => f());
}, []);
The effect grabs every <pre> under the post body. For each one, it confirms a <code> child exists and reads the highlight declarations from dataset.line. If found, it stores the range string (e.g., "3,8-10") and the line-numbers container. It then defines a runHighlight callback and attaches it to a ResizeObserver so highlights reposition if the layout changes.
The highlightCode Function
The core work happens in highlightCode, which parses each range, finds the matching line-number spans, and sets custom properties on them:
function highlightCode(pre, highlightRanges, lineNumberRowsContainer) {
const ranges = highlightRanges.split(",").filter(val => val);
const preWidth = pre.scrollWidth;
for (const range of ranges) {
let [start, end] = range.split("-");
if (!start || !end) {
start = range;
end = range;
}
for (let i = +start; i <= +end; i++) {
const lineNumberSpan: HTMLSpanElement = lineNumberRowsContainer.querySelector(
`span:nth-child(${i})`
);
lineNumberSpan.style.setProperty(
"--highlight-background",
"rgb(100 100 100 / 0.5)"
);
lineNumberSpan.style.setProperty("--highlight-width", `${preWidth}px`);
}
}
}
For each range, it reads the <pre> element's scrollWidth to size the highlight correctly. The background uses a translucent gray (rgb(100 100 100 / 0.5)), but any color works. The result:

Highlighting Without Line Numbers
All of this relies on Prism's line-number rows existing. To hide numbers while keeping highlights, add a .hide-numbers class override:
```js[class="line-numbers"][class="hide-numbers"][data-line="3,8-10"]
class Shape {
draw() {
console.log("Uhhh maybe override me");
}
}
class Circle {
draw() {
console.log("I'm a circle! :D");
}
}
```
Then add CSS that reverts Prism's number-indent padding, squishes the line-number container, hides the generated number spans, and shifts the now-empty line spans back into the correct position:
.line-numbers.hide-numbers {
padding: 1em !important;
}
.hide-numbers .line-numbers-rows {
width: 0;
}
.hide-numbers .line-numbers-rows > span::before {
content: " ";
}
.hide-numbers .line-numbers-rows > span {
padding-left: 2.8em;
}
These rules reverse the 3.8em padding Prism's line-number plugin adds, returning it to the theme's default 1em. The empty line-number spans get moved 2.8em to the right so highlights sit correctly. Values may differ if you swap themes. The final result:

A Copy-to-Clipboard Button
One last enhancement: a button on each code block that copies its contents. The navigator.clipboard.writeText API handles the core task; all that's needed is a button injected next to each <pre> element.
In the same useEffect, append the button creation call:
useEffect(() => {
const allPres = rootRef.current.querySelectorAll("pre");
const cleanup: (() => void)[] = [];
for (const pre of allPres) {
const code = pre.firstElementChild;
if (!code || !/code/i.test(code.tagName)) {
continue;
}
pre.appendChild(createCopyButton(code));
The createCopyButton function builds the button, attaches a click handler, and swaps its text and disabled state briefly after copying:
function createCopyButton(codeEl) {
const button = document.createElement("button");
button.classList.add("prism-copy-button");
button.textContent = "Copy";
button.addEventListener("click", () => {
if (button.textContent === "Copied") {
return;
}
navigator.clipboard.writeText(codeEl.textContent || "");
button.textContent = "Copied";
button.disabled = true;
setTimeout(() => {
button.textContent = "Copy";
button.disabled = false;
}, 3000);
});
return button;
}
The key detail is using codeEl.textContent rather than innerHTML—that strips away Prism's markup and yields the actual source text, preserving indentation and newlines:
navigator.clipboard.writeText(codeEl.textContent || "");
A minimal style for the button, positioned absolutely over the code block, completes the feature:
.prism-copy-button {
position: absolute;
top: 5px;
right: 5px;
width: 10ch;
background-color: rgb(100 100 100 / 0.5);
border-width: 0;
color: rgb(0, 0, 0);
cursor: pointer;
}
.prism-copy-button[disabled] {
cursor: default;
}
This gives you functional copy buttons on every snippet:

Prism wasn't built for server-rendered output, but these techniques bridge that gap cleanly. Highlighting, line numbers, and copy support all work together on a statically generated Next.js site with just a few small additions to your Markdown pipeline and a modest amount of client-side JavaScript.



