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:
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.
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
.
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!
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.
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.
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.