Logo

Migrating Syntax Highlighting from Prism to Shiki

5 min read
shikimdx

Table of Contents

Why Switch?

I wrote about setting up Prism a while back. It worked well. prism-react-renderer is simple to set up and gets the job done. But after using it for a while, I started noticing places where the highlighting was off. TypeScript generics would sometimes confuse the tokenizer, and JSX mixed with complex expressions didn't always come out right.

Prism uses regex-based tokenization. It works for most cases, but regex has limits, especially with nested syntax like TypeScript's type system or embedded expressions in JSX.

Shiki uses the same TextMate grammars that power VS Code. The highlighting is noticeably more accurate, and you get access to VS Code's entire theme ecosystem.

Dependencies

Versions used when this was written:

Install

1npm install shiki
2npm uninstall prism-react-renderer

The Async Problem

Shiki's main API is async. It needs to load grammars and themes before it can highlight anything. That doesn't play well with react-markdown, which renders components synchronously through its components prop.

There are a few ways around this:

  1. Use @shikijs/rehype as a rehype plugin (but react-markdown calls processSync internally)
  2. Pre-process markdown on the server before passing it to the renderer
  3. Use shiki's synchronous core API with bundled grammars

I went with option 3. Shiki provides createHighlighterCoreSync which lets you import grammars as JavaScript modules and create a highlighter that works synchronously. No WASM, no async initialization.

Highlighter Setup

Created a module that initializes a sync highlighter with the languages I use on this blog:

1import { createHighlighterCoreSync } from 'shiki/core'
2import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
3import githubDark from 'shiki/themes/github-dark.mjs'
4import langBash from 'shiki/langs/bash.mjs'
5import langTypescript from 'shiki/langs/typescript.mjs'
6import langTsx from 'shiki/langs/tsx.mjs'
7// ... add whatever languages you need
8
9const highlighter = createHighlighterCoreSync({
10 themes: [githubDark],
11 langs: [langBash, langTypescript, langTsx],
12 engine: createJavaScriptRegexEngine(),
13})
14
15export function highlightCode(code: string, lang: string) {
16 try {
17 return highlighter.codeToTokensBase(code, {
18 lang,
19 theme: 'github-dark',
20 })
21 } catch {
22 return highlighter.codeToTokensBase(code, {
23 lang: 'plaintext',
24 theme: 'github-dark',
25 })
26 }
27}

A few things worth noting:

Updating the Component

The previous version used Prism's Highlight render prop to tokenize code. Shiki's codeToTokensBase returns a similar structure, an array of lines, where each line is an array of tokens with content and color properties.

The component interface stays the same. It still receives a <pre> element from react-markdown with a <code> child:

1import { highlightCode } from '@/libs/shiki'
2
3export const HighlightedCode = ({ children }: Props) => {
4 const [copied, setCopied] = useState(false)
5
6 const codeElement = isValidElement<CodeProps>(children) ? children : null
7 const codeContent = codeElement?.props.children
8 const code = typeof codeContent === 'string' ? codeContent.trim() : ''
9 const language =
10 codeElement?.props.className?.replace('language-', '').trim() || 'text'
11
12 const tokens = useMemo(
13 () => (code ? highlightCode(code, language) : []),
14 [code, language],
15 )
16
17 if (!code) return null
18
19 return (
20 <CodeContainer>
21 <CopyButton onClick={handleCopy} title={copied ? 'Copied!' : 'Copy code'}>
22 {copied ? <ClipboardCheck /> : <Clipboard />}
23 </CopyButton>
24 <Pre>
25 <CodeWrapper>
26 {tokens.map((line, lineIndex) => (
27 <Line key={`line-${lineIndex}`}>
28 <LineNumber>{lineIndex + 1}</LineNumber>
29 <LineContent>
30 {line.map((token, tokenIndex) => (
31 <span
32 key={`token-${tokenIndex}`}
33 style={{
34 color: token.color,
35 fontStyle: token.fontStyle === 1 ? 'italic' : undefined,
36 }}
37 >
38 {token.content}
39 </span>
40 ))}
41 </LineContent>
42 </Line>
43 ))}
44 </CodeWrapper>
45 </Pre>
46 </CodeContainer>
47 )
48}

What Changed

What Didn't Change

Prism vs Shiki

PrismShiki
Grammar systemRegex-basedTextMate (same as VS Code)
ThemesLimited built-in setFull VS Code theme ecosystem
AccuracyGood for common casesHandles edge cases better
APIRender prop (sync)codeToTokensBase / codeToHtml
BundleIncluded in prism-react-rendererLanguage grammars loaded per-language

Final Notes

The migration was straightforward, just one utility file and an updated component. No changes to the markdown pipeline or rendering setup. The code blocks on this blog now use GitHub Dark theme colors with TextMate-grade tokenization, which is a nice upgrade from Prism's defaults.

If you're using react-markdown and need synchronous highlighting, the createHighlighterCoreSync + createJavaScriptRegexEngine combo is the way to go. It avoids the async initialization dance entirely while still giving you shiki's grammar quality.

Related Articles