Markdown Syntax Highlighting with the Next.js App Router

A headshot of Colin Hemphill
Written by Colin Hemphill
April 14, 20238 min read
The views expressed on this blog are my own, and do not necessarily reflect those of my employer.
A photo of highlighters, sticky notes, and a notepad on a desk

If you want to display beautiful highlighted code snippets in your application, the new Next.js App Router offers an easy solution using integrated Markdown and MDX rendering.

In a previous blog post I discussed how to tokenize snippets in getStaticProps at build-time so that your statically exported site wouldn't need an expensive client-side highlighting library. We can accomplish the same task with React Server Components without incurring a build-time cost.

The Next.js App Router is in beta at the time of this writing, and is not officially recommended for production. However, they are very close to stable release and I'm confident in its long-term success.

Installation and Setup

In this article I'll use a brand new Next.js application as our starting point, but you can also follow these steps for an existing Next.js project so long as it's using the new App Router.

npx create-next-app@latest --experimental-app

Provide a name for your project and select the defaults for the remaining prompts (this will enable TypeScript, ESLint, Tailwind CSS, and the /src directory).

cd your-project npm run dev

Now hit localhost:3000 in your browser and open the project directory in your code editor. We can go straight to src/app/page.tsx and clear out everything between the <main> tags to give ourselves a blank canvas.

Adding MDX Support

You're probably familiar with Markdown, a lightweight markup language used to write content that can be easily converted to HTML. MDX is a superset of Markdown that lets you write JSX in your Markdown files. We'll add MDX support instead of plain Markdown since it offers more powerful capabilities, but you can still write simple Markdown in these files if you prefer.

Start by installing @next/mdx.

npm install @next/mdx @types/mdx @mdx-js/loader

Then you'll need to create a config file in the root of your application named mdx-components.tsx. The file is required for @next/mdx to work with the App Router, and later you can add components to it that you want to support in your MDX files.

mdx-components.tsx
import type { MDXComponents } from 'mdx/types'; export function useMDXComponents(components: MDXComponents): MDXComponents { return { ...components, }; }

Since we'll be using a plugin that requires ESM, you'll have to enable ESM support by renaming the next.config.js file in your project root to next.config.mjs. Once you do that, update its contents to the following:

next.config.mjs
import nextMDX from '@next/mdx'; const nextConfig = { pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'mdx'], experimental: { appDir: true, }, }; const withMDX = nextMDX({ options: { remarkPlugins: [], rehypePlugins: [], }, }); export default withMDX(nextConfig);

Rendering Markdown

Now let's create some Markdown and display it in your app. Copy the following and add it to a new Markdown file at src/app/example.mdx.

src/app/example.mdx
# My Example Lorem ipsum dolor sit amet, consectetur adipiscing elit. ```tsx const test = 'test'; interface Test { myProp: string; } ```

This example will include a heading, a paragraph, and a TSX code block. To render the Markdown, simply import it in your home page. Here I've imported the MDX as a component and applied a few basic Tailwind styles to the page.

src/app/page.tsx
import Example from './example.mdx'; export default function Home() { return ( <main className="bg-zinc-50 dark:bg-zinc-950 min-h-screen p-24"> <Example /> </main> ); }

If your configuration worked as expected, your app should look something like this:

A screenshot of the starter Next.js application with Markdown rendered on the page

Styling the Markdown

You'll notice that there isn't much visual distinction between elements in your rendered Markdown, so as a short tangent let's apply styles to our MDX components. Here I'll add an h1 component, a p component, and a pre component and add Tailwind classes to each.

mdx-components.tsx
import type { MDXComponents } from 'mdx/types'; export function useMDXComponents(components: MDXComponents): MDXComponents { return { h1: (props) => <h1 {...props} className="mb-4 text-4xl font-bold" />, p: (props) => <p {...props} className="mb-4" />, pre: (props) => ( <pre {...props} className="border-zinc-500 rounded-lg border-2 p-4" /> ), ...components, }; }

To make sure that Tailwind doesn't purge the styles you just added, open your Tailwind config and add mdx-components.tsx to the content list.

tailwind.config.js
/** @type {import('tailwindcss').Config} */ module.exports = { content: [ './src/pages/**/*.{js,ts,jsx,tsx}', './src/components/**/*.{js,ts,jsx,tsx}', './src/app/**/*.{js,ts,jsx,tsx}', './mdx-components.tsx', ], plugins: [], };

Now your heading, paragraph, and code block will be styled.

A screenshot of the same rendered markdown, but sizing and margins are applied to the heading, paragraph, and code block elements

Highlighting the Code

We'll use Highlight.js to handle parsing and tokenizing the code, then we'll add a stylesheet to apply styles to those tokens.

Installation and Setup

npm i highlight.js rehype-highlight tailwind-highlightjs

Rehype is a type of plugin for MDX, and the rehype-highlight plugin we added will parse our code blocks using Highlight.js. Add this plugin in your Next.js config.

next.config.mjs
import nextMDX from '@next/mdx'; import rehypeHighlight from 'rehype-highlight'; const nextConfig = { pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'mdx'], experimental: { appDir: true, }, }; const withMDX = nextMDX({ options: { remarkPlugins: [], rehypePlugins: [rehypeHighlight], }, }); export default withMDX(nextConfig);

Once you make this change, you're almost done! You can now inspect the HTML for your app, and should see that Highlight.js has applied classes to various tokens within the code block, and determined that the code should be tokenized as tsx.

A screenshot of the Chrome inspector showing that hljs has applied classes to elements within the code block such as language-tsx, hljs-keyword, and hljs-string

With these tokens applied, all we need is a stylesheet. Using tailwind-highlightjs we'll import one of the default Highlight.js themes. In the Tailwind config file, add the plugin, a theme, and add hljs to the safelist so the styles won't be purged.

tailwind.config.js
/** @type {import('tailwindcss').Config} */ module.exports = { content: [ './src/pages/**/*.{js,ts,jsx,tsx}', './src/components/**/*.{js,ts,jsx,tsx}', './src/app/**/*.{js,ts,jsx,tsx}', './mdx-components.tsx', ], plugins: [require('tailwind-highlightjs')], safelist: [ { pattern: /hljs+/, }, ], theme: { hljs: { theme: 'atom-one-dark', }, }, };

With the stylesheet added to your project, hljs classes will now produce a highlighted code block!

A screenshot showing that the rendered code block is now highlighted as if it were displayed in a code editor

Next Steps

Remote MDX

This tutorial only covers how to render local MDX files that are committed to your project repo. If you need to fetch MDX from an external source such as your preferred headless CMS, you should follow the Remote MDX guide in the Next.js documentation. Whatever package you use to fetch and render the remote content needs rehype-highlight added to its Rehype plugins in order to parse your code blocks.

Extending Your MDX Components

As a quick exploration for how to use MDX to create more advanced code snippets, let's add a header to your code block. Start by creating a simple header component.

src/app/components/CodeHeader.tsx
interface CodeHeaderProps { text: string; } export default function CodeHeader({ text }: CodeHeaderProps) { return ( <div className="bg-zinc-200 text-neutral-700 dark:bg-zinc-700 dark:text-neutral-300 rounded-t-md px-4 py-2 font-mono text-sm"> {text} </div> ); }

Now import this into your MDX components so that it can be added to Markdown files.

mdx-components.tsx
import CodeHeader from '@/app/components/CodeHeader'; import type { MDXComponents } from 'mdx/types'; export function useMDXComponents(components: MDXComponents): MDXComponents { return { h1: (props) => <h1 {...props} className="mb-4 text-4xl font-bold" />, p: (props) => <p {...props} className="mb-4" />, pre: (props) => <pre {...props} className="rounded-b-lg" />, // remove most of our original styles for the code blocks CodeHeader, // this component can be entered as-is ...components, }; }

And finally, use the component within your Markdown.

src/app/example.mdx
# My Example Lorem ipsum dolor sit amet, consectetur adipiscing elit. <CodeHeader text="TestComponent.tsx" /> ```tsx const test = 'test'; interface Test { myProp: string; } ```

Now your rendered and highlighted code blocks can feature a header to describe the content of the code block.

A screenshot of the highlighted code block with a header that says TestComponent.tsx

Closing

If you build your project with npm run build, you'll note that with the addition of MDX support and syntax highlighting, no additional JavaScript will be downloaded by your users.

A screenshot of the Next.js build process in a terminal showing first load JS for the application is 74.2 kB

With no added build-time or runtime cost to support reading MDX files and syntax highlighting code blocks, you can have beautiful snippets with little to no impact on UX and DX. By extending your MDX files with custom components you can support headers, copy buttons, and whatever other features you can think of.