Skip to main content
luke@terminal:/blog$ ls
luke@terminal:/blog$ cat adding-syntax-highlighting-shiki.md

Adding Shiki for a pop of colour in my code blocks was a struggle

Created: 2025-11-23 | 4 min read

Adding Syntax Highlighting to Velite with Shiki

I already had Velite working with Next.js (covered in my previous post), with posts in /content/posts/*.md and the dev server running on port 3000.

My code blocks were just... sad. Monospace text on a dark background. No syntax highlighting, no nothing. Made my blog posts look like they were written in Notepad.

For reference, my versions:

  • Next.js ^16.1.1
  • Velite ^0.3.0
  • @shikijs/rehype ^3.20.0
  • shiki ^3.15.0
  • Node 20.18.0

Things might differ with other versions, especially with Next.js 16.

I wanted them to look like VS Code - proper colors for keywords, strings, functions, the whole deal. Shiki uses VS Code's actual syntax highlighting engine, so it seemed like the obvious choice.

Turns out "obvious" doesn't mean "straightforward." Here's what I ran into.

Setting Up Shiki

I needed two packages: Shiki itself, and the rehype plugin to hook it into Velite's markdown processing:

npm install @shikijs/rehype shiki

In my case, this grabbed shiki ^3.15.0 and @shikijs/rehype ^3.20.0.

Now it's time to hook this up in the Velite config. This is where I hit my first snag.

Problem #1: TypeScript Syntax in a JavaScript File

My first attempt:

import rehypeShiki from '@shikijs/rehype'

export default defineConfig({
  collections: { /* ... */ },
  markdown: {
    rehypePlugins: [
      [rehypeShiki as any, { theme: 'github-dark' }]
    ]
  }
})

Error:

Expected "]" but found "as"

The error was straightforward - my config file was velite.config.js (JavaScript), but I was using TypeScript syntax (as any). Just remove the type cast:

markdown: {
  rehypePlugins: [
    [rehypeShiki, { theme: 'github-dark' }]
  ]
}

Problem #2: Config Structure

I kept getting syntax errors because I couldn't get the braces right. The markdown key needs to be at the same level as collections, not inside it:

// This doesn't work - markdown inside collections
export default defineConfig({
  collections: {
    posts: { /* ... */ },
    markdown: { /* ... */ }
  }
})

// What actually works - markdown as sibling to collections
export default defineConfig({
  collections: {
    posts: { /* ... */ }
  },
  markdown: {
    rehypePlugins: [
      [rehypeShiki, { theme: 'github-dark' }]
    ]
  }
})

Count your braces carefully. I had to trace through mine multiple times.

One more thing: after changing velite.config.js, I had to clear the cache or nothing would work:

npm run clean:dev

Stop the dev server first, run the clean command, then restart. I forgot this the first time and spent 15 minutes wondering why nothing changed.

Problem #3: CSS Conflicts

Once Shiki was working, I had CSS fighting with it. My existing styles in /src/app/globals.css were overriding Shiki's colors.

I restarted the dev server, refreshed the page, and... nothing had changed. Code blocks were still just monospace text in my custom colors.

Wait, what? Shiki was supposed to be handling colors now.

I opened DevTools and saw inline styles like style="color: #ff79c6" on each code token. I realized Shiki generates these because it needs exact color control for syntax highlighting. My CSS's text-color property was more specific and overriding them.

When I disabled text-color in DevTools, Shiki colors instantly appeared. So I just needed to remove that property from my CSS and let Shiki's colors come through.

/* My initial approach - this was fighting with Shiki */
code {
  @apply bg-code-carbon text-neural-silver px-1.5 py-0.5 rounded font-mono text-sm;
}

pre {
  @apply bg-code-carbon/35 p-4 rounded-lg overflow-x-auto font-mono text-sm m-4;
}

pre code {
  @apply bg-transparent p-0 text-code-carbon;
}

The problem was that my colors were overriding Shiki's syntax highlighting.

Here's what's happening: Shiki generates inline styles on each code token (like style="color: #ff79c6" for keywords). My CSS was more specific and overriding these. What finally worked was letting Shiki handle colors for code blocks and only styling inline code myself:

/* Inline code */
article :not(pre) > code {
  @apply !bg-terminal-primary/15 !text-terminal-primary !px-1.5 !py-0.5 rounded-sm !font-mono !text-sm !border-none;
}

/* Code block container - let Shiki handle colors */
pre {
  @apply bg-terminal-dim/10 p-4 rounded-lg border border-terminal-dim/15 font-mono text-sm overflow-x-auto my-6;
}

/* Reset for code inside pre */
pre code {
  @apply !bg-transparent !p-0 !border-none !rounded-none !text-inherit;
}

Verifying It Works

When I checked a blog post after getting the CSS right, I finally saw:

  • Inline code (like const foo = 'bar' in a paragraph) had my custom background color
  • Code blocks had colorized syntax highlighting:
    • Keywords (like const, function, import) in purple/pink
    • Strings (like 'github-dark') in green
    • Comments in muted gray
    • Variables in light blue/white
  • In the example const foo = 'bar', I saw const in one color and 'bar' in a different color

When my code blocks were still just monochrome text at first, I checked Shiki's theme preview to see what github-dark was supposed to look like. That's when I realized the Velite cache hadn't cleared properly. Ran npm run clean:dev one more time, restarted the dev server, and it worked.

Untagged Code Blocks Don't Get Highlighting

I noticed code blocks without a language specified don't get Shiki styling:

```
This has no syntax highlighting
```

Two options:

  1. Always specify a language - use text or plaintext for non-code
  2. Add fallback text color in CSS:
pre code {
  @apply bg-transparent p-0 text-inherit;
}

I went with always specifying languages since it's more explicit.

What Actually Worked

After all that debugging, here's what finally worked. Two files needed changes:

velite.config.js (mine's in the project root):

import { defineConfig, s } from 'velite'
import rehypeShiki from '@shikijs/rehype'

export default defineConfig({
  collections: {
    posts: {
      name: 'Post',
      pattern: 'posts/**/*.md',
      schema: s
        .object({
          title: s.string().max(99),
          slug: s.slug('posts'),
          date: s.isodate(),
          cover: s.image().optional(),
          video: s.file().optional(),
          metadata: s.metadata(),
          excerpt: s.excerpt(),
          content: s.markdown()
        })
        .transform(data => ({ ...data, permalink: `/blog/${data.slug}` }))
    }
  },
  markdown: {
    rehypePlugins: [
      [rehypeShiki, { theme: 'github-dark' }]
    ]
  }
})

globals.css (at /src/app/globals.css):

/* Inline code */
article :not(pre) > code {
  @apply !bg-terminal-primary/15 !text-terminal-primary !px-1.5 !py-0.5 rounded-sm !font-mono !text-sm !border-none;
}

pre {
  @apply bg-terminal-dim/10 p-4 rounded-lg border border-terminal-dim/15 font-mono text-sm overflow-x-auto my-6;
}

/* Reset prose code block styling - let Shiki handle it */
pre code {
  @apply !bg-transparent !p-0 !border-none !rounded-none !text-inherit;
}

Thinking back on it, the thing that would have saved me the most time was adding Shiki first. I spent time manually styling code blocks - choosing colors, tweaking spacing, getting everything just right. Then Shiki took over and I had to undo most of it.

Other stuff that tripped me up:

  • File extensions matter - .js files can't use TypeScript syntax. Took me a few minutes of staring at Expected "]" but found "as" before I remembered the config was JavaScript, not TypeScript.

  • Config structure is finicky - Velite has specific expectations about where keys go. Count your braces carefully.

  • Specify languages in markdown - ```javascript gives proper highlighting, but ``` without a language just shows plain text.

Syntax highlighting is working now and my code blocks actually look like code blocks. Took way longer than I expected, but at least they look like code blocks now.

luke@terminal:/blog$ ls previous_post.sh
luke@terminal:/blog$ ls next_post.sh