Skip to content

Fix: Shiki Not Working — No Syntax Highlighting, Wrong Theme, or Build Errors

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Shiki syntax highlighter issues — basic setup, theme configuration, custom languages, transformer plugins, Next.js and Astro integration, and bundle size optimization.

The Problem

Shiki returns plain text without highlighting:

import { codeToHtml } from 'shiki';

const html = await codeToHtml('const x = 1;', { lang: 'typescript', theme: 'github-dark' });
// Returns HTML but no color tokens — everything is one color

Or the build fails with a bundle error:

Error: Cannot find module 'shiki/langs/typescript.mjs'
// Or: Dynamic require of "fs" is not supported

Or a language isn’t recognized:

Error: Language 'prisma' is not loaded

Why This Happens

Shiki is a syntax highlighter that uses TextMate grammars (the same as VS Code) for accurate highlighting. It works at build time or server-side:

  • Shiki loads languages and themes asynchronously — grammars and theme files are JSON that must be fetched or imported. The first call to codeToHtml loads the requested language and theme. If loading fails silently, you get plain uncolored text.
  • Not all languages are bundled by default — Shiki supports 200+ languages, but to reduce bundle size, you may need to load them explicitly. shiki/bundle/web includes common languages; custom ones need registration.
  • Shiki is server-side only by default — the full Shiki bundle uses fs for loading grammars. In browser or edge environments, use shiki/bundle/web or pre-highlight at build time.
  • Themes determine the color output — if the theme isn’t loaded, tokens exist but have no color. The HTML output includes style attributes with color values from the theme.

The thing that surprises most new users is that Shiki has two engines under the hood, and they fail in different places. The default engine wraps Oniguruma — the same regex engine VS Code uses — compiled to WebAssembly. This is the most accurate option and the slowest to boot, because the runtime has to fetch and instantiate the WASM module. The newer JavaScript engine reimplements a subset of Oniguruma in pure JS; it does not need WASM but it skips some advanced regex features, so a handful of grammars will subtly mis-tokenize. If you ship the WASM engine to a runtime that does not allow WebAssembly.instantiate (locked-down Cloudflare Workers free tier, certain SaaS embeds) the engine fails to boot and you get “Dynamic require of fs is not supported” or “WebAssembly.compileStreaming is not a function.” The fix is the JS engine, with the trade-off that you have to verify your specific languages render correctly.

The second source of confusion is that Shiki’s behavior changed substantially between v0.x and v1+. Older docs mention getHighlighter returning a sync API after a top-level await; newer docs use createHighlighter. The setColorReplacements API moved. The bundle layout changed too: shiki/bundle/full and shiki/bundle/web are v1+ concepts. Code copied from Stack Overflow answers written in 2023 will compile but produce empty output because the function names match but the loading semantics do not. Always check the version of shiki in your package.json against the docs you are reading.

A third issue specific to MDX and frameworks: most framework integrations call Shiki during the build or RSC pass, not at request time. If your highlighter throws inside an unified rehype pipeline (Next.js, Astro, Docusaurus), the error often surfaces several layers up as “Unexpected error in MDX compile” with no useful stack. The trick is to call codeToHtml once in isolation, see the real error, then re-integrate.

Fix 1: Basic Usage

npm install shiki
// Server-side highlighting
import { codeToHtml, codeToTokens } from 'shiki';

// Highlight code to HTML
const html = await codeToHtml(`
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
`, {
  lang: 'tsx',
  theme: 'github-dark',
});

// html = '<pre class="shiki github-dark" ...><code>...</code></pre>'

// Multiple themes (light/dark)
const htmlDual = await codeToHtml('const x: number = 42;', {
  lang: 'typescript',
  themes: {
    light: 'github-light',
    dark: 'github-dark',
  },
  // defaultColor: false,  // Don't inline colors — use CSS variables
});

// Get tokens for custom rendering
const tokens = await codeToTokens('const x = 1;', {
  lang: 'typescript',
  theme: 'github-dark',
});
// tokens.tokens = [[{ content: 'const', color: '#ff7b72' }, ...]]

Fix 2: Create a Highlighter Instance

import { createHighlighter } from 'shiki';

// Create a reusable highlighter — preload languages and themes
const highlighter = await createHighlighter({
  themes: ['github-dark', 'github-light', 'one-dark-pro', 'vitesse-dark'],
  langs: ['typescript', 'javascript', 'tsx', 'jsx', 'html', 'css', 'json', 'bash', 'python', 'rust', 'go'],
});

// Use the highlighter (synchronous after creation)
const html = highlighter.codeToHtml('const x = 1;', {
  lang: 'typescript',
  theme: 'github-dark',
});

// Load additional languages on demand
await highlighter.loadLanguage('prisma');
await highlighter.loadLanguage('graphql');

// Load additional themes
await highlighter.loadTheme('dracula');

// Check loaded languages/themes
console.log(highlighter.getLoadedLanguages());
console.log(highlighter.getLoadedThemes());

// Dispose when done (frees memory)
highlighter.dispose();

Fix 3: Transformers (Line Numbers, Highlighting, Diffs)

npm install @shikijs/transformers
import { codeToHtml } from 'shiki';
import {
  transformerNotationDiff,
  transformerNotationHighlight,
  transformerNotationFocus,
  transformerMetaHighlight,
  transformerNotationWordHighlight,
} from '@shikijs/transformers';

const html = await codeToHtml(`
const oldValue = 'hello'; // [!code --]
const newValue = 'world'; // [!code ++]

function important() { // [!code highlight]
  return true;
}

console.log('focused'); // [!code focus]
`, {
  lang: 'typescript',
  theme: 'github-dark',
  transformers: [
    transformerNotationDiff(),       // [!code --] and [!code ++]
    transformerNotationHighlight(),  // [!code highlight]
    transformerNotationFocus(),      // [!code focus]
    transformerMetaHighlight(),      // ```ts {1,3-5} — highlight lines
    transformerNotationWordHighlight(), // [!code word:xxx]
  ],
});

// Add CSS for diff/highlight styles
/*
.shiki .diff.add { background-color: rgba(16, 185, 129, 0.15); }
.shiki .diff.remove { background-color: rgba(244, 63, 94, 0.15); }
.shiki .highlighted { background-color: rgba(101, 117, 133, 0.16); }
.shiki .has-focus .line:not(.focused) { opacity: 0.5; transition: opacity 0.3s; }
.shiki:hover .has-focus .line:not(.focused) { opacity: 1; }
*/

// Custom transformer — add line numbers
import type { ShikiTransformer } from 'shiki';

const lineNumbers: ShikiTransformer = {
  name: 'line-numbers',
  code(node) {
    this.addClassToHast(node, 'line-numbers');
  },
  line(node, line) {
    node.properties['data-line'] = line;
  },
};

Fix 4: Next.js Integration

// lib/shiki.ts — singleton highlighter
import { createHighlighter, type Highlighter } from 'shiki';

let highlighter: Highlighter | null = null;

export async function getHighlighter() {
  if (!highlighter) {
    highlighter = await createHighlighter({
      themes: ['github-dark', 'github-light'],
      langs: ['typescript', 'javascript', 'tsx', 'jsx', 'bash', 'json', 'css', 'html'],
    });
  }
  return highlighter;
}
// components/CodeBlock.tsx — Server Component
import { getHighlighter } from '@/lib/shiki';

interface CodeBlockProps {
  code: string;
  lang: string;
  filename?: string;
}

export async function CodeBlock({ code, lang, filename }: CodeBlockProps) {
  const highlighter = await getHighlighter();

  const html = highlighter.codeToHtml(code.trim(), {
    lang,
    themes: { light: 'github-light', dark: 'github-dark' },
    defaultColor: false,
  });

  return (
    <div className="relative rounded-lg overflow-hidden my-4">
      {filename && (
        <div className="bg-gray-800 text-gray-400 text-xs px-4 py-2 border-b border-gray-700">
          {filename}
        </div>
      )}
      <div
        className="[&_pre]:p-4 [&_pre]:overflow-x-auto [&_code]:text-sm"
        dangerouslySetInnerHTML={{ __html: html }}
      />
    </div>
  );
}

// Usage in Server Component
<CodeBlock
  code={`const greeting = "Hello, World!";
console.log(greeting);`}
  lang="typescript"
  filename="example.ts"
/>
/* Dual theme CSS — light/dark mode */
html.dark .shiki,
html.dark .shiki span {
  color: var(--shiki-dark) !important;
  background-color: var(--shiki-dark-bg) !important;
}

Fix 5: Astro Integration

// astro.config.mjs — Shiki is built into Astro
import { defineConfig } from 'astro/config';

export default defineConfig({
  markdown: {
    shikiConfig: {
      theme: 'github-dark',
      // Or dual themes:
      // themes: { light: 'github-light', dark: 'github-dark' },
      langs: [],  // Add custom languages
      wrap: true,  // Word wrap
      transformers: [],
    },
  },
});

Fix 6: Bundle for Client-Side / Edge

// Use the web bundle for browser/edge environments
import { createHighlighterCore } from 'shiki/core';
import { createOnigurumaEngine } from 'shiki/engine/oniguruma';

// Or use the lighter JavaScript regex engine
import { createJavaScriptRegExpEngine } from 'shiki/engine/javascript';

const highlighter = await createHighlighterCore({
  themes: [
    import('shiki/themes/github-dark.mjs'),
  ],
  langs: [
    import('shiki/langs/typescript.mjs'),
    import('shiki/langs/javascript.mjs'),
  ],
  engine: createJavaScriptRegExpEngine(),  // No WASM — smaller bundle
});

// Pre-built web bundle with common languages
import { codeToHtml } from 'shiki/bundle/web';

const html = await codeToHtml('const x = 1;', {
  lang: 'typescript',
  theme: 'github-dark',
});

Fix 7: Platform Differences — Node, Browser, Workers, and RSC

Pick the entry point that matches the runtime, and pick the engine that matches what the runtime allows.

RuntimeRecommended entryEngineNotes
Node (server, build-time)shikiOniguruma WASMFull grammar accuracy, ~1 MB cold start
Browser bundleshiki/bundle/webOniguruma WASMLazy-load grammars to keep main bundle small
Cloudflare Workersshiki/coreOniguruma WASM (allowed)Bundle grammars statically — no fs access
Cloudflare Workers (strict CSP / no WASM)shiki/coreJavaScript engineSlight grammar drift on edge cases
Next.js App Router (RSC)shiki in Server ComponentsOniguruma WASMCall inside a singleton; never inside a 'use client' component
BunshikiOniguruma WASMWorks identically to Node; Bun loads .wasm natively
Denonpm:shikiOniguruma WASMRequires --allow-net for first WASM fetch unless bundled

Browser bundle size: the full Shiki bundle is large because TextMate grammars are JSON regex sets. Use shiki/bundle/web to get common languages, and call codeToHtml only for what you actually need to render. For static sites, pre-highlight at build time and ship the HTML — no Shiki runtime in the browser at all. This is what Astro does by default.

Cloudflare Workers Wasm grammar: Workers allow up to 1 MB of compiled WASM in the bundle. Oniguruma fits easily, but you must bundle the grammars and themes statically rather than import()-ing them at request time. Use createHighlighterCore with explicit import('shiki/langs/typescript.mjs') calls so the bundler can inline the JSON.

Next.js App Router RSC compatibility: Shiki works inside async Server Components but the Highlighter instance must be a module-level singleton — creating one per request leaks WASM memory and slows TTFB. Do not call createHighlighter inside a Client Component; the WASM blob will not load over the browser bundler boundary cleanly, and the Server Component output is already pre-highlighted HTML anyway.

MDX integration per framework: every framework wires Shiki differently:

// Astro — shikiConfig in astro.config (already shown above)
// Next.js + MDX — use rehype-pretty-code as the bridge
import rehypePrettyCode from 'rehype-pretty-code';

const config = {
  experimental: { mdxRs: false }, // Shiki not yet supported in mdx-rs
};
// then in mdx config:
const mdxOptions = {
  rehypePlugins: [[rehypePrettyCode, { theme: 'github-dark' }]],
};

// Docusaurus — use @shikijs/markdown-it or the dedicated docusaurus preset
// Astrojs/markdown remark — Shiki is the default highlighter

For Next.js specifically, rehype-pretty-code is the standard wrapper because it handles the dual-theme CSS variables (--shiki-light, --shiki-dark) and integrates with MDX. Using Shiki directly inside an unified pipeline works but you re-implement what rehype-pretty-code already does.

Still Not Working?

Output has no colors — the theme isn’t loaded. When using createHighlighter, pass themes in the constructor. When using codeToHtml, the theme must be specified. Check the output HTML — if style attributes have color values, the theme loaded correctly.

“Language not loaded” error — the language must be preloaded in createHighlighter({ langs: [...] }) or loaded dynamically with highlighter.loadLanguage(). For codeToHtml (standalone function), languages are loaded automatically but may fail if the grammar file isn’t accessible.

Build fails with “Dynamic require of fs” — you’re importing the full Shiki bundle in a client-side or edge environment. Use shiki/bundle/web for browsers or createHighlighterCore with the JavaScript engine for edge runtimes.

Highlighting is slow — creating a highlighter is expensive (loads WASM + grammars). Create it once and reuse across requests. In Next.js, use a module-level singleton. Don’t call createHighlighter inside a component render.

Cloudflare Workers throws “WebAssembly.compileStreaming is not a function” — your Worker is on a tier that forbids WASM, or you forgot to mark the .wasm import as a Worker asset. Switch to createJavaScriptRegExpEngine() and accept the minor grammar drift, or move highlighting to build time and ship the rendered HTML through KV.

Dual-theme CSS variables render as raw var(--shiki-dark) text — you set defaultColor: false but never wrote the matching CSS that maps the variables to actual colors based on theme. Add the html.dark .shiki { color: var(--shiki-dark) } block shown above.

Code blocks render unstyled in Next.js production build but work in dev — the singleton highlighter was tree-shaken because nothing top-level referenced it. Export it from a server-only module ('server-only' directive) and import it from every Server Component that highlights code.

For related code display issues, see Fix: MDX Not Working, Fix: Docusaurus Not Working, Fix: Wrangler Not Working, and Fix: Remix Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles