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:
- Next.js: 16.2.3
- shiki: 4.0.2
- React: 19.2.3
- react-markdown: 10.1.0
Install
1npm install shiki2npm 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:
- Use
@shikijs/rehypeas a rehype plugin (butreact-markdowncallsprocessSyncinternally) - Pre-process markdown on the server before passing it to the renderer
- 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 need89const highlighter = createHighlighterCoreSync({10 themes: [githubDark],11 langs: [langBash, langTypescript, langTsx],12 engine: createJavaScriptRegexEngine(),13})1415export 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:
createJavaScriptRegexEngineuses a pure JS regex engine instead of Oniguruma (WASM). It's lighter and works synchronously. For blog code blocks, the accuracy difference is negligible.- Language imports use
.mjs. These are pre-compiled grammar bundles that can be imported synchronously. - The try/catch handles cases where a code block specifies a language I haven't loaded. It falls back to plain text instead of crashing.
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'23export const HighlightedCode = ({ children }: Props) => {4 const [copied, setCopied] = useState(false)56 const codeElement = isValidElement<CodeProps>(children) ? children : null7 const codeContent = codeElement?.props.children8 const code = typeof codeContent === 'string' ? codeContent.trim() : ''9 const language =10 codeElement?.props.className?.replace('language-', '').trim() || 'text'1112 const tokens = useMemo(13 () => (code ? highlightCode(code, language) : []),14 [code, language],15 )1617 if (!code) return null1819 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 <span32 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
- Replaced
Highlightfromprism-react-rendererwithhighlightCodefrom the shiki utility - Token rendering maps
token.colorandtoken.fontStyledirectly instead of using Prism'sgetTokenProps - Wrapped tokenization in
useMemoto avoid re-tokenizing on copy button clicks - Validation logic moved before hooks to keep the rules of hooks happy, so there are no early returns before
useMemo
What Didn't Change
- The component still receives
<pre>fromreact-markdown's component mapping - Line numbers, copy button, and all styled components stayed the same
- The
MarkdownRendererdidn't need any changes at all
Prism vs Shiki
| Prism | Shiki | |
|---|---|---|
| Grammar system | Regex-based | TextMate (same as VS Code) |
| Themes | Limited built-in set | Full VS Code theme ecosystem |
| Accuracy | Good for common cases | Handles edge cases better |
| API | Render prop (sync) | codeToTokensBase / codeToHtml |
| Bundle | Included in prism-react-renderer | Language 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.
