ยท 8 min read ยท Ty Barho

Titles, highlights, and line numbers with next-mdx-remote

tldr

It took me a lot of messing with different libararies to get the next-mdx-remote working with line numbers and highlighted lines. Here's how I did it.

I love the look of Josh W. Comeau's blog, especially his attention to detail in his code components. He makes them really friendly and approachable, but also usable, and fairly copy / pastable.

I wanted to try to emulate that on this blog, with titles, highlights and line-numbers.

I don't typically use line numbers in my code blocks, but it really helps with figuring out which line numbers to highlight.

When I started on implementing this stuff, I saw that Josh was using prism-react-renderer in a custom MDX component. Problem was, I'm using next-mdx-remote to render my MDX files, and I couldn't figure out how to get the two to work together.

After about a day of messing with it, I decided I would, at leat for a while, sacrifice the flexibility of a custom component for Go To Market speed ๐Ÿ˜‰ and see if I could get next-mdx-remote working with plugins.

Here's how I did it.

Switch from @mapbox/rehype-prism to rehype-prism-plus

Most of the changes for this ended up being css or in my /src/pages/articles/[slug].jsx files getStaticProps function, which is where the MDX is processed.

Here was my original file.

src/pages/articles/[slug].jsx
import { MDXProvider } from '@mdx-js/react';
import { serialize } from 'next-mdx-remote/serialize';
import remarkGfm from 'remark-gfm'
import rehypePrism from '@mapbox/rehype-prism'
import remarkMdxCodeMeta from 'remark-mdx-code-meta';
import { MDXRemote } from 'next-mdx-remote'

import components from '@/components/MDXComponents';
import { ArticleLayout } from '@/components/ArticleLayout';
import { getAllContent, getContentItem } from '@/lib/getContent';

const custom = {
  ul: props => <ul className="list-disc ml-2" {...props} />,
  li: props => <li className="font-light my-1 leading-6" {...props} />,
  hr: props => <hr className="my-16 border-zinc-300" {...props} />,
  a: props => <a {...props} target="_blank" rel="noopener noreferrer" />,
}


export default function Article(props) {
  return (
    <MDXProvider components={components}>
      <ArticleLayout {...props}>
        <MDXRemote {...props.mdxSource} components={{...components, ...custom}} />
      </ArticleLayout>
    </MDXProvider>
  )
}

export async function getStaticPaths() {
  const articles = await getAllContent('articles');
  const paths = articles.map(a => `/articles/${a.slug}`);

  return {
    paths,
    fallback: false,
  };
}

export async function getStaticProps({ params: { slug } }) {
  const { content, data } = await getContentItem(slug, 'articles');

  const mdxSource = await serialize(content, {
    // Optionally pass remark/rehype plugins
    components,
    mdxOptions: {
      remarkPlugins: [remarkGfm, remarkMdxCodeMeta],
      rehypePlugins: [rehypePrism],
    },
    scope: data,
  });

  return {
    props: {
      raw: content,
      meta: { ...data, slug },
      mdxSource,
    },
  };
}

I did some reading, and found that rehype-prism-plus was a fork of @mapbox/rehype-prism that allowed enabling line numbers and line highlighting.

I also found rehype-code-titles which allowed adding titles to code blocks.

So I added them as plugins.

src/pages/articles/[slug].jsx
import { MDXProvider } from '@mdx-js/react';
import { serialize } from 'next-mdx-remote/serialize';
import remarkGfm from 'remark-gfm'
- import rehypePrism from '@mapbox/rehype-prism'
+ import rehypePrism from 'rehype-prism-plus'
+ import rehypeCodeTitles from 'rehype-code-titles'
import remarkMdxCodeMeta from 'remark-mdx-code-meta';
import { MDXRemote } from 'next-mdx-remote'

import components from '@/components/MDXComponents';
import { ArticleLayout } from '@/components/ArticleLayout';
import { getAllContent, getContentItem } from '@/lib/getContent';

const custom = {
  ul: props => <ul className="list-disc ml-2" {...props} />,
  li: props => <li className="font-light my-1 leading-6" {...props} />,
  hr: props => <hr className="my-16 border-zinc-300" {...props} />,
  a: props => <a {...props} target="_blank" rel="noopener noreferrer" />,
}


export default function Article(props) {
  return (
    <MDXProvider components={components}>
      <ArticleLayout {...props}>
        <MDXRemote {...props.mdxSource} components={{...components, ...custom}} />
      </ArticleLayout>
    </MDXProvider>
  )
}

export async function getStaticPaths() {
  const articles = await getAllContent('articles');
  const paths = articles.map(a => `/articles/${a.slug}`);

  return {
    paths,
    fallback: false,
  };
}

export async function getStaticProps({ params: { slug } }) {
  const { content, data } = await getContentItem(slug, 'articles');

  const mdxSource = await serialize(content, {
    // Optionally pass remark/rehype plugins
    components,
    mdxOptions: {
      remarkPlugins: [remarkGfm, remarkMdxCodeMeta],
-      rehypePlugins: [rehypePrism],
+      rehypePlugins: [rehypePrism, rehypeCodeTitles],
    },
    scope: data,
  });

  return {
    props: {
      raw: content,
      meta: { ...data, slug },
      mdxSource,
    },
  };
}

That produced HTML output that looked more correct.

HTML
<pre class="language-jsx">
  <code class="language-jsx code-highlight">
    <span class="code-line">
      <!-- tokens -->
    </span>
  </code>
</pre>

Notice the code-highlight and code-line classes have been added.

Adding the styles

I have a custom tailwind.css file that imports my prism styles like so:

/src/styles/tailwind.css
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import './prism.css';
@import 'tailwindcss/utilities';

And my custom prism.css file looks like this:

src/styles/prism.css
pre[class*='language-'] {
  color: theme('colors.zinc.100');
  margin-top: 1rem !important;
}

.token.tag,
.token.class-name,
.token.selector,
.token.selector .class,
.token.selector.class,
.token.function {
  color: theme('colors.pink.400');
}

.token.attr-name,
.token.keyword,
.token.rule,
.token.pseudo-class,
.token.important {
  color: theme('colors.blue.300');
}

.token.module {
  color: theme('colors.purple.300');
}

.token.attr-value {
  color: theme('colors.blue.300');
}

.token.string {
  color: theme('colors.orange.300')
}

.token.class {
  color: theme('colors.teal.300');
}

.token.punctuation,
.token.attr-equals {
  color: theme('colors.zinc.500');
}

.token.unit,
.language-css .token.function {
  color: theme('colors.sky.200');
}

.token.comment {
  color: theme('colors.emerald.400');
  opacity: 0.7;
}

.token.property,
.token.operator,
.token.combinator {
  color: theme('colors.zinc.400');
}

So I found the style guide for rehype-prism-plus and made some edits:

src/styles/prism.css
pre[class*='language-'] {
  color: theme('colors.zinc.100');
  margin-top: 1rem !important;
}

/* ... token CSS ... */

.token.property,
.token.operator,
.token.combinator {
  color: theme('colors.zinc.400');
}

.code-highlight {
  float: left; /* 1 */
  min-width: 100%; /* 2 */
}

.code-line {
  display: block;
  padding-left: 16px;
  padding-right: 16px;
  margin-left: -16px;
  margin-right: -16px;
  border-left: 4px solid rgba(0, 0, 0, 0); /* Set placeholder for highlight accent border color to transparent */
  line-height: 1.5rem;
}

.code-line.inserted {
  background-color: theme('colors.emerald.900'); /* Set inserted line (+) color */
}

.code-line.deleted {
  background-color: theme('colors.red.900'); /* Set deleted line (-) color */
}

.highlight-line {
  margin-left: -14px;
  margin-right: -16px;
  background-color: theme('colors.zinc.800'); /* Set highlight bg color */
  border-left: 2px solid theme('colors.amber.400'); /* Set highlight accent border color */
}

.line-number::before {
  display: inline-block;
  width: 1rem;
  text-align: right;
  margin-right: 16px;
  margin-left: -8px;
  color: theme('colors.zinc.500');
  content: attr(line);
}

.rehype-code-title {
  margin: 0 !important;
  display: inline-flex;
  position: relative;
  top: 1rem;
  left: 2rem;
  background: theme('colors.zinc.900');
  color: theme('colors.zinc.100');
  font-family: theme('fontFamily.mono');
  padding: 0.25rem 1.5rem;
  border-radius: 0.5rem 0.5rem 0 0;
  border-top: 4px solid theme('colors.indigo.600');
  font-size: 0.8rem;
}

And that's that! ๐Ÿ˜Žโœจ

Using Code Block Titles in MDX

Adding a title to a code block is now really simple:

MDX
'''jsx:src/components/ArticleLayout.js

'''

The string after the : will be the title of the code block, and will appear at the top left.

Using Line Numbers in MDX

Adding line numbers to a code block is also really simple:

MDX
'''jsx showLineNumbers

'''

Easy peasy! ๐Ÿ‹๐Ÿ‹๐Ÿ‹

Using Line Highlights in MDX

Lastly, here's how you can add line highlights to a code block:

MDX
'''jsx {2}
Here is the highlighted line!
'''

You can even do multiple lines, and ranges:

MDX
'''jsx {1,3-4}
Here is the highlighted line!

And 2 more
highlighted lines.
'''

Get an email when I post something new.

Putting it all together

Here's an example of using all 3 features in one code block:

index.jsx
'''jsx:index.jsx {2,4-5} showLineNumbers
Here is the highlighted line!

And 2 more
highlighted lines.
'''

Though, like I said, I don't typically use line numbers.

Bonus: File diffs

One thing I realized as I was pasting the CSS for our code blocks were these classes:

src/styles/prism.css
.code-line.inserted {
  background-color: theme('colors.emerald.900'); /* Set inserted line (+) color */
}

.code-line.deleted {
  background-color: theme('colors.red.900'); /* Set deleted line (-) color */
}

After doing a little digging, I figured out that there was a diff language type that I could to show diffs.

Here's how to use it:

DIFF
'''diff
+ Added line
- Deleted line
'''

You simply select diff as the syntax language, then add + or - to the beginning of the line to indicate whether it was added or deleted. The above block would result in:

+ Added line
- Deleted line

Pretty cool, eh? ๐Ÿ˜Žโœจ

Removing Remark Plugins

One last thing I noticed in my getStaticProps function was the Remark plugins that I added when I copied and pasted sample code into this blog.

src/pages/articles/[slug].jsx
export async function getStaticProps({ params: { slug } }) {
  const { content, data } = await getContentItem(slug, 'articles');

  const mdxSource = await serialize(content, {
    // Optionally pass remark/rehype plugins
    components,
    mdxOptions: {
      remarkPlugins: [remarkGfm, remarkMdxCodeMeta],
      rehypePlugins: [rehypePrism, rehypeCodeTitles],
    },
    scope: data,
  });

  return {
    props: {
      raw: content,
      meta: { ...data, slug },
      mdxSource,
    },
  };
}

I got curious to see if they did anything when I removed them, and they didn't. So I removed them and the imports...

src/pages/articles/[slug].jsx
- import remarkGfm from 'remark-gfm'
import rehypePrism from 'rehype-prism-plus'
import rehypeCodeTitles from 'rehype-code-titles'
- import remarkMdxCodeMeta from 'remark-mdx-code-meta';


export async function getStaticProps({ params: { slug } }) {
  const { content, data } = await getContentItem(slug, 'articles');

  const mdxSource = await serialize(content, {
    // Optionally pass remark/rehype plugins
    components,
    mdxOptions: {
-      remarkPlugins: [remarkGfm, remarkMdxCodeMeta],
      rehypePlugins: [rehypePrism, rehypeCodeTitles],
    },
    scope: data,
  });

  return {
    props: {
      raw: content,
      meta: { ...data, slug },
      mdxSource,
    },
  };
}

Updating other serialize functions

Lastly, there were a couple of other places in my project that I needed to update my serialize function options:

next-config.js
import nextMDX from '@next/mdx'
import rehypePrism from 'rehype-prism-plus'
import rehypeCodeTitles from 'rehype-code-titles'

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ['js', 'jsx', 'mdx'],
  reactStrictMode: true,
  experimental: {
    scrollRestoration: true,
  }
}

const withMDX = nextMDX({
  extension: /\.mdx?$/,
  options: {
    rehypePlugins: [rehypeCodeTitles, rehypePrism],
    providerImportSource: '@mdx-js/react',
  },
})

export default withMDX(nextConfig)

This ensures any pages that straight MDX in my /pages directory will also have the code block features.

And finally, I'm generating an RSS feed in my pages/index.jsx file, so I needed to update that method as well, otherwise, I was getting NextJS build errors.

/src/pages/index.jsx
export async function getStaticProps() {
  if (process.env.NODE_ENV === 'production') {
    await generateRssFeed()
  }

  const articles = (await getAllContent('articles'))
    .slice(0, 4)
    .map(({ component, ...meta }) => meta)

  return {
    props: {
      articles,
    },
  }
}

And the generateRssFeed function:

src/lib/generateRssFeed.js
import ReactDOMServer from 'react-dom/server'
import { Feed } from 'feed'
import { mkdir, writeFile } from 'fs/promises'
import { withRouter } from 'next/router'
import { MDXRemote } from 'next-mdx-remote'
import { serialize } from 'next-mdx-remote/serialize'
import rehypePrism from 'rehype-prism-plus'
import rehypeCodeTitles from 'rehype-code-titles'

import components from '@/components/MDXComponents'
import { getAllContent } from '@/lib/getContent'

export async function generateRssFeed() {
  let articles = await getAllContent('articles')
  let siteUrl = process.env.NEXT_PUBLIC_SITE_URL
  let author = {
    name: 'Ty Barho',
    email: 'ty@tybarho.com',
  }

  let feed = new Feed({
    title: author.name,
    description: 'Writing on technology, dad-hacking, and spiritual formation.',
    author,
    id: siteUrl,
    link: siteUrl,
    image: `${siteUrl}/favicon.ico`,
    favicon: `${siteUrl}/favicon.ico`,
    copyright: `All rights reserved ${new Date().getFullYear()}`,
    feedLinks: {
      rss2: `${siteUrl}/rss/feed.xml`,
      json: `${siteUrl}/rss/feed.json`,
    },
  })

  for (let article of articles) {
    let url = `${siteUrl}/articles/${article.slug}`
    const mdxSource = await serialize(article.content, {
      // Optionally pass remark/rehype plugins
      components,
      mdxOptions: {
        rehypePlugins: [rehypeCodeTitles, rehypePrism],
      },
      scope: article.data,
    })

    let html = ReactDOMServer.renderToStaticMarkup(
      <MDXRemote {...mdxSource} components={components} />
    )

    feed.addItem({
      title: article.data.title,
      id: url,
      link: url,
      description: article.data.description,
      content: html,
      author: [author],
      contributor: [author],
      date: new Date(article.data.date),
    })
  }

  await mkdir('./public/rss', { recursive: true })
  await Promise.all([
    writeFile('./public/rss/feed.xml', feed.rss2(), 'utf8'),
    writeFile('./public/rss/feed.json', feed.json1(), 'utf8'),
  ])
}

And that's that! ๐ŸŽ‰ Hope you enjoyed!

Liked this? Get the next one.

Share this article

Keep readingFiltering by Tags in your NextJS MDX Blog

ยฉ 2026 Ty Barho ยท Georgetown, TX ยท James 1:5