Engineering

Build-Time Code Highlighting in NextJS and Sanity

Stop sending huge bundles to your users in-browser just to make code snippets pretty. It’s pretty easy.

Maybe this sounds familiar: You’re building a React-based frontend for a blog, and find that you need some way to feature helpful code snippets for your visitors to read, admire and cut-and-paste into their favorite code editor. You ask ChatGPT how to do it (or maybe even run a Google search), and get a lot of really straightforward options that go like…

yarn add some-code-highlighter

And then…

import { SomeCodeHighlighter } from ‘some-code-highlighter’

export default function CodeBlock ({ code }: { code: string }) {
	return <SomeCodeHighlighter codeToHighlight={code} language=’javascript’ /> 
}

If you need advanced options, specific themes, special parsing, this gets a bit more complicated but it’s basically the same deal!

Now you see that your code snippet is beautifully (and probably correctly) highlighted and formatted so you call it a day, pack up early and go fire up Fortnite or The Crown or whatever. The thing is, if you have anything/anyone monitoring the size of the client-side bundle in your app, klaxons will sound, large doors that look like bank safes will quickly close and lock, and whomever is most concerned about #corewebvitals will be your next human interaction.

This is because most of the recommendations you find around building this particular feature into websites, especially using single-page-app paradigms, depend on reading the code you’re trying to format at run time in the browser, first reading and parsing the raw text you’ve entered, then formatting it and styling it according to the language and theme you’ve specified. You will perhaps be unsurprised that this is functionally identical to what your favorite code editor does locally, and even less surprised that it takes a big old chunk of JS (which gets larger the more languages you support) to do this within a plug-and-play NPM package.

Sure, there are instances where this kind of offloading makes sense, but this is really not one of them. Formatting code (especially when you’re not building a code playground or something dynamic like that, just showing snippets) requires only HTML and CSS. The styles can even be inlined so you can do it all with just a single block of HTML. Thus, it makes total sense, from both a performance and bundle optimization standpoint, to simply build the HTML before it gets to the browser.

If you’re using a popular framework with a lot of optimization features such as NextJS, you have a couple options here. If you’re able to build and deploy your site using static site generation (SSG) then that’s the happy path. (Building this all server side with SSR is also probably fine if stuff is cached.) Either way, the goal here (assuming you’re on NextJS 13+ and using the app router) is to use only server components to parse a block of raw text into formatted code blocks using HTML. this will ensure that we don’t add huge new dependencies to our bundle that aren’t needed, and that we don’t need to worry about the browser getting hung up parsing a huge chunk (or many smaller chunks) of code on every page load for every user.

Good News: It’s Still Real Easy

We use Sanity to create content for this blog, with a statically-generated NextJS site on top. While our implementation is specific to Sanity as a back-end, you could take this approach to really any CMS where you’re allowing code snippet blocks to be posted. In a lot of ways, this is even easier than doing the client side thing, and yes, we still get to use a plug-and-play NPM package for it.

If you’re using Sanity, we really love the Code Input Block but rest assured, you could just use a simple text field to do this as well. In our case, after running the simple installation steps, the schema for our code block looks like this:

import {defineType, defineField} from 'sanity'

defineType({
  // [...]
  fields: [
    defineField({
    type: 'code',
          name: 'codeSnippet',
          title: 'Code Snippet',
          options: {
            language: 'javascript',
            languageAlternatives: [
              { title: 'Javascript', value: 'javascript' },
              { title: 'HTML', value: 'html' },
              { title: 'CSS', value: 'css' },
            ],
          },
    }),
  ],
})

This sets up a really nice little code-editor block in Sanity for you, and specifies that you only need HTML, CSS and JS to be supported in it. (We don’t do FORTRAN here but I’ll bet it’s possible to support it). You can cut and paste from your code editor or code right in the Sanity studio, but either way you’re going to get back something like this in your front-end query:

codeSnippet: {
   value: {
       language: ‘html’,
       code: ‘<raw text code with line breaks and whitespace, etc…>’,
  },
}

This is pretty much as deep as it gets for posting simple snippets of code in Sanity. Honestly, how you get your raw text and language props to the component is up to you.

In NextJS 13+ we really like the Shikiji project for highlighting code at build time. After running yarn add shikiji and assuming your code and language data are sent as props to a component called CodeSnippet, it might look more or less like ours (note that ‘use client’ is nowhere to be found!):

import { getHighlighter } from 'shikiji'

export interface CodeSnippetProps {
 language?: 'javascript' | 'css' | 'html'
 code: string
}

export default async function CodeSnippet({ language, code }: CodeSnippetProps) {
 const highlighter = await getHighlighter({
   themes: ['monokai'],
   langs: ['javascript', 'css', 'html'],
 })

   const html = highlighter.codeToHtml(code, {
   lang: language || 'javascript',
   theme: 'monokai',
 })
 return (
   <div
     // eslint-disable-next-line react/no-danger
     dangerouslySetInnerHTML={{ __html: html }}
   />
 )
}

And that’s it. If you’re using one of the many built-in themes (we are fond of Monokai, obviously) then this simple component will take your code and generate HTML with inline styles at build time, as promised. Consider the infinite loop of madness that we just opened by posting the code snippet of the component on our blog using the actual code in the code snippet that we posted on our blog. It’s crazy.