Syntax Highlight with shiki

nabilfikrispAfter building my SSG blog with Next.js and Markdown, I needed proper syntax highlighting for code blocks. Plain text code looks unprofessional and hurts readability. Enter Shiki, the same highlighter that powers VS Code.
Here's how I integrated Shiki into my existing markdown pipeline and the implementation decisions that matter.
The Core Problem
Basic remark-html produces unstyled code blocks.
Without syntax highlighting, code is just plain text wrapped in <pre><code> tags. Readers lose context, and your technical content looks amateurish.
Shiki provides VS Code-quality highlighting.
It uses the same tokenizer and themes as VS Code, ensuring accurate highlighting for dozens of languages with beautiful, familiar themes.
The key insight: integrate highlighting into your markdown processing pipeline, not as a client-side afterthought.
Why Shiki Over Alternatives
The problem with client-side highlighting:
- Prism.js and highlight.js add runtime JavaScript
- Flash of unstyled content (FOUC) on page load
- Extra bundle size sent to every visitor
Shiki's advantage:
- Build-time highlighting produces static HTML
- No client-side JavaScript required
- Perfect theme consistency
- Zero runtime performance impact
The trade-off: Longer build times for better user experience.
The Implementation
Replacing the basic markdown processor:
// Before: Basic remark setup
import { remark } from "remark";
import html from "remark-html";
export default async function markdownToHtml(markdown: string) {
const result = await remark().use(html).process(markdown);
return result.toString();
}
After: Shiki-powered highlighting:
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
import rehypeStringify from "rehype-stringify";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { createHighlighter } from "shiki/bundle/web";
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
import { unified } from "unified";
const highlighter = await createHighlighter({
themes: [
import("@shikijs/themes/github-light"),
import("@shikijs/themes/github-dark"),
],
langs: [
import("@shikijs/langs/javascript"),
import("@shikijs/langs/typescript"),
import("@shikijs/langs/jsx"),
import("@shikijs/langs/tsx"),
import("@shikijs/langs/markdown"),
import("@shikijs/langs/json"),
import("@shikijs/langs/css"),
import("@shikijs/langs/html"),
import("@shikijs/langs/bash"),
import("@shikijs/langs/yaml"),
],
engine: createOnigurumaEngine(import("shiki/wasm")),
});
export default async function markdownToHtml(markdown: string) {
const result = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeShikiFromHighlighter, highlighter, {
inline: "tailing-curly-colon",
themes: {
light: "github-light",
dark: "github-dark",
},
})
.use(rehypeStringify)
.process(markdown);
return result.toString();
}
Styling the themes in CSS:
The dual-theme setup requires CSS to properly toggle between light and dark modes. Add this to your global CSS file:
/* Shiki Theme */
/* Dark mode */
html.dark .markdown pre.shiki {
background-color: var(--shiki-dark-bg) !important;
color: var(--shiki-dark) !important;
}
/* Inside Code Block */
html.dark .markdown pre.shiki span {
color: var(--shiki-dark) !important;
}
/* Inline */
html.dark .markdown span.shiki {
background-color: var(--shiki-dark-bg) !important;
}
html.dark .markdown span.shiki code span.line span {
color: var(--shiki-dark) !important;
}
/* End of Shiki Theme */
Note: This CSS assumes dark mode is handled with next-themes and Tailwind CSS. The html.dark selector should match your theme provider’s toggle, and the Markdown content should be wrapped with the .markdown class.
Understanding the Pipeline
The transformation flow:
Markdown → AST → HTML AST → Highlighted HTML
Key components:
remarkParse- Converts markdown to syntax treeremarkRehype- Converts markdown AST to HTML ASTrehypeShikiFromHighlighter- Adds syntax highlightingrehypeStringify- Converts HTML AST to string
Why unified() instead of remark(): The unified processor gives you more control over the transformation pipeline. You're not limited to remark plugins; you can use both remark and rehype plugins together.
Configuration Choices That Matter
Theme setup for dark mode:
themes: {
light: "github-light",
dark: "github-dark",
}
This generates CSS custom properties that respond to your site's dark mode implementation. No JavaScript required.
Inline code highlighting:
inline: "tailing-curly-colon";
Enables syntax like const foo = "bar" for inline code highlighting. The language hint appears after the code block.
Language imports: Only import languages you actually use. Each language adds to bundle size and build time.
Understanding the Trade-offs
What You Gain
Performance benefits:
- Zero runtime JavaScript for highlighting
- No flash of unstyled content
- Smaller client bundles
- Perfect theme consistency
- VS Code-quality accuracy
Developer experience:
- Familiar VS Code themes
- Extensive language support
- Dark mode built-in
- Inline code highlighting
- No client-side configuration needed
What You Give Up
Build complexity:
- Longer build times (processes every code block)
- More complex markdown pipeline
- WASM dependency increases bundle size
- Language selection happens at build time
Runtime limitations:
- Can't dynamically highlight user-generated code
- Theme switching requires CSS, not JavaScript
- No runtime language detection
When This Approach Shines
Perfect for:
- Technical blogs with lots of code
- Documentation sites
- Static sites prioritizing performance
- Sites with consistent code languages
- Content where theme consistency matters
Struggles with:
- Dynamic code highlighting needs
- User-generated code content
- Sites with hundreds of languages
- Real-time code editing features
The sweet spot: Static content with known languages where build-time processing is acceptable.
Common Implementation Gotchas
WASM loading - The Oniguruma engine requires WebAssembly. Make sure your deployment environment supports it.
Theme CSS generation - Shiki generates CSS custom properties. Your dark mode implementation needs to toggle the appropriate classes or attributes.
Language registration - Languages must be imported at build time. You can't dynamically add languages based on content.
Bundle size consideration - Each theme and language adds to your build. Only import what you need.
The Integration Reality
Before Shiki: Fast builds, ugly code blocks
After Shiki: Slower builds, beautiful highlighting
The build time trade-off is worth it for technical content. Your readers get a better experience, and your code examples look professional.
References
- Previous SSG blog post - The foundation this builds on
- My implementation - Working code for this blog
- Shiki documentation - Official Shiki docs and examples