Copy to Clipboard Button In MDX with Next.js and Rehype Pretty Code

Updated on · 6 min read
Copy to Clipboard Button In MDX with Next.js and Rehype Pretty Code

Code sharing is an essential aspect of modern web development, enabling developers to collaborate and learn from each other. Many websites make code sharing more accessible by adding a "copy to clipboard" button to their code snippets. This feature is especially useful on mobile devices, providing users with a fast and straightforward method of sharing code fragments without the need to select text manually. Some code highlighting libraries include this functionality, either built-in or through a plugin.

However, if you're using MDX in your Next.js application, there is no one-size-fits-all solution for implementing a "copy to clipboard" button.

This tutorial will guide you on how to create a "copy to clipboard" button for the code snippets processed with Rehype Pretty Code, a popular Rehype plugin for syntax highlighting in an MDX-based Next.js website. We'll be using Next.js 13+ with the "app" directory enabled. By the end of this post, you'll be able to streamline code sharing and enhance the user experience on your Next.js website. In fact, this blog uses the exact functionality we'll be creating, so you can see the result in action. Let's dive in!

Setting up

As usual, we need to install the required dependencies.

bash
npm install rehype-pretty-code shiki unist-util-visit
bash
npm install rehype-pretty-code shiki unist-util-visit

In this project, we're utilizing the Rehype Pretty Code plugin, which offers syntax highlighting for MD or MDX files. This highlighting is performed at build time, resulting in a positive impact on page speed. Additionally, you'll likely require a library for processing the MDX content. This blog uses Contentlayer, which has robust support for MDX processing. However, keep in mind that it's currently in beta and may not be suitable for everyone.

If you want to improve the discoverability of your Next.js website, I have written an article about adding a sitemap to it: Add a Dynamic Sitemap to Next.js Website Using Pages or App Directory

Extracting the code content

Rendering the button in our case presents a challenge since the syntax highlighting is executed on the server side. By the time we intend to render the button on the client side, the code content will already be wrapped in various tags necessary for syntax highlighting. One potential solution is to parse the code content when copying it, removing all HTML markup. However, this approach is inefficient as it would require undoing everything that Rehype Pretty Code has done for us.

Thankfully, there's a better way to achieve our objective. We can extract the unstyled code content before the syntax highlighting stage and append it as a property to the code nodes. This content can then be accessed as props inside our custom MDX component that renders the copy button. I originally came across this idea on a Twitter thread and slightly adapted the code to fit our use case.

To begin, we need to create a visitor function that traverses the node tree of the content and extracts the unmodified (raw text) content from all code elements nested inside the pre tag. We'll store this text content on the pre node itself. To traverse the node tree, we'll use the visit function from the unist-util-visit package. This visitor function should be added to the list of existing Rehype plugins, in the case of Contentlayer, it's the contentlayer.config.js file.

js
{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, ]; }
js
{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, ]; }

This will give us a way to keep the unmodified code content, which we can access later from the node's raw property.

Now we add the configuration for the Rehype Pretty Code plugin.

js
{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, [ rehypePrettyCode, { theme: { dark: "one-dark-pro", light: "github-light", }, // The rest of the rehypePrettyCode config }, ], ]; }
js
{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, [ rehypePrettyCode, { theme: { dark: "one-dark-pro", light: "github-light", }, // The rest of the rehypePrettyCode config }, ], ]; }

It's worth noting that we're utilizing both a light and a dark theme in the following code. The plugin generates two separate code blocks for each theme, with one hidden via CSS depending on the selected theme. To switch between themes, this blog uses the class property, ensuring that only one theme is visible at a time with the following code:

css
html.light[data-theme="dark"] { display: none; } html.dark[data-theme="light"] { display: none; }
css
html.light[data-theme="dark"] { display: none; } html.dark[data-theme="light"] { display: none; }

It's important to note that using two themes has another implication: we must forward the raw property to two separate pre elements, rather than just one. To achieve this, we'll need to implement another visitor function that runs after the syntax highlighting has been completed.

js
{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, [ rehypePrettyCode, { theme: { dark: "one-dark-pro", light: "github-light", }, // The rest of the rehypePrettyCode config }, ], () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "div") { if (!("data-rehype-pretty-code-fragment" in node.properties)) { return; } for (const child of node.children) { if (child.tagName === "pre") { child.properties["raw"] = node.raw; } } } }); }, ]; }
js
{ rehypePlugins: [ () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "pre") { const [codeEl] = node.children; if (codeEl.tagName !== "code") return; node.raw = codeEl.children?.[0].value; } }); }, [ rehypePrettyCode, { theme: { dark: "one-dark-pro", light: "github-light", }, // The rest of the rehypePrettyCode config }, ], () => (tree) => { visit(tree, (node) => { if (node?.type === "element" && node?.tagName === "div") { if (!("data-rehype-pretty-code-fragment" in node.properties)) { return; } for (const child of node.children) { if (child.tagName === "pre") { child.properties["raw"] = node.raw; } } } }); }, ]; }

In the following code, we select all div elements that contain a data-rehype-pretty-code-fragment data attribute. Then, we iterate over the pre children within each div (one for each theme) and add the raw code content as a property to them. With this implementation, a custom MDX component for rendering pre elements will have raw as one of the available props. Next, we'll add this Pre component to our code:

js
export const Pre = ({ children, raw, ...props }) => { const lang = props["data-language"]; return ( <pre {...props} className={"p-0"}> <div className={"code-header"}>{lang}</div> {children} </pre> ); };
js
export const Pre = ({ children, raw, ...props }) => { const lang = props["data-language"]; return ( <pre {...props} className={"p-0"}> <div className={"code-header"}>{lang}</div> {children} </pre> ); };

We also extract the data-language to display the code language of a given snippet. Now we will use it as one of the components inside our MDX renderer.

js
import { useMDXComponent } from "next-contentlayer/hooks"; import { Pre } from "./components/Pre"; const components = { pre: Pre, }; export const Mdx = ({ code }) => { const MDXContent = useMDXComponent(code); return <MDXContent components={components} />; };
js
import { useMDXComponent } from "next-contentlayer/hooks"; import { Pre } from "./components/Pre"; const components = { pre: Pre, }; export const Mdx = ({ code }) => { const MDXContent = useMDXComponent(code); return <MDXContent components={components} />; };

The Contentlayer library handles the MDX rendering, and we use its next-contentlayer package for Next.js integration. This package provides the useMDXComponent hook, which we use to render MDX and pass our custom component via the components object. This allows each pre element in the MDX files to be replaced with our custom Pre component. The content of the pre tags will be accessible through the children prop inside the Pre component.

Adding the CopyButton component

Now we are ready to add the CopyButton component, which will handle the "copy to clipboard" functionality.

To implement the "copy to clipboard" functionality, we can use JavaScript to access the clipboard and React to render the UI. In JavaScript, previously we would use the document.execCommand("copy") method to copy the content to the clipboard. However, this method is no longer recommended and is not supported in some browsers. A more reliable method is to use the Clipboard API which provides a set of asynchronous methods to read and write to the clipboard.

js
"use client"; import { useState } from "react"; export const CopyButton = ({ text }) => { const [isCopied, setIsCopied] = useState(false); const copy = async () => { await navigator.clipboard.writeText(text); setIsCopied(true); setTimeout(() => { setIsCopied(false); }, 10000); }; return ( <button disabled={isCopied} onClick={copy}> {isCopied ? "Copied!" : "Copy"} </button> ); };
js
"use client"; import { useState } from "react"; export const CopyButton = ({ text }) => { const [isCopied, setIsCopied] = useState(false); const copy = async () => { await navigator.clipboard.writeText(text); setIsCopied(true); setTimeout(() => { setIsCopied(false); }, 10000); }; return ( <button disabled={isCopied} onClick={copy}> {isCopied ? "Copied!" : "Copy"} </button> ); };

Note that since we're using the app directory, where all components default to Server Components, we need to explicitly mark this component as the client one via the "use client" directive.

The component itself is quite minimal. When the Copy text is clicked, we store the code content (available via the text prop) using the Navigator.clipboard API. Additionally, we change the button text to Copied! and set a ten-second timeout to reset it.

Finally, we can use this "copy to clipboard" button inside the Pre component.

js
import { CopyButton } from "./CopyButton"; export const Pre = ({ children, raw, ...props }) => { const lang = props["data-language"] || "shell"; return ( <pre {...props} className={"p-0"}> <div className={"code-header"}> {lang} <CopyButton text={raw} /> </div> {children} </pre> ); };
js
import { CopyButton } from "./CopyButton"; export const Pre = ({ children, raw, ...props }) => { const lang = props["data-language"] || "shell"; return ( <pre {...props} className={"p-0"}> <div className={"code-header"}> {lang} <CopyButton text={raw} /> </div> {children} </pre> ); };

The final result should look similar to the Copy button for the code snippets on this post (minus the styling).

Summary

"Copy to clipboard" buttons are an excellent way to improve the accessibility of code snippets, particularly on mobile devices. However, when it comes to creating a "copy to clipboard" button for code snippets that have been processed with Rehype Pretty Code in an MDX-based Next.js application, a specific approach is required. The primary concept is to pass the unprocessed code content as a property of the pre element, which allows the content to be accessed later after syntax highlighting has been applied. This approach is efficient, and it streamlines code sharing, thereby enhancing the user experience on Next.js websites that use MDX and Rehype Pretty Code. The concept behind the CopyButton component is not Next.js-specific, though. It is implemented as a React component that utilizes the Clipboard API provided by JavaScript in the browser.

References and resources