Setting up Shiki on Next.js

Published See discussion on Bluesky

I recently updated how I handle syntax highlighting within code blocks on my personal site here. After my recent refactor a few months ago, I had adopted highlight.js originally but then only recently realized that it still doesn't support JSX/TSX syntax highlighting.

I did some googling around, almost went down the path of using Prism.js as well, and then remembered that shiki exists and decided to give it a try.

Originally, I thought it was going to be pretty easy, or at least it was easy to implement for local development within my React Server Component setup for my blog. It was only when I went to deploy it that it started to break.

I ran into the following error and was a bit confused on what it meant:

1[Error: ENOENT: no such file or directory, open '/var/task/node_modules/shiki/languages/typescript.tmLanguage.json'] {
2 errno: -2,
3 code: 'ENOENT',
4 syscall: 'open',
5 path: '/var/task/node_modules/shiki/languages/typescript.tmLanguage.json'
1[Error: ENOENT: no such file or directory, open '/var/task/node_modules/shiki/languages/typescript.tmLanguage.json'] {
2 errno: -2,
3 code: 'ENOENT',
4 syscall: 'open',
5 path: '/var/task/node_modules/shiki/languages/typescript.tmLanguage.json'

It turns out that by default, Next.js doesn't bundle in shiki, which means there's a raw require or dynamic import for the text grammars and the themes.

After stumbling across a few different issues, each with different ways to resolve the problem I was running into, I found the following solution:

  1. Import the themes and grammars that are needed manually
  2. Pass them directly into the highlight call

Here's a complete example of my current CodeBlock component:

1import shiki from 'shiki';
2
3// Manually import themes and grammars
4import cssGrammar from 'shiki/languages/css.tmLanguage.json';
5import diffGrammar from 'shiki/languages/diff.tmLanguage.json';
6import jsonGrammar from 'shiki/languages/json.tmLanguage.json';
7import mdGrammar from 'shiki/languages/markdown.tmLanguage.json';
8import bashGrammar from 'shiki/languages/shellscript.tmLanguage.json';
9import tsxGrammar from 'shiki/languages/tsx.tmLanguage.json';
10import githubDarkDimmed from 'shiki/themes/github-dark-dimmed.json';
11import githubLight from 'shiki/themes/github-light.json';
12
13export default async function CodeBlock({ children, className, ...props }) {
14 let lang = className ? className.split('-')[1] : 'typescript';
15 if (lang === 'tsx' || lang === 'jsx' || lang === 'js') {
16 lang = 'typescript';
17 } else if (lang === 'sh') {
18 lang = 'bash';
19 }
20
21 let codeToHighlight = children;
22
23 let highlighter = await shiki.getHighlighter({
24 // Pass in manually imported themes and grammars
25 // @ts-ignore
26 themes: [githubDarkDimmed, githubLight],
27 langs: [
28 // @ts-ignore
29 { id: 'tsx', scopeName: 'source.tsx', grammar: tsxGrammar },
30 // @ts-ignore
31 { id: 'typescript', scopeName: 'source.tsx', grammar: tsxGrammar },
32 // @ts-ignore
33 { id: 'md', scopeName: 'text.html.markdown', grammar: mdGrammar },
34 // @ts-ignore
35 { id: 'css', scopeName: 'source.css', grammar: cssGrammar },
36 // @ts-ignore
37 { id: 'diff', scopeName: 'source.diff', grammar: diffGrammar },
38 // @ts-ignore
39 { id: 'bash', scopeName: 'source.shell', grammar: bashGrammar },
40 // @ts-ignore
41 { id: 'json', scopeName: 'source.json', grammar: jsonGrammar },
42 ],
43 });
44
45 let html = highlighter.codeToHtml(codeToHighlight, { lang });
46
47 return (
48 <Box
49 is='code'
50 dangerouslySetInnerHTML={{ __html: html }}
51 {...props}
52 className={className ? `${className} ${code}` : `${code}`}
53 />
54 );
55}
1import shiki from 'shiki';
2
3// Manually import themes and grammars
4import cssGrammar from 'shiki/languages/css.tmLanguage.json';
5import diffGrammar from 'shiki/languages/diff.tmLanguage.json';
6import jsonGrammar from 'shiki/languages/json.tmLanguage.json';
7import mdGrammar from 'shiki/languages/markdown.tmLanguage.json';
8import bashGrammar from 'shiki/languages/shellscript.tmLanguage.json';
9import tsxGrammar from 'shiki/languages/tsx.tmLanguage.json';
10import githubDarkDimmed from 'shiki/themes/github-dark-dimmed.json';
11import githubLight from 'shiki/themes/github-light.json';
12
13export default async function CodeBlock({ children, className, ...props }) {
14 let lang = className ? className.split('-')[1] : 'typescript';
15 if (lang === 'tsx' || lang === 'jsx' || lang === 'js') {
16 lang = 'typescript';
17 } else if (lang === 'sh') {
18 lang = 'bash';
19 }
20
21 let codeToHighlight = children;
22
23 let highlighter = await shiki.getHighlighter({
24 // Pass in manually imported themes and grammars
25 // @ts-ignore
26 themes: [githubDarkDimmed, githubLight],
27 langs: [
28 // @ts-ignore
29 { id: 'tsx', scopeName: 'source.tsx', grammar: tsxGrammar },
30 // @ts-ignore
31 { id: 'typescript', scopeName: 'source.tsx', grammar: tsxGrammar },
32 // @ts-ignore
33 { id: 'md', scopeName: 'text.html.markdown', grammar: mdGrammar },
34 // @ts-ignore
35 { id: 'css', scopeName: 'source.css', grammar: cssGrammar },
36 // @ts-ignore
37 { id: 'diff', scopeName: 'source.diff', grammar: diffGrammar },
38 // @ts-ignore
39 { id: 'bash', scopeName: 'source.shell', grammar: bashGrammar },
40 // @ts-ignore
41 { id: 'json', scopeName: 'source.json', grammar: jsonGrammar },
42 ],
43 });
44
45 let html = highlighter.codeToHtml(codeToHighlight, { lang });
46
47 return (
48 <Box
49 is='code'
50 dangerouslySetInnerHTML={{ __html: html }}
51 {...props}
52 className={className ? `${className} ${code}` : `${code}`}
53 />
54 );
55}

For some reason, shiki's TypeScript types don't seem to like me passing in the themes or the grammars manually, so I opted to ts-ignore those errors for the time being 🙂.

Hopefully this helps others that might run into the same issue!