Shiki in Next.js App Router: Zero Client JS
A how-to guide for zero-JS syntax highlighting in a Next.js App Router blog, built around Shiki’s server-side codeToHtml and a language allowlist that stops editors from breaking the build.

Server-side syntax highlighting with Shiki in the Next.js App Router means you ship exactly zero client‑side JavaScript for code blocks. That’s 0 KB of grammar definitions, 0 KB of theme data, and 0 KB of a highlighter library—browser downloads that often add 50–100 KB before a single line becomes readable. Instead, every code block is rendered into coloured HTML spans on the server, and the browser just paints it. This how‑to walks through the core move, the performance details that make it safe, and the maintenance trap that will bite you if you wire it into a CMS.
Shiki + Next.js App Router: Zero KB of Client JS
The takeaway: when you call Shiki’s codeToHtml inside a Server Component, the visitor’s browser receives ready‑to‑display HTML. No highlighter bundle is ever downloaded. Compare that with a typical client‑side highlighter like Prism or Highlight.js, where the grammar and theme data are part of the bundle the browser must fetch, parse, and execute before code becomes readable. On a slow connection, that delay is visible—a flash of unstyled text or a blank block while the scripts load.
With Shiki in the App Router, that bundle simply doesn’t exist. The server does the work once at render time, and the output is <span style="color: #..."> elements that any browser can handle immediately. The performance win is permanent, not just a first‑load trick.
The Core Move: Highlighting in a Server Component
Create a highlight function that runs codeToHtml and returns the HTML string. The key is an allowlist of supported languages that fall back to plain text when an unsupported language is requested. Without that fallback, one editor picking an unlisted language will take down the page render.
Here’s the function we use at techpotions to highlight every code block on our blog:
import { codeToHtml } from 'shiki';
const ALLOWED_LANGUAGES = new Set([
'typescript', 'tsx', 'javascript', 'jsx',
'json', 'bash', 'python', 'go', 'rust',
'sql', 'html', 'css', 'yaml', 'markdown',
'text'
]);
export async function highlight(code: string, lang: string): Promise<string> {
const safeLang = ALLOWED_LANGUAGES.has(lang) ? lang : 'text';
return codeToHtml(code, { lang: safeLang, theme: 'vitesse-dark' });
}Call this function inside a Server Component and inject the result with dangerouslySetInnerHTML. Because the HTML is generated on the server, there’s no hydration mismatch and no client‑side JavaScript involved.
// app/(blog)/[slug]/page.tsx
import { highlight } from '@/lib/shiki';
export default async function BlogPost({ code, language }) {
const html = await highlight(code, language);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}Why This Approach Scales: Shiki’s Internal Caching
A common worry: “If I highlight 20 code blocks on a page, will I create 20 highlighter instances?” No. Shiki maintains a singleton highlighter internally. It lazily loads each grammar and theme, then caches them. So repeated calls to codeToHtml within a single render are cheap—you don’t need to hand‑roll a highlighter cache or worry about instantiating one per block.
This caching is why the allowlist‑and‑fallback pattern works so well. Even if a page has a dozen code blocks in different languages, Shiki only loads the grammars it actually needs, and the fallback to text costs next to nothing.
The Maintenance Trap: Keeping Language Allowlists in Sync
The practical advice that saves a build: you need an explicit allowlist of supported languages, and it has to stay in sync with whatever your CMS offers as language options. Our allowlist is a 15‑entry set kept in lockstep with the language dropdown on our Payload code block. Anything not in the set falls back to text rather than throwing. Without the fallback, a single typo or an editor adding a new language in the CMS would break the entire page render.
If you’re wiring this into a rich‑text editor, there’s a related gotcha that hits you right before this one. The default markdown‑to‑Lexical converter has no transformer for a custom code block, so fenced code has to be split out and hand‑built into the right node type before conversion. That’s a separate problem from highlighting, but it’s the one you’ll hit immediately. (We addressed several Payload Lexical quirks while building this system—including a fix for GFM table rendering that you can read about in our lab post on Payload Lexical GFM tables.)
Build a Zero‑JS Blog with Us
This approach is at the core of the Next.js sites we build at techpotions. By moving highlighting to the server, we keep the client bundle lean and the Critical Path fast. If you want a performant Next.js blog that ships zero unnecessary JavaScript, explore our web development services or start a project with us.
FAQ
What happens if an unsupported language is passed to the highlighter?
The highlight function falls back to text—a built‑in Shiki language that wraps the code in <pre><code> without any syntax coloring. The page renders normally, the code is readable, and no error is thrown.
Can I use a different theme or multiple themes?
Shiki works with any theme you supply. You can switch themes by passing a different built‑in theme name or a custom JSON theme object to codeToHtml. For dark‑mode support, you can render both a light and dark theme on the server and toggle them with CSS, but that re‑introduces a tiny bit of client logic. Many teams simply pick one theme that works well in both modes.
How do I add a new language to the allowlist?
Install the corresponding language grammar (for example, npm install @shikijs/langs if you’re using the bundled approach) and add its identifier to the ALLOWED_LANGUAGES set. Then synchronise the CMS editor’s language dropdown so content authors can select it.