<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Luke Manning - Blog — Velite</title>
        <link>https://lukemanning.ie/</link>
        <description>Breaking things. Building things. Writing about it. (tag: Velite)</description>
        <lastBuildDate>Wed, 30 Sep 2026 12:46:24 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>All rights reserved 2026, Luke Manning</copyright>
        <atom:link href="https://lukemanning.ie/feeds/velite.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[I Have 41 Posts. I Audited Their Tags. 11 Survived.]]></title>
            <link>https://lukemanning.ie/blog/i-have-41-posts-i-audited-their-tags-11-survived</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/i-have-41-posts-i-audited-their-tags-11-survived</guid>
            <pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<p>I have 41 blog posts. They each have tags. Until last week, those tags were whatever I thought made sense when I wrote the post. Some recurred. Most didn't. The rest were categories I wanted to exist, or aspects I wanted to mention.</p>
<p>I had no system. I had vibes.</p>
<p>Then I ran the audit that wiped the registry.</p>
<hr>
<h2>The system I wanted</h2>
<p>I wanted four content pillars, four primary niches that described the shape of my blog. Every published post had to declare a pillar. Tags had to map to pillars. A build-time validator had to fail if a published post declared a pillar that no tag supported.</p>
<p>Four pillars:</p>
<ul>
<li><code>ai</code> — AI tooling, agents, OpenCode, Claude Code, agentic coding workflows</li>
<li><code>homelab</code> — self-hosting, hardware, Unraid, servers, OS setup on machines</li>
<li><code>rabbit-holes</code> — broad technical pillar: shipping projects + debugging/learning while building things</li>
<li><code>reflections</code> — non-tech, opinion, career, expanded thoughts</li>
</ul>
<p>The pillars themselves were the easy part.</p>
<p>The hard part was the tags.</p>
<h2>The audit</h2>
<p>I ran the audit as a <a href="https://github.com/mattpocock/skills/blob/main/skills/engineering/wayfinder/SKILL.md">wayfinder</a> pass. One decision per tag.</p>
<p>The mechanics were dull. Every tag lives in a post's frontmatter, so I pulled the frontmatter from all 41 posts, listed every tag with its count, and sorted by that. The agent did the tallying. The judgment calls were all mine.</p>
<p>Some tags were obviously load-bearing. <code>projex</code> showed up on every post that mentioned my component library. <code>velite</code> showed up on every post about <a href="/blog/setting-up-velite-nextjs">my content pipeline</a>. <code>homelab</code> showed up on the server posts. <code>github</code> showed up on the recovery and the deprecation posts.</p>
<p>Some tags were obviously decorative. <code>debugging</code> showed up on three posts, all of which were about different kinds of debugging. <code>workflow</code> showed up on five posts, all of which were about different kinds of workflows. <code>ai-assisted-development</code> showed up on one post.</p>
<p>The first rule was: if it recurs, keep it. That got me a first registry with about 48 tags in it, more than the posts actually used. Some were categories I wanted to exist rather than categories I had. It took reading the five <code>workflow</code> posts back to back to see the problem: they had nothing in common. The rule was measuring the wrong thing.</p>
<p>A tag earns its place if there's evidence of a series. At least one other post has to anchor it. A tag that could be folded into a broader existing tag gets folded. A tag that's used once and not part of a series dies. The count gets a hearing, but the posts have to actually belong together.</p>
<p><code>apt</code> died. Used once. Subsumed by <code>ubuntu</code>. No reason to keep it.</p>
<p><code>github</code> stayed. Multiple posts: <a href="/blog/gh-issue-view-failing-ubuntu-deprecation-apt-pin">the Ubuntu apt deprecation post</a> and <a href="/blog/git-corrupt-object-recovery">the git corruption recovery post</a>. A real series about GitHub-shaped problems.</p>
<p><code>workflow</code> died. Five uses, zero series. The tag was a context label, not a topic.</p>
<p><code>claude-code</code> stayed. Two posts already, more coming. Real series.</p>
<p>The death rate was high. Of 41 tags that appeared across the 41 posts (the equal counts were an accident), 30 died. 11 survived.</p>
<p>The 11:</p>
<ul>
<li><code>ai</code>: <code>opencode</code>, <code>hermes</code>, <code>claude-code</code></li>
<li><code>homelab</code>: <code>docker</code>, <code>zerowork</code>, <code>obsidian</code></li>
<li><code>rabbit-holes</code>: <code>projex</code>, <code>velite</code>, <code>nextjs</code>, <code>github</code>, <code>ubuntu</code></li>
</ul>
<p>(The registry also carries the pillar names themselves as tags: <code>ai</code>, <code>homelab</code>, <code>reflections</code>. Those aren't series. They're just the pillars.)</p>
<p>Yes, I could tag any post with the bare pillar name and the validator would wave it through. The validator catches accidents. The coherent-series test is what catches lies. Tagging a post <code>reflections</code> just to get through a build is <code>workflow</code> all over again.</p>
<p><code>reflections</code> is the only pillar with no series tags. The first genuinely reflective post has to bring its tag through the PR process.</p>
<h2>Why the death rate was the point</h2>
<p>A tag is a promise. When I tag a post <code>workflow</code>, if you click this tag, you'll find a series of posts about workflows. The reader lands on a tag page expecting a coherent collection.</p>
<p>If the tag delivers three posts about three different things, the tag lied. Anyone who clicked it got a pile of nothing. That's on me.</p>
<p>I keep going back and forth on whether the test is too strict. It's not, really. A tag with no series is just noise on a tag page.</p>
<p>The 30 tags that died weren't wrong. They were over-promised. They were:</p>
<ul>
<li>context labels (<code>debugging</code>, <code>thinking</code>, <code>meta</code>, <code>lessons-learned</code>, <code>retrospective</code>, <code>burnout</code>)</li>
<li>aspect descriptions (<code>css</code>, <code>styling</code>, <code>build-time</code>, <code>production</code>, <code>ai-assisted-development</code>)</li>
<li>single-use nouns (<code>apt</code>, <code>devto</code>, <code>router</code>) that fit better as a broader existing tag</li>
</ul>
<p>The 11 that survived are the tags I actually write in.</p>
<h2>What the validator actually does</h2>
<p>The build-time validator lives in <code>velite.config.js</code>, the config for <a href="https://velite.js.org">Velite</a>, my content pipeline. It runs on every build, for anything published. It checks: the declared <code>pillar</code> of a post must equal the pillar of at least one tag in <code>tags[]</code>. If it doesn't, the build fails.</p>
<p>This sounds bureaucratic. It isn't. It's the way I keep myself honest.</p>
<p>Say I write something about what two years of blogging taught me, and I mention in passing that the site runs in Docker. Tag: <code>docker</code>. Declared pillar: <code>reflections</code>. Build dead:</p>
<pre><code>[content-pillars] post 'what-blogging-taught-me' declares pillar='reflections' but no tag in tags[] maps to that pillar. Either add a tag whose registered pillar is 'reflections' or change the declared pillar.
</code></pre>
<p><code>docker</code> is registered as homelab. Nothing on the post maps to reflections. The validator won't let me silently have a post that doesn't belong to any tag series. If a post can't anchor any tag series, it shouldn't be published as a tag-anchored post. It should be published as something else, or it should get a tag that earns its place.</p>
<p>The validator also changes how I write. When I sit down to write a post and I want to tag it <code>workflow</code>, I have to ask: is this post part of a series I'm willing to commit to? If yes, fine. If no, the tag dies and I pick a real one.</p>
<p>I don't know if that counts as discipline or just an elaborate way to make my own builds fail. But the vibes never made me stop and ask.</p>
<h2>The two ADRs that came out of this</h2>
<p>The whole taxonomy lives in two architecture decision records, under <code>docs/adr/</code> in this site's repo.</p>
<p>ADR 0001 — the four pillars, the tag registration rule, the coherent-series test. This is the constitution.</p>
<p>ADR 0002 — the SEO policy. How tag pages render, what metadata they expose, how they relate to pillar pages. This is the policy that turns the constitution into something Google can crawl.</p>
<p>The wayfinder pass gave me a rule I could encode in the validator.</p>
<p>I knew what the blog was about. I had been writing it for two years. I didn't have the taxonomy for what it was about until I counted the tags.</p>
<h2>New tags go through me</h2>
<p>There will be new tags. New tags require a PR to the registry (<code>src/data/content-pillars.ts</code>) with a one-line rationale that passes the coherent-series test. No agent mediates this. I'm the curator.</p>
<p>That's the right shape. Tags are mine. The agent can write the post, can audit the existing tags, can build the validator. The decision about whether a new tag earns its place is mine.</p>
<p>The death rate felt bad while I was doing it. Felt like I was throwing things away, or being too strict.</p>
<p>Then I read a tag page with three posts that actually belonged together, and I realized what the page would have looked like with seven tags on it, half of which lied. The page would have been unreadable. The reader would have clicked and bounced. The tag would have meant nothing.</p>
<p>Most of the work in this was deletion. 30 tags out of 41 went. The posts stayed. They're still about what they were about. The surviving tags earned their place.</p>]]></description>
            <content:encoded><![CDATA[<p>I have 41 blog posts. They each have tags. Until last week, those tags were whatever I thought made sense when I wrote the post. Some recurred. Most didn't. The rest were categories I wanted to exist, or aspects I wanted to mention.</p>
<p>I had no system. I had vibes.</p>
<p>Then I ran the audit that wiped the registry.</p>
<hr>
<h2>The system I wanted</h2>
<p>I wanted four content pillars, four primary niches that described the shape of my blog. Every published post had to declare a pillar. Tags had to map to pillars. A build-time validator had to fail if a published post declared a pillar that no tag supported.</p>
<p>Four pillars:</p>
<ul>
<li><code>ai</code> — AI tooling, agents, OpenCode, Claude Code, agentic coding workflows</li>
<li><code>homelab</code> — self-hosting, hardware, Unraid, servers, OS setup on machines</li>
<li><code>rabbit-holes</code> — broad technical pillar: shipping projects + debugging/learning while building things</li>
<li><code>reflections</code> — non-tech, opinion, career, expanded thoughts</li>
</ul>
<p>The pillars themselves were the easy part.</p>
<p>The hard part was the tags.</p>
<h2>The audit</h2>
<p>I ran the audit as a <a href="https://github.com/mattpocock/skills/blob/main/skills/engineering/wayfinder/SKILL.md">wayfinder</a> pass. One decision per tag.</p>
<p>The mechanics were dull. Every tag lives in a post's frontmatter, so I pulled the frontmatter from all 41 posts, listed every tag with its count, and sorted by that. The agent did the tallying. The judgment calls were all mine.</p>
<p>Some tags were obviously load-bearing. <code>projex</code> showed up on every post that mentioned my component library. <code>velite</code> showed up on every post about <a href="/blog/setting-up-velite-nextjs">my content pipeline</a>. <code>homelab</code> showed up on the server posts. <code>github</code> showed up on the recovery and the deprecation posts.</p>
<p>Some tags were obviously decorative. <code>debugging</code> showed up on three posts, all of which were about different kinds of debugging. <code>workflow</code> showed up on five posts, all of which were about different kinds of workflows. <code>ai-assisted-development</code> showed up on one post.</p>
<p>The first rule was: if it recurs, keep it. That got me a first registry with about 48 tags in it, more than the posts actually used. Some were categories I wanted to exist rather than categories I had. It took reading the five <code>workflow</code> posts back to back to see the problem: they had nothing in common. The rule was measuring the wrong thing.</p>
<p>A tag earns its place if there's evidence of a series. At least one other post has to anchor it. A tag that could be folded into a broader existing tag gets folded. A tag that's used once and not part of a series dies. The count gets a hearing, but the posts have to actually belong together.</p>
<p><code>apt</code> died. Used once. Subsumed by <code>ubuntu</code>. No reason to keep it.</p>
<p><code>github</code> stayed. Multiple posts: <a href="/blog/gh-issue-view-failing-ubuntu-deprecation-apt-pin">the Ubuntu apt deprecation post</a> and <a href="/blog/git-corrupt-object-recovery">the git corruption recovery post</a>. A real series about GitHub-shaped problems.</p>
<p><code>workflow</code> died. Five uses, zero series. The tag was a context label, not a topic.</p>
<p><code>claude-code</code> stayed. Two posts already, more coming. Real series.</p>
<p>The death rate was high. Of 41 tags that appeared across the 41 posts (the equal counts were an accident), 30 died. 11 survived.</p>
<p>The 11:</p>
<ul>
<li><code>ai</code>: <code>opencode</code>, <code>hermes</code>, <code>claude-code</code></li>
<li><code>homelab</code>: <code>docker</code>, <code>zerowork</code>, <code>obsidian</code></li>
<li><code>rabbit-holes</code>: <code>projex</code>, <code>velite</code>, <code>nextjs</code>, <code>github</code>, <code>ubuntu</code></li>
</ul>
<p>(The registry also carries the pillar names themselves as tags: <code>ai</code>, <code>homelab</code>, <code>reflections</code>. Those aren't series. They're just the pillars.)</p>
<p>Yes, I could tag any post with the bare pillar name and the validator would wave it through. The validator catches accidents. The coherent-series test is what catches lies. Tagging a post <code>reflections</code> just to get through a build is <code>workflow</code> all over again.</p>
<p><code>reflections</code> is the only pillar with no series tags. The first genuinely reflective post has to bring its tag through the PR process.</p>
<h2>Why the death rate was the point</h2>
<p>A tag is a promise. When I tag a post <code>workflow</code>, if you click this tag, you'll find a series of posts about workflows. The reader lands on a tag page expecting a coherent collection.</p>
<p>If the tag delivers three posts about three different things, the tag lied. Anyone who clicked it got a pile of nothing. That's on me.</p>
<p>I keep going back and forth on whether the test is too strict. It's not, really. A tag with no series is just noise on a tag page.</p>
<p>The 30 tags that died weren't wrong. They were over-promised. They were:</p>
<ul>
<li>context labels (<code>debugging</code>, <code>thinking</code>, <code>meta</code>, <code>lessons-learned</code>, <code>retrospective</code>, <code>burnout</code>)</li>
<li>aspect descriptions (<code>css</code>, <code>styling</code>, <code>build-time</code>, <code>production</code>, <code>ai-assisted-development</code>)</li>
<li>single-use nouns (<code>apt</code>, <code>devto</code>, <code>router</code>) that fit better as a broader existing tag</li>
</ul>
<p>The 11 that survived are the tags I actually write in.</p>
<h2>What the validator actually does</h2>
<p>The build-time validator lives in <code>velite.config.js</code>, the config for <a href="https://velite.js.org">Velite</a>, my content pipeline. It runs on every build, for anything published. It checks: the declared <code>pillar</code> of a post must equal the pillar of at least one tag in <code>tags[]</code>. If it doesn't, the build fails.</p>
<p>This sounds bureaucratic. It isn't. It's the way I keep myself honest.</p>
<p>Say I write something about what two years of blogging taught me, and I mention in passing that the site runs in Docker. Tag: <code>docker</code>. Declared pillar: <code>reflections</code>. Build dead:</p>
<pre><code>[content-pillars] post 'what-blogging-taught-me' declares pillar='reflections' but no tag in tags[] maps to that pillar. Either add a tag whose registered pillar is 'reflections' or change the declared pillar.
</code></pre>
<p><code>docker</code> is registered as homelab. Nothing on the post maps to reflections. The validator won't let me silently have a post that doesn't belong to any tag series. If a post can't anchor any tag series, it shouldn't be published as a tag-anchored post. It should be published as something else, or it should get a tag that earns its place.</p>
<p>The validator also changes how I write. When I sit down to write a post and I want to tag it <code>workflow</code>, I have to ask: is this post part of a series I'm willing to commit to? If yes, fine. If no, the tag dies and I pick a real one.</p>
<p>I don't know if that counts as discipline or just an elaborate way to make my own builds fail. But the vibes never made me stop and ask.</p>
<h2>The two ADRs that came out of this</h2>
<p>The whole taxonomy lives in two architecture decision records, under <code>docs/adr/</code> in this site's repo.</p>
<p>ADR 0001 — the four pillars, the tag registration rule, the coherent-series test. This is the constitution.</p>
<p>ADR 0002 — the SEO policy. How tag pages render, what metadata they expose, how they relate to pillar pages. This is the policy that turns the constitution into something Google can crawl.</p>
<p>The wayfinder pass gave me a rule I could encode in the validator.</p>
<p>I knew what the blog was about. I had been writing it for two years. I didn't have the taxonomy for what it was about until I counted the tags.</p>
<h2>New tags go through me</h2>
<p>There will be new tags. New tags require a PR to the registry (<code>src/data/content-pillars.ts</code>) with a one-line rationale that passes the coherent-series test. No agent mediates this. I'm the curator.</p>
<p>That's the right shape. Tags are mine. The agent can write the post, can audit the existing tags, can build the validator. The decision about whether a new tag earns its place is mine.</p>
<p>The death rate felt bad while I was doing it. Felt like I was throwing things away, or being too strict.</p>
<p>Then I read a tag page with three posts that actually belonged together, and I realized what the page would have looked like with seven tags on it, half of which lied. The page would have been unreadable. The reader would have clicked and bounced. The tag would have meant nothing.</p>
<p>Most of the work in this was deletion. 30 tags out of 41 went. The posts stayed. They're still about what they were about. The surviving tags earned their place.</p>]]></content:encoded>
            <category>velite</category>
            <category>nextjs</category>
        </item>
        <item>
            <title><![CDATA[I Fixed Velite Watch Mode Problem (And Immediately Broke It Again)]]></title>
            <link>https://lukemanning.ie/blog/fixing-velite-watch-mode-irony</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/fixing-velite-watch-mode-irony</guid>
            <pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<p>I just ran blog review agent on my <a href="/blog/adding-syntax-highlighting-shiki">Adding Syntax Highlighting to Velite with Shiki</a> post. Made a few revisions to content including fixing tutorial framing, added version numbers, corrected CSS classes. Committed everything to git.</p>
<p>Then I refreshed <a href="http://localhost:3000">http://localhost:3000</a>.</p>
<p>Still seeing old content. Weird. This never happens.</p>
<h2>The Confusion</h2>
<p>Honestly, this annoyed me more than it should have. I had just spent all this time fixing the post, and now I can't even see the changes?</p>
<p>Velite was supposed to watch files automatically, like some content management systems do. Velite doesn't watch by default - you have to configure it. I'd written about this exact issue in a previous post. The irony was not lost on me.</p>
<p>So I restarted the dev server, cleared cache, expecting everything to work. The new post showed up, but Velite still wasn't watching files. Changes only appeared when I restarted the server. This definitely is a regression on what I had previously configured.</p>
<h2>What's Actually Going On?</h2>
<p>Velite's docs say it supports watch mode, but it needs to be configured properly. The integration with Next.js happens through next.config.ts - that's where Velite gets initialized. If watch mode isn't set up there, Velite just runs once when the server starts and never again.</p>
<p>I opened <code>next.config.ts</code> at the root of my project. Here's what the file looked like:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { NextConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'next/config'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> nextConfig</span><span style="color:#F97583">:</span><span style="color:#B392F0"> NextConfig</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#6A737D">  // some other config...</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#E1E4E8"> nextConfig</span></span></code></pre>
<p>Oh. The Velite integration code was completely missing.</p>
<p>I found the Velite integration code in their Next.js docs. Here's what should have been in next.config.ts:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { NextConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'next/config'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> nextConfig</span><span style="color:#F97583">:</span><span style="color:#B392F0"> NextConfig</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#6A737D">  // some other config...</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// Velite integration</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'development'</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isBuild</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> (isDev </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> isBuild)) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: isDev, clean: </span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isDev }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#E1E4E8"> nextConfig</span></span></code></pre>
<p>But there was nothing there. Just Next.js config.</p>
<p>So that's why watch mode wasn't working. The code to start Velite at all was gone.</p>
<h2>Why Was It Removed?</h2>
<p>I checked git history to see when it disappeared. Found commit <code>1afe3e2</code> with message "Duplicate Velite build issue" that had removed all the Velite integration code. I figured I must have made a mistake.</p>
<p>So I restored the code from git. Velite was running again. Watch mode was working. Changes appeared instantly.</p>
<p>Then I remembered what the duplicate build issue actually was.</p>
<h2>The Double Build Problem</h2>
<p>When I deployed to Vercel, I was seeing Velite build twice in the logs:</p>
<pre><code>[VELITE] building...
[VELITE] building... again
</code></pre>
<p>The logs showed Velite building twice. I still don't know the exact root cause, but what I observed was: Next.js was triggering Velite during its build process, and something else (Vercel's Velite integration?) was also triggering a build. Two builds were happening.</p>
<p>The original code was running Velite in both development (<code>isDev</code>) and production (<code>isBuild</code>). In production, when <code>isBuild</code> is true, Next.js runs during the build process. If Vercel also has its own Velite integration, both would be triggering Velite builds during deployment. That's inefficient. So commit <code>1afe3e2</code> removed the Velite integration entirely to stop the double builds.</p>
<p>The problem with that approach? It also broke watch mode in development because the commit removed ALL the integration code.</p>
<h2>The Actual Fix</h2>
<p>The real solution is to only run Velite watch mode in development. Vercel handles Velite builds separately in production (I verified this by checking my Vercel project settings - there's a "Velite" section that shows it runs builds during deployment), so Next.js shouldn't trigger them.</p>
<p>Here's what I ended up with:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'development'</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> isDev) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">, clean: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8"> }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>This is different from the original code:</p>
<ul>
<li>Removed <code>isBuild</code> - only run in development</li>
<li>I ended up using <code>watch: true</code> in dev mode</li>
<li>I ended up using <code>clean: false</code> in dev mode (I tried both settings. With <code>clean: true</code>, Velite would rebuild everything from scratch each time a file changed. With <code>clean: false</code>, it only rebuilt changed files, which was much faster)</li>
<li>Vercel handles Velite builds separately in production (no watch mode needed)</li>
</ul>
<p>The <code>VELITE_STARTED</code> environment variable prevents Velite from starting multiple times during Next.js hot reloads. Without it, every time Next.js reloaded the config (when files change), Velite would start again, which causes issues.</p>
<p>To verify this works, I changed the content in one of my blog posts without restarting the dev server. Within a second, the change appeared in the browser. I also checked my Vercel deploy logs and only saw one Velite build.</p>
<p>Happy days. Back in business.</p>]]></description>
            <content:encoded><![CDATA[<p>I just ran blog review agent on my <a href="/blog/adding-syntax-highlighting-shiki">Adding Syntax Highlighting to Velite with Shiki</a> post. Made a few revisions to content including fixing tutorial framing, added version numbers, corrected CSS classes. Committed everything to git.</p>
<p>Then I refreshed <a href="http://localhost:3000">http://localhost:3000</a>.</p>
<p>Still seeing old content. Weird. This never happens.</p>
<h2>The Confusion</h2>
<p>Honestly, this annoyed me more than it should have. I had just spent all this time fixing the post, and now I can't even see the changes?</p>
<p>Velite was supposed to watch files automatically, like some content management systems do. Velite doesn't watch by default - you have to configure it. I'd written about this exact issue in a previous post. The irony was not lost on me.</p>
<p>So I restarted the dev server, cleared cache, expecting everything to work. The new post showed up, but Velite still wasn't watching files. Changes only appeared when I restarted the server. This definitely is a regression on what I had previously configured.</p>
<h2>What's Actually Going On?</h2>
<p>Velite's docs say it supports watch mode, but it needs to be configured properly. The integration with Next.js happens through next.config.ts - that's where Velite gets initialized. If watch mode isn't set up there, Velite just runs once when the server starts and never again.</p>
<p>I opened <code>next.config.ts</code> at the root of my project. Here's what the file looked like:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { NextConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'next/config'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> nextConfig</span><span style="color:#F97583">:</span><span style="color:#B392F0"> NextConfig</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#6A737D">  // some other config...</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#E1E4E8"> nextConfig</span></span></code></pre>
<p>Oh. The Velite integration code was completely missing.</p>
<p>I found the Velite integration code in their Next.js docs. Here's what should have been in next.config.ts:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { NextConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'next/config'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> nextConfig</span><span style="color:#F97583">:</span><span style="color:#B392F0"> NextConfig</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#6A737D">  // some other config...</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// Velite integration</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'development'</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isBuild</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> (isDev </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> isBuild)) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: isDev, clean: </span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isDev }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#E1E4E8"> nextConfig</span></span></code></pre>
<p>But there was nothing there. Just Next.js config.</p>
<p>So that's why watch mode wasn't working. The code to start Velite at all was gone.</p>
<h2>Why Was It Removed?</h2>
<p>I checked git history to see when it disappeared. Found commit <code>1afe3e2</code> with message "Duplicate Velite build issue" that had removed all the Velite integration code. I figured I must have made a mistake.</p>
<p>So I restored the code from git. Velite was running again. Watch mode was working. Changes appeared instantly.</p>
<p>Then I remembered what the duplicate build issue actually was.</p>
<h2>The Double Build Problem</h2>
<p>When I deployed to Vercel, I was seeing Velite build twice in the logs:</p>
<pre><code>[VELITE] building...
[VELITE] building... again
</code></pre>
<p>The logs showed Velite building twice. I still don't know the exact root cause, but what I observed was: Next.js was triggering Velite during its build process, and something else (Vercel's Velite integration?) was also triggering a build. Two builds were happening.</p>
<p>The original code was running Velite in both development (<code>isDev</code>) and production (<code>isBuild</code>). In production, when <code>isBuild</code> is true, Next.js runs during the build process. If Vercel also has its own Velite integration, both would be triggering Velite builds during deployment. That's inefficient. So commit <code>1afe3e2</code> removed the Velite integration entirely to stop the double builds.</p>
<p>The problem with that approach? It also broke watch mode in development because the commit removed ALL the integration code.</p>
<h2>The Actual Fix</h2>
<p>The real solution is to only run Velite watch mode in development. Vercel handles Velite builds separately in production (I verified this by checking my Vercel project settings - there's a "Velite" section that shows it runs builds during deployment), so Next.js shouldn't trigger them.</p>
<p>Here's what I ended up with:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'development'</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> isDev) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">, clean: </span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8"> }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>This is different from the original code:</p>
<ul>
<li>Removed <code>isBuild</code> - only run in development</li>
<li>I ended up using <code>watch: true</code> in dev mode</li>
<li>I ended up using <code>clean: false</code> in dev mode (I tried both settings. With <code>clean: true</code>, Velite would rebuild everything from scratch each time a file changed. With <code>clean: false</code>, it only rebuilt changed files, which was much faster)</li>
<li>Vercel handles Velite builds separately in production (no watch mode needed)</li>
</ul>
<p>The <code>VELITE_STARTED</code> environment variable prevents Velite from starting multiple times during Next.js hot reloads. Without it, every time Next.js reloaded the config (when files change), Velite would start again, which causes issues.</p>
<p>To verify this works, I changed the content in one of my blog posts without restarting the dev server. Within a second, the change appeared in the browser. I also checked my Vercel deploy logs and only saw one Velite build.</p>
<p>Happy days. Back in business.</p>]]></content:encoded>
            <category>velite</category>
            <category>nextjs</category>
        </item>
        <item>
            <title><![CDATA[Draft Posts Still Showing in Production: My Velite Filtering Journey]]></title>
            <link>https://lukemanning.ie/blog/velite-draft-filtering-not-working</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/velite-draft-filtering-not-working</guid>
            <pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<p>I deployed my blog to Vercel, and there they were. All 17 draft posts, live on the internet. Great.</p>
<p>If you're also setting up a blog with Velite, I wrote about <a href="/blog/setting-up-velite-nextjs-revised">the full Velite/Next.js setup process</a> as well — this was part of that same initial build.</p>
<p>I had previously set up draft posts following <a href="/blog/adding-draft-posts-to-velite">my guide on adding draft posts to Velite</a>. The frontmatter clearly said <code>draft: true</code>, but Velite didn't seem to care. My blog was showing posts that shouldn't exist yet - they appeared in the post list with full content visible, no indication they were drafts at all.</p>
<h2>My Initial Setup</h2>
<p>I'm using Velite 0.3.0 with Next.js 16.1.1. Here's what my <code>velite.config.js</code> looked like initially:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// velite.config.js (original)</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: [</span></span>
<span class="line"><span style="color:#E1E4E8">    {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: {</span></span>
<span class="line"><span style="color:#6A737D">        // ... schema definition</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#B392F0">      filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">        const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">VERCEL_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">        return</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft </span><span style="color:#F97583">:</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#E1E4E8">      }</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  ],</span></span>
<span class="line"><span style="color:#6A737D">  // ... rest of config</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>The logic seemed sound: check if we're on Vercel production, and if so, filter out draft posts. In development, show everything including drafts.</p>
<p>This worked fine locally. I could see my drafts when writing, and published posts appeared on the blog. But when I deployed to Vercel, all my drafts went live.</p>
<h2>First Suspicion: Maybe VERCEL_ENV Isn't Set at Build Time?</h2>
<p>I had a suspicion that <code>VERCEL_ENV</code> wasn't being set correctly when Velite runs its build process. Vercel sets this environment variable, but maybe it's not available during the static site generation phase when Velite is processing posts.</p>
<p>I changed the config to use <code>NODE_ENV</code> instead, since that's definitely set during the build:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// velite.config.js (attempt 1)</span></span>
<span class="line"><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">  return</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft </span><span style="color:#F97583">:</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>I was hoping this would work because <code>NODE_ENV</code> is set by Next.js when running <code>npm run build</code>, so it should be available when Velite processes the posts.</p>
<h2>First Test: Still There</h2>
<p>I ran the build:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">NODE_ENV</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">production</span><span style="color:#B392F0"> npm</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> build</span></span></code></pre>
<p>Then checked <code>.velite/posts.json</code> to see what was generated. All 18 posts. Including 17 drafts.</p>
<p>I also checked what <code>VERCEL_ENV</code> was actually set to during the build:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// Quick debug check</span></span>
<span class="line"><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'VERCEL_ENV:'</span><span style="color:#E1E4E8">, process.env.</span><span style="color:#79B8FF">VERCEL_ENV</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">  return</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft </span><span style="color:#F97583">:</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Output: <code>VERCEL_ENV: undefined</code> - exactly what I suspected. The environment variable wasn't being set during Velite's build process.</p>
<p>Okay, so the filter function wasn't working as I expected.</p>
<h2>Next I Tried: Check Timing of Environment Evaluation</h2>
<p>I wondered if <code>NODE_ENV</code> was being evaluated when the module loads instead of when Velite actually processes each post. If the module loads before <code>NODE_ENV</code> is set to 'production', the filter would always see development mode.</p>
<p>But wait - that didn't make sense. I was explicitly running <code>NODE_ENV=production npm run build</code>, so the environment variable should be set before any Node modules load. The timing wasn't the issue.</p>
<p>Something else was going on.</p>
<h2>Does the Filter Function Even Run?</h2>
<p>Let me see if the filter function is even being called:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Filtering post:'</span><span style="color:#E1E4E8">, post.title, </span><span style="color:#9ECBFF">'draft:'</span><span style="color:#E1E4E8">, post.draft)</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">  return</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft </span><span style="color:#F97583">:</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Ran the build. I was watching the terminal where I ran <code>npm run build</code>, but no logs appeared there. The filter function wasn't being invoked at all, or at least not in a way that would show logs during the build process.</p>
<h2>So I Tested the Filter Logic Itself</h2>
<p>Maybe the filter function syntax was wrong? Let me test by making it always return false, which should exclude all posts:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Filtering post:'</span><span style="color:#E1E4E8">, post.title)</span></span>
<span class="line"><span style="color:#F97583">  return</span><span style="color:#79B8FF"> false</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Built again. Checked <code>.velite/posts.json</code>. All 18 posts still there.</p>
<p>At this point I was genuinely confused. The filter function exists in the Velite API, but it doesn't seem to affect the generated output at all. Either I'm using it wrong, or it's not doing what I think it does.</p>
<h2>Filter in the complete() Callback</h2>
<p>Velite has a <code>complete()</code> callback that runs after it generates all the data. Maybe filtering needs to happen there, since it's called after processing is complete?</p>
<p>I thought this might work because in my mental model, Velite processed the posts first, then wrote the JSON files, then called <code>complete()</code> to do final cleanup. If that was the case, modifying <code>data.posts</code> in <code>complete()</code> would affect what got written to the JSON file.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">complete</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">data</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'isProduction:'</span><span style="color:#E1E4E8">, isProduction)</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Posts before filter:'</span><span style="color:#E1E4E8">, data.posts.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> (isProduction) {</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#79B8FF"> draftCount</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> data.posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">p</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> p.draft).</span><span style="color:#79B8FF">length</span></span>
<span class="line"><span style="color:#E1E4E8">    console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Filtering out'</span><span style="color:#E1E4E8">, draftCount, </span><span style="color:#9ECBFF">'draft posts'</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">    data.posts </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> data.posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583"> =></span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft)</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Posts after filter:'</span><span style="color:#E1E4E8">, data.posts.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#6A737D">  // ... rest of function</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Ran <code>NODE_ENV=production npm run build</code>.</p>
<p>Output:</p>
<pre><code>isProduction: true
Posts before filter: 18
Filtering out 17 draft posts
Posts after filter: 1
</code></pre>
<p>Okay, so the filtering logic itself works! The logs show 1 post after filtering.</p>
<p>But when I opened <code>.velite/posts.json</code> to verify, I was surprised - all 18 posts were there. My mental model was wrong. The <code>complete()</code> callback must run AFTER the JSON files are written, or it doesn't affect the generated output files at all.</p>
<p>Either way, the filtering I was doing wasn't making it into the JSON file.</p>
<h2>The Breakthrough</h2>
<p>Then it clicked. Velite generates the JSON, but I don't have to use all of it. My app imports posts from <code>.velite/posts.json</code> in <code>src/lib/posts.ts</code>, where I sort them and prepare them for display. Why not filter there too?</p>
<p>This is runtime filtering - it happens right when the posts are accessed by my app, not during Velite's build process. That means <code>NODE_ENV</code> would definitely be set correctly because it's happening in the Next.js runtime.</p>
<p>I know build-time filtering is theoretically faster since filtering happens once during the build instead of on every request. But at this point, I was tired of debugging the build process and wanted something that actually worked. I can always optimize later if performance becomes an issue - right now, reliability matters more.</p>
<p>Here's what <code>src/lib/posts.ts</code> looked like before:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// src/lib/posts.ts (before)</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> posts </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '.velite/posts.json'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { cache } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'react'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> getPosts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> cache</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=></span></span>
<span class="line"><span style="color:#E1E4E8">  posts.</span><span style="color:#B392F0">sort</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">a</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">b</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(b.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">-</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(a.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">())</span></span>
<span class="line"><span style="color:#E1E4E8">)</span></span></code></pre>
<p>And here's what I changed it to:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// src/lib/posts.ts (after)</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> posts </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '.velite/posts.json'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { cache } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'react'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> filteredPosts</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583"> =></span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft) </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> posts</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> getPosts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> cache</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=></span></span>
<span class="line"><span style="color:#E1E4E8">  filteredPosts.</span><span style="color:#B392F0">sort</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">a</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">b</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(b.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">-</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(a.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">())</span></span>
<span class="line"><span style="color:#E1E4E8">)</span></span></code></pre>
<p>Now when <code>getPosts()</code> is called, it checks <code>NODE_ENV</code> and returns filtered posts if we're in production.</p>
<p>Before deploying, I wanted to verify locally:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">NODE_ENV</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">production</span><span style="color:#B392F0"> npm</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> build</span></span>
<span class="line"><span style="color:#E1E4E8">NODE_ENV</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">production</span><span style="color:#B392F0"> npm</span><span style="color:#9ECBFF"> start</span></span></code></pre>
<p>Opened localhost:3000 in the browser. Only the published post showed up. The drafts were gone.</p>
<p>Deployed to Vercel. Checked the production URL directly (not from cache - used an incognito window to be sure). Only the published post shows up. The drafts are gone.</p>
<hr>
<p><strong>Quick note about preview deployments</strong>: The <code>NODE_ENV</code> check I used means preview deployments on Vercel will also hide draft posts, since <code>NODE_ENV</code> is 'production' in preview builds. If you want drafts to show in preview deployments, you'd need to check <code>VERCEL_ENV === 'production'</code> instead. But for my use case, hiding drafts in all non-dev environments works fine.</p>
<hr>
<h2>Cleaning Up</h2>
<p>I removed the filter logic from <code>velite.config.js</code> since it wasn't working anyway:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// velite.config.js (final)</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: [</span></span>
<span class="line"><span style="color:#E1E4E8">    {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: {</span></span>
<span class="line"><span style="color:#6A737D">        // ... schema definition</span></span>
<span class="line"><span style="color:#E1E4E8">      }</span></span>
<span class="line"><span style="color:#6A737D">      // No filter function - filtering happens in src/lib/posts.ts</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  ],</span></span>
<span class="line"><span style="color:#6A737D">  // ... rest of config</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>Committed with message: "Fix draft post filtering to work in production"</p>
<p>Ran <code>npm run lint</code> to make sure everything was clean. Deployed. Verified.</p>
<p>Now my drafts stay drafts, and published posts are the only ones visible in production.</p>
<p>I spent quite a while trying to make build-time filtering work in Velite before realizing runtime filtering in my app was actually the right approach.</p>]]></description>
            <content:encoded><![CDATA[<p>I deployed my blog to Vercel, and there they were. All 17 draft posts, live on the internet. Great.</p>
<p>If you're also setting up a blog with Velite, I wrote about <a href="/blog/setting-up-velite-nextjs-revised">the full Velite/Next.js setup process</a> as well — this was part of that same initial build.</p>
<p>I had previously set up draft posts following <a href="/blog/adding-draft-posts-to-velite">my guide on adding draft posts to Velite</a>. The frontmatter clearly said <code>draft: true</code>, but Velite didn't seem to care. My blog was showing posts that shouldn't exist yet - they appeared in the post list with full content visible, no indication they were drafts at all.</p>
<h2>My Initial Setup</h2>
<p>I'm using Velite 0.3.0 with Next.js 16.1.1. Here's what my <code>velite.config.js</code> looked like initially:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// velite.config.js (original)</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: [</span></span>
<span class="line"><span style="color:#E1E4E8">    {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: {</span></span>
<span class="line"><span style="color:#6A737D">        // ... schema definition</span></span>
<span class="line"><span style="color:#E1E4E8">      },</span></span>
<span class="line"><span style="color:#B392F0">      filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">        const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">VERCEL_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">        return</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft </span><span style="color:#F97583">:</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#E1E4E8">      }</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  ],</span></span>
<span class="line"><span style="color:#6A737D">  // ... rest of config</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>The logic seemed sound: check if we're on Vercel production, and if so, filter out draft posts. In development, show everything including drafts.</p>
<p>This worked fine locally. I could see my drafts when writing, and published posts appeared on the blog. But when I deployed to Vercel, all my drafts went live.</p>
<h2>First Suspicion: Maybe VERCEL_ENV Isn't Set at Build Time?</h2>
<p>I had a suspicion that <code>VERCEL_ENV</code> wasn't being set correctly when Velite runs its build process. Vercel sets this environment variable, but maybe it's not available during the static site generation phase when Velite is processing posts.</p>
<p>I changed the config to use <code>NODE_ENV</code> instead, since that's definitely set during the build:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// velite.config.js (attempt 1)</span></span>
<span class="line"><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">  return</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft </span><span style="color:#F97583">:</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>I was hoping this would work because <code>NODE_ENV</code> is set by Next.js when running <code>npm run build</code>, so it should be available when Velite processes the posts.</p>
<h2>First Test: Still There</h2>
<p>I ran the build:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">NODE_ENV</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">production</span><span style="color:#B392F0"> npm</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> build</span></span></code></pre>
<p>Then checked <code>.velite/posts.json</code> to see what was generated. All 18 posts. Including 17 drafts.</p>
<p>I also checked what <code>VERCEL_ENV</code> was actually set to during the build:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// Quick debug check</span></span>
<span class="line"><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'VERCEL_ENV:'</span><span style="color:#E1E4E8">, process.env.</span><span style="color:#79B8FF">VERCEL_ENV</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">  return</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft </span><span style="color:#F97583">:</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Output: <code>VERCEL_ENV: undefined</code> - exactly what I suspected. The environment variable wasn't being set during Velite's build process.</p>
<p>Okay, so the filter function wasn't working as I expected.</p>
<h2>Next I Tried: Check Timing of Environment Evaluation</h2>
<p>I wondered if <code>NODE_ENV</code> was being evaluated when the module loads instead of when Velite actually processes each post. If the module loads before <code>NODE_ENV</code> is set to 'production', the filter would always see development mode.</p>
<p>But wait - that didn't make sense. I was explicitly running <code>NODE_ENV=production npm run build</code>, so the environment variable should be set before any Node modules load. The timing wasn't the issue.</p>
<p>Something else was going on.</p>
<h2>Does the Filter Function Even Run?</h2>
<p>Let me see if the filter function is even being called:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Filtering post:'</span><span style="color:#E1E4E8">, post.title, </span><span style="color:#9ECBFF">'draft:'</span><span style="color:#E1E4E8">, post.draft)</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">  return</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft </span><span style="color:#F97583">:</span><span style="color:#79B8FF"> true</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Ran the build. I was watching the terminal where I ran <code>npm run build</code>, but no logs appeared there. The filter function wasn't being invoked at all, or at least not in a way that would show logs during the build process.</p>
<h2>So I Tested the Filter Logic Itself</h2>
<p>Maybe the filter function syntax was wrong? Let me test by making it always return false, which should exclude all posts:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">post</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Filtering post:'</span><span style="color:#E1E4E8">, post.title)</span></span>
<span class="line"><span style="color:#F97583">  return</span><span style="color:#79B8FF"> false</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Built again. Checked <code>.velite/posts.json</code>. All 18 posts still there.</p>
<p>At this point I was genuinely confused. The filter function exists in the Velite API, but it doesn't seem to affect the generated output at all. Either I'm using it wrong, or it's not doing what I think it does.</p>
<h2>Filter in the complete() Callback</h2>
<p>Velite has a <code>complete()</code> callback that runs after it generates all the data. Maybe filtering needs to happen there, since it's called after processing is complete?</p>
<p>I thought this might work because in my mental model, Velite processed the posts first, then wrote the JSON files, then called <code>complete()</code> to do final cleanup. If that was the case, modifying <code>data.posts</code> in <code>complete()</code> would affect what got written to the JSON file.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">complete</span><span style="color:#E1E4E8">: (</span><span style="color:#FFAB70">data</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'isProduction:'</span><span style="color:#E1E4E8">, isProduction)</span></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Posts before filter:'</span><span style="color:#E1E4E8">, data.posts.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">  if</span><span style="color:#E1E4E8"> (isProduction) {</span></span>
<span class="line"><span style="color:#F97583">    const</span><span style="color:#79B8FF"> draftCount</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> data.posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">p</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> p.draft).</span><span style="color:#79B8FF">length</span></span>
<span class="line"><span style="color:#E1E4E8">    console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Filtering out'</span><span style="color:#E1E4E8">, draftCount, </span><span style="color:#9ECBFF">'draft posts'</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#E1E4E8">    data.posts </span><span style="color:#F97583">=</span><span style="color:#E1E4E8"> data.posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583"> =></span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft)</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">  console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'Posts after filter:'</span><span style="color:#E1E4E8">, data.posts.</span><span style="color:#79B8FF">length</span><span style="color:#E1E4E8">)</span></span>
<span class="line"><span style="color:#6A737D">  // ... rest of function</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Ran <code>NODE_ENV=production npm run build</code>.</p>
<p>Output:</p>
<pre><code>isProduction: true
Posts before filter: 18
Filtering out 17 draft posts
Posts after filter: 1
</code></pre>
<p>Okay, so the filtering logic itself works! The logs show 1 post after filtering.</p>
<p>But when I opened <code>.velite/posts.json</code> to verify, I was surprised - all 18 posts were there. My mental model was wrong. The <code>complete()</code> callback must run AFTER the JSON files are written, or it doesn't affect the generated output files at all.</p>
<p>Either way, the filtering I was doing wasn't making it into the JSON file.</p>
<h2>The Breakthrough</h2>
<p>Then it clicked. Velite generates the JSON, but I don't have to use all of it. My app imports posts from <code>.velite/posts.json</code> in <code>src/lib/posts.ts</code>, where I sort them and prepare them for display. Why not filter there too?</p>
<p>This is runtime filtering - it happens right when the posts are accessed by my app, not during Velite's build process. That means <code>NODE_ENV</code> would definitely be set correctly because it's happening in the Next.js runtime.</p>
<p>I know build-time filtering is theoretically faster since filtering happens once during the build instead of on every request. But at this point, I was tired of debugging the build process and wanted something that actually worked. I can always optimize later if performance becomes an issue - right now, reliability matters more.</p>
<p>Here's what <code>src/lib/posts.ts</code> looked like before:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// src/lib/posts.ts (before)</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> posts </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '.velite/posts.json'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { cache } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'react'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> getPosts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> cache</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=></span></span>
<span class="line"><span style="color:#E1E4E8">  posts.</span><span style="color:#B392F0">sort</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">a</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">b</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(b.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">-</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(a.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">())</span></span>
<span class="line"><span style="color:#E1E4E8">)</span></span></code></pre>
<p>And here's what I changed it to:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// src/lib/posts.ts (after)</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> posts </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '.velite/posts.json'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { cache } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'react'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> filteredPosts</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583"> =></span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft) </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> posts</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> getPosts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> cache</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=></span></span>
<span class="line"><span style="color:#E1E4E8">  filteredPosts.</span><span style="color:#B392F0">sort</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">a</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">b</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(b.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">-</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(a.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">())</span></span>
<span class="line"><span style="color:#E1E4E8">)</span></span></code></pre>
<p>Now when <code>getPosts()</code> is called, it checks <code>NODE_ENV</code> and returns filtered posts if we're in production.</p>
<p>Before deploying, I wanted to verify locally:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">NODE_ENV</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">production</span><span style="color:#B392F0"> npm</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> build</span></span>
<span class="line"><span style="color:#E1E4E8">NODE_ENV</span><span style="color:#F97583">=</span><span style="color:#9ECBFF">production</span><span style="color:#B392F0"> npm</span><span style="color:#9ECBFF"> start</span></span></code></pre>
<p>Opened localhost:3000 in the browser. Only the published post showed up. The drafts were gone.</p>
<p>Deployed to Vercel. Checked the production URL directly (not from cache - used an incognito window to be sure). Only the published post shows up. The drafts are gone.</p>
<hr>
<p><strong>Quick note about preview deployments</strong>: The <code>NODE_ENV</code> check I used means preview deployments on Vercel will also hide draft posts, since <code>NODE_ENV</code> is 'production' in preview builds. If you want drafts to show in preview deployments, you'd need to check <code>VERCEL_ENV === 'production'</code> instead. But for my use case, hiding drafts in all non-dev environments works fine.</p>
<hr>
<h2>Cleaning Up</h2>
<p>I removed the filter logic from <code>velite.config.js</code> since it wasn't working anyway:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// velite.config.js (final)</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: [</span></span>
<span class="line"><span style="color:#E1E4E8">    {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: {</span></span>
<span class="line"><span style="color:#6A737D">        // ... schema definition</span></span>
<span class="line"><span style="color:#E1E4E8">      }</span></span>
<span class="line"><span style="color:#6A737D">      // No filter function - filtering happens in src/lib/posts.ts</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  ],</span></span>
<span class="line"><span style="color:#6A737D">  // ... rest of config</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>Committed with message: "Fix draft post filtering to work in production"</p>
<p>Ran <code>npm run lint</code> to make sure everything was clean. Deployed. Verified.</p>
<p>Now my drafts stay drafts, and published posts are the only ones visible in production.</p>
<p>I spent quite a while trying to make build-time filtering work in Velite before realizing runtime filtering in my app was actually the right approach.</p>]]></content:encoded>
            <category>velite</category>
            <category>nextjs</category>
        </item>
        <item>
            <title><![CDATA[Adding Draft Posts to Velite: Why 'draft: true' Isn't Enough]]></title>
            <link>https://lukemanning.ie/blog/adding-draft-posts-to-velite</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/adding-draft-posts-to-velite</guid>
            <pubDate>Mon, 05 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<p>I previously wrote about my multi-agent blog post generation system that takes my raw conversations and notes and generates structured posts from them: <a href="/blog/building-multi-agent-blog-review-system">I Built a Multi-Agent System to Review My Blog Posts</a>.</p>
<p>These posts are often half-finished thoughts and content that I didn't want to be published yet. So to avoid this happening, but to still give me the ability to create a base post to work from, I thought I'd enable drafts that would be hidden until I was ready to publish.</p>
<p>"Just add draft: true to frontmatter," I thought. Every other static site generator does this, right?</p>
<p>Spoiler: Velite doesn't work that way.</p>
<p>Here's what I tried first:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">---</span></span>
<span class="line"><span style="color:#85E89D">title</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"Work in Progress"</span></span>
<span class="line"><span style="color:#85E89D">date</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2025-01-03</span></span>
<span class="line"><span style="color:#85E89D">draft</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#E1E4E8">---</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">Still writing this...</span></span></code></pre>
<p>Except... it doesn't work that way.</p>
<h2>The Question</h2>
<p>"Does Velite automatically consider all frontmatter (those YAML settings at the top of my markdown files between the <code>---</code> markers), or do we need to handle each value separately?"</p>
<p>I assumed Velite would auto-pick up any field I put in frontmatter. Add <code>draft: true</code>, filter posts by <code>post.draft</code>, done.</p>
<p>That's not how Velite works.</p>
<h2>What I'm Starting With</h2>
<p>Before I get into the solution, here's what my setup looked like:</p>
<p><strong>My existing Velite schema</strong> (<code>/velite.config.js</code>):</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeShiki </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@shikijs/rehype'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineConfig, s } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: s</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">          title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          lastModified: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          cover: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          video: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          metadata: s.</span><span style="color:#B392F0">metadata</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          excerpt: s.</span><span style="color:#B392F0">excerpt</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          content: s.</span><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">()</span></span>
<span class="line"><span style="color:#E1E4E8">        })</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">transform</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">data</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> ({ </span><span style="color:#F97583">...</span><span style="color:#E1E4E8">data, permalink: </span><span style="color:#9ECBFF">`/blog/${</span><span style="color:#E1E4E8">data</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">slug</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8"> }))</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">  markdown: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rehypePlugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">      [rehypeShiki, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">    ]</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p><strong>Prerequisites</strong>: You should have:</p>
<ul>
<li>Velite 0.3.0+ installed</li>
<li>A working posts collection</li>
<li>Frontmatter with title, slug, date fields</li>
</ul>
<p>If your setup looks different, the draft field addition should still work. You'll just need to adapt the schema structure.</p>
<h2>How Velite Actually Works</h2>
<p>I'm using Velite 0.3.0 with Next.js 16.1.1, and this is where I learned something important.</p>
<p>Velite is <strong>schema-first</strong>. Every field must be explicitly defined in your schema in <code>/velite.config.js</code>.</p>
<p>From <a href="https://velite.js.org/guide/schema">Velite's official docs</a>:</p>
<blockquote>
<p>The schema property defines the structure and types of the data within a collection. You use Velite's schema system to specify fields, their types, and whether they are optional.</p>
</blockquote>
<p>No schema field? No access to that data. Period.</p>
<p>This means:</p>
<ol>
<li><strong>Schema defines fields</strong> - What fields exist on your posts</li>
<li><strong>Frontmatter provides values</strong> - The actual data for those fields</li>
<li><strong>Velite validates</strong> - Ensures frontmatter matches schema at build time</li>
</ol>
<p>If <code>draft</code> isn't in your schema, Velite ignores it in frontmatter.</p>
<h2>What I Actually Did (And Got Wrong First)</h2>
<p>Adding draft functionality requires <strong>two changes</strong>. I thought I could skip the first one. I was wrong.</p>
<h3>1. Add the Field to Your Schema</h3>
<p>I figured Velite would just... pick up the field. Add <code>draft: true</code> to frontmatter, access <code>post.draft</code> in my code, done.</p>
<p>Nope.</p>
<p>Velite ignored my <code>draft</code> field entirely. No error, no warning, nothing. The post compiled, the field was just... gone.</p>
<p>Here's what I added to my config:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineCollection, defineConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { MDXPlugin } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite-remark'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeSlug </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'rehype-slug'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeAutolinkHeadings </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'rehype-autolink-headings'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> posts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> defineCollection</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  schema: s.</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">    title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">    slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">    date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">    draft: s.</span><span style="color:#B392F0">boolean</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">default</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">), </span><span style="color:#6A737D">// ← Add this line</span></span>
<span class="line"><span style="color:#6A737D">    // ... other existing fields like description, tags, etc.</span></span>
<span class="line"><span style="color:#E1E4E8">  }),</span></span>
<span class="line"><span style="color:#E1E4E8">  mdx: </span><span style="color:#B392F0">MDXPlugin</span><span style="color:#E1E4E8">({ rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings] })</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: { posts }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>In my case, I had MDX plugins set up for syntax highlighting. If your setup is simpler, the schema addition looks like this:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> posts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> defineCollection</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  schema: s.</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">    title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">    slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">    date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">    draft: s.</span><span style="color:#B392F0">boolean</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">default</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">  })</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({ collections: { posts } })</span></span></code></pre>
<h3>Understanding the Schema Builder</h3>
<p>The <code>s</code> object you see in the code examples is the schema builder. It's provided by <code>defineCollection</code> and gives you type-safe field definitions:</p>
<ul>
<li><code>s.string()</code> - text fields</li>
<li><code>s.boolean()</code> - true/false values</li>
<li><code>s.isodate()</code> - date fields (validated as ISO format)</li>
<li><code>s.slug()</code> - URL-friendly slugs</li>
<li><code>.optional()</code> - field doesn't have to exist</li>
<li><code>.default(false)</code> - if missing, use this value</li>
<li><code>.max(99)</code> - string length limit</li>
</ul>
<h3>Why I Set It Up This Way</h3>
<p>I chose <code>.optional().default(false)</code> and <code>.boolean()</code> for specific reasons:</p>
<ul>
<li><code>.optional()</code> — not every post needs a draft flag, so we don't require it</li>
<li><code>.default(false)</code> — posts are published by default (safer than assuming draft)</li>
<li><code>.boolean()</code> — catches typos like <code>draft: yes</code> instead of <code>true</code>, failing at build time</li>
</ul>
<h3>Don't Forget Cache Clearing</h3>
<p>I saved the config and restarted the dev server.</p>
<p>Still no <code>draft</code> field in my posts. I checked <code>.velite/posts.json</code>. Nothing.</p>
<p>Hmm. Maybe I typoed something in the schema? No, looks fine. Maybe the MDX plugins are interfering? I tried simplifying the config to just the schema. Still nothing.</p>
<p>At this point I remembered: Velite caches everything. Schema changes mean the cached data is invalid.</p>
<p>I ran <code>npm run clean:dev</code> (which removes both <code>.next</code> and <code>.velite</code> folders) and restarted the dev server.</p>
<p>This time, <code>.velite/posts.json</code> had the <code>draft</code> field for every post:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  "title"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"Work in Progress"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "slug"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"work-in-progress"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "date"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"2025-01-03T00:00:00.000Z"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "draft"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#6A737D">  // ... other fields</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Posts without <code>draft: true</code> in frontmatter had <code>"draft": false</code> (from the <code>.default(false)</code>). I tested a typo (<code>darft: true</code>) which resulted in the field being ignored entirely, defaulting to <code>false</code>. Nice.</p>
<p>Lesson learned: Schema changes in Velite require clearing the cache. It's annoying, but it prevents stale cached data from causing weird bugs.</p>
<h3>2. Add Environment-Aware Filtering</h3>
<p>But having the field isn't enough. You also need to <strong>filter out drafts</strong> based on environment.</p>
<p>I needed:</p>
<ul>
<li><strong>Local dev</strong>: Show all posts (including drafts)</li>
<li><strong>Vercel preview</strong>: Show all posts (for review)</li>
<li><strong>Production</strong>: Hide drafts</li>
</ul>
<p>I considered using Vercel's <code>VERCEL_ENV</code> at first. That's the "proper" Vercel way. But I was already using <code>NODE_ENV</code> elsewhere in my config, so I stuck with that.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span></code></pre>
<p>This works because:</p>
<ul>
<li>Local dev means <code>NODE_ENV=development</code>, so show drafts</li>
<li>Production build means <code>NODE_ENV=production</code>, so hide drafts</li>
</ul>
<p>For the filtering, I added it in <code>/src/lib/posts.ts</code> rather than the Velite config. Here's what that looks like:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { cache } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'react'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { posts } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '#site/content'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// Filter posts at the application level</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> filteredPosts</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583"> =></span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft) </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> posts</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// All exported functions use filteredPosts instead of raw posts</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> getPosts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> cache</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=></span></span>
<span class="line"><span style="color:#E1E4E8">  filteredPosts.</span><span style="color:#B392F0">sort</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">a</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">b</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(b.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">-</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(a.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">())</span></span>
<span class="line"><span style="color:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> getPostBySlug</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> cache</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">slug</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span></span>
<span class="line"><span style="color:#E1E4E8">  filteredPosts.</span><span style="color:#B392F0">find</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">p</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> p.slug </span><span style="color:#F97583">===</span><span style="color:#E1E4E8"> slug)</span></span>
<span class="line"><span style="color:#E1E4E8">)</span></span></code></pre>
<p>I filtered at the application level because it keeps the Velite config simple. I just need the schema field. The filtering logic lives with the rest of the post retrieval code, which felt cleaner to me.</p>
<h2>How Draft Filtering Works Across Environments</h2>
<p>Here's how it behaves:</p>
<ul>
<li><strong>Local dev</strong> (<code>NODE_ENV=development</code>) means show drafts</li>
<li><strong>Vercel preview</strong> (<code>NODE_ENV=production</code>) means hide drafts</li>
<li><strong>Production</strong> (<code>NODE_ENV=production</code>) means hide drafts</li>
</ul>
<p>The simple check <code>process.env.NODE_ENV === 'production'</code> handles all cases:</p>
<ul>
<li>Local dev gives <code>isProduction = false</code>, so show drafts</li>
<li>Production build gives <code>isProduction = true</code>, so hide drafts</li>
</ul>
<h2>Why I'm Okay With This Now</h2>
<p>Honestly, defining every field in the schema felt annoying at first. More boilerplate, more config.</p>
<p>But then I thought about what would happen without it:</p>
<p><strong>Typo <code>draft: true</code> as <code>darft: true</code>?</strong>
Build succeeds. Draft goes live. You don't notice until someone emails you.</p>
<p><strong>Inconsistent values</strong> like <code>draft: yes</code> or <code>draft: 1</code>?
No type checking. Your filter logic breaks silently.</p>
<p><strong>Rename <code>draft</code> to <code>published</code> in frontmatter</strong> but forget to update code?
No build error. Filtering stops working.</p>
<p>Schema-first means:</p>
<ul>
<li>Typos in field names → build fails immediately</li>
<li>Wrong types → Velite catches it at build time</li>
<li>Refactoring → TypeScript shows all usage</li>
</ul>
<p>The 30 seconds to add a schema field saves hours of "why isn't this working?" debugging.</p>
<h2>What About Vercel Deployments?</h2>
<p>I initially thought about using <code>VERCEL_ENV</code> since I'm deploying to Vercel. That's what their docs recommend for detecting preview vs production deployments. But <code>NODE_ENV</code> works fine for my use case:</p>
<ul>
<li>I don't need separate preview deployments with different draft visibility</li>
<li><code>NODE_ENV</code> is already set by Vercel in production builds</li>
<li>I was already using <code>NODE_ENV</code> elsewhere in my config</li>
</ul>
<p>If you need draft posts visible in preview deployments but hidden in production, <code>VERCEL_ENV</code> is the way to go:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">VERCEL_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// Then use the same filtering logic</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> filteredPosts</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583"> =></span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft) </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> posts</span></span></code></pre>
<p>But for me, <code>NODE_ENV</code> is simpler and works just as well.</p>
<h2>My Actual Testing Experience</h2>
<p>I created a test draft post to verify everything worked:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">---</span></span>
<span class="line"><span style="color:#85E89D">title</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"Test Draft"</span></span>
<span class="line"><span style="color:#85E89D">slug</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">test-draft</span></span>
<span class="line"><span style="color:#85E89D">date</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2025-01-03</span></span>
<span class="line"><span style="color:#85E89D">draft</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#E1E4E8">---</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">This should only appear in dev/preview.</span></span></code></pre>
<p>First, I ran <code>npm run dev</code>. The draft showed up in my post list. I clicked through to <code>/blog/test-draft</code> and saw the content. Good.</p>
<p>Then I ran <code>npm run build</code> followed by <code>npm run start</code> to test production behavior.</p>
<p>Expected: Post appears in list, I can read it.
Actual: Post is gone from the list. <code>/blog/test-draft</code> returns 404.</p>
<p>Exactly what I wanted. But I wanted to double-check the build output, so I opened <code>.velite/posts.json</code>. Wait. The <code>test-draft</code> entry was still there.</p>
<p>That's weird. I thought it would be filtered out by Velite?</p>
<p>Oh right. I'm filtering at the application level in <code>posts.ts</code>, not in the Velite config. Velite builds all posts, including drafts, into <code>.velite/posts.json</code>. Then my application code filters them out when retrieving posts.</p>
<p>I verified this by checking the RSS feed. In the Velite config's <code>complete</code> callback, I filter out drafts before adding to the feed. So draft posts don't show up in the RSS. But <code>.velite/posts.json</code> still contains everything.</p>
<p>This is actually fine. The filtering happens where I retrieve posts, so draft posts never reach my components. The build output includes everything, but my app only serves what's appropriate for the environment.</p>
<h2>What I Learned</h2>
<p><strong>Velite doesn't auto-pick up frontmatter fields.</strong> You must define them in your schema first.</p>
<p>This felt like a constraint at first. Now I see it as a safety net:</p>
<ul>
<li>Build-time validation catches mistakes</li>
<li>TypeScript knows what fields exist</li>
<li>Impossible states become... impossible</li>
</ul>
<p>The schema isn't boilerplate. It's the contract between your content and your code.</p>
<p>That <code>darft: true</code> typo? Can't happen. Velite would ignore the field entirely, and TypeScript would show an error when you try to access <code>post.darft</code>.</p>
<p>Schema-first design saves future-you from current-you's mistakes.</p>]]></description>
            <content:encoded><![CDATA[<p>I previously wrote about my multi-agent blog post generation system that takes my raw conversations and notes and generates structured posts from them: <a href="/blog/building-multi-agent-blog-review-system">I Built a Multi-Agent System to Review My Blog Posts</a>.</p>
<p>These posts are often half-finished thoughts and content that I didn't want to be published yet. So to avoid this happening, but to still give me the ability to create a base post to work from, I thought I'd enable drafts that would be hidden until I was ready to publish.</p>
<p>"Just add draft: true to frontmatter," I thought. Every other static site generator does this, right?</p>
<p>Spoiler: Velite doesn't work that way.</p>
<p>Here's what I tried first:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">---</span></span>
<span class="line"><span style="color:#85E89D">title</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"Work in Progress"</span></span>
<span class="line"><span style="color:#85E89D">date</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2025-01-03</span></span>
<span class="line"><span style="color:#85E89D">draft</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#E1E4E8">---</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">Still writing this...</span></span></code></pre>
<p>Except... it doesn't work that way.</p>
<h2>The Question</h2>
<p>"Does Velite automatically consider all frontmatter (those YAML settings at the top of my markdown files between the <code>---</code> markers), or do we need to handle each value separately?"</p>
<p>I assumed Velite would auto-pick up any field I put in frontmatter. Add <code>draft: true</code>, filter posts by <code>post.draft</code>, done.</p>
<p>That's not how Velite works.</p>
<h2>What I'm Starting With</h2>
<p>Before I get into the solution, here's what my setup looked like:</p>
<p><strong>My existing Velite schema</strong> (<code>/velite.config.js</code>):</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeShiki </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@shikijs/rehype'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineConfig, s } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: s</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">          title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          lastModified: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          cover: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          video: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          metadata: s.</span><span style="color:#B392F0">metadata</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          excerpt: s.</span><span style="color:#B392F0">excerpt</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          content: s.</span><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">()</span></span>
<span class="line"><span style="color:#E1E4E8">        })</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">transform</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">data</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> ({ </span><span style="color:#F97583">...</span><span style="color:#E1E4E8">data, permalink: </span><span style="color:#9ECBFF">`/blog/${</span><span style="color:#E1E4E8">data</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">slug</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8"> }))</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">  markdown: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rehypePlugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">      [rehypeShiki, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">    ]</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p><strong>Prerequisites</strong>: You should have:</p>
<ul>
<li>Velite 0.3.0+ installed</li>
<li>A working posts collection</li>
<li>Frontmatter with title, slug, date fields</li>
</ul>
<p>If your setup looks different, the draft field addition should still work. You'll just need to adapt the schema structure.</p>
<h2>How Velite Actually Works</h2>
<p>I'm using Velite 0.3.0 with Next.js 16.1.1, and this is where I learned something important.</p>
<p>Velite is <strong>schema-first</strong>. Every field must be explicitly defined in your schema in <code>/velite.config.js</code>.</p>
<p>From <a href="https://velite.js.org/guide/schema">Velite's official docs</a>:</p>
<blockquote>
<p>The schema property defines the structure and types of the data within a collection. You use Velite's schema system to specify fields, their types, and whether they are optional.</p>
</blockquote>
<p>No schema field? No access to that data. Period.</p>
<p>This means:</p>
<ol>
<li><strong>Schema defines fields</strong> - What fields exist on your posts</li>
<li><strong>Frontmatter provides values</strong> - The actual data for those fields</li>
<li><strong>Velite validates</strong> - Ensures frontmatter matches schema at build time</li>
</ol>
<p>If <code>draft</code> isn't in your schema, Velite ignores it in frontmatter.</p>
<h2>What I Actually Did (And Got Wrong First)</h2>
<p>Adding draft functionality requires <strong>two changes</strong>. I thought I could skip the first one. I was wrong.</p>
<h3>1. Add the Field to Your Schema</h3>
<p>I figured Velite would just... pick up the field. Add <code>draft: true</code> to frontmatter, access <code>post.draft</code> in my code, done.</p>
<p>Nope.</p>
<p>Velite ignored my <code>draft</code> field entirely. No error, no warning, nothing. The post compiled, the field was just... gone.</p>
<p>Here's what I added to my config:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineCollection, defineConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { MDXPlugin } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite-remark'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeSlug </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'rehype-slug'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeAutolinkHeadings </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'rehype-autolink-headings'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> posts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> defineCollection</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  schema: s.</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">    title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">    slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">    date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">    draft: s.</span><span style="color:#B392F0">boolean</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">default</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">), </span><span style="color:#6A737D">// ← Add this line</span></span>
<span class="line"><span style="color:#6A737D">    // ... other existing fields like description, tags, etc.</span></span>
<span class="line"><span style="color:#E1E4E8">  }),</span></span>
<span class="line"><span style="color:#E1E4E8">  mdx: </span><span style="color:#B392F0">MDXPlugin</span><span style="color:#E1E4E8">({ rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings] })</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: { posts }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>In my case, I had MDX plugins set up for syntax highlighting. If your setup is simpler, the schema addition looks like this:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> posts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> defineCollection</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">  schema: s.</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">    title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">    slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">    date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">    draft: s.</span><span style="color:#B392F0">boolean</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">default</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">false</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">  })</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({ collections: { posts } })</span></span></code></pre>
<h3>Understanding the Schema Builder</h3>
<p>The <code>s</code> object you see in the code examples is the schema builder. It's provided by <code>defineCollection</code> and gives you type-safe field definitions:</p>
<ul>
<li><code>s.string()</code> - text fields</li>
<li><code>s.boolean()</code> - true/false values</li>
<li><code>s.isodate()</code> - date fields (validated as ISO format)</li>
<li><code>s.slug()</code> - URL-friendly slugs</li>
<li><code>.optional()</code> - field doesn't have to exist</li>
<li><code>.default(false)</code> - if missing, use this value</li>
<li><code>.max(99)</code> - string length limit</li>
</ul>
<h3>Why I Set It Up This Way</h3>
<p>I chose <code>.optional().default(false)</code> and <code>.boolean()</code> for specific reasons:</p>
<ul>
<li><code>.optional()</code> — not every post needs a draft flag, so we don't require it</li>
<li><code>.default(false)</code> — posts are published by default (safer than assuming draft)</li>
<li><code>.boolean()</code> — catches typos like <code>draft: yes</code> instead of <code>true</code>, failing at build time</li>
</ul>
<h3>Don't Forget Cache Clearing</h3>
<p>I saved the config and restarted the dev server.</p>
<p>Still no <code>draft</code> field in my posts. I checked <code>.velite/posts.json</code>. Nothing.</p>
<p>Hmm. Maybe I typoed something in the schema? No, looks fine. Maybe the MDX plugins are interfering? I tried simplifying the config to just the schema. Still nothing.</p>
<p>At this point I remembered: Velite caches everything. Schema changes mean the cached data is invalid.</p>
<p>I ran <code>npm run clean:dev</code> (which removes both <code>.next</code> and <code>.velite</code> folders) and restarted the dev server.</p>
<p>This time, <code>.velite/posts.json</code> had the <code>draft</code> field for every post:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  "title"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"Work in Progress"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "slug"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"work-in-progress"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "date"</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"2025-01-03T00:00:00.000Z"</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#79B8FF">  "draft"</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#6A737D">  // ... other fields</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Posts without <code>draft: true</code> in frontmatter had <code>"draft": false</code> (from the <code>.default(false)</code>). I tested a typo (<code>darft: true</code>) which resulted in the field being ignored entirely, defaulting to <code>false</code>. Nice.</p>
<p>Lesson learned: Schema changes in Velite require clearing the cache. It's annoying, but it prevents stale cached data from causing weird bugs.</p>
<h3>2. Add Environment-Aware Filtering</h3>
<p>But having the field isn't enough. You also need to <strong>filter out drafts</strong> based on environment.</p>
<p>I needed:</p>
<ul>
<li><strong>Local dev</strong>: Show all posts (including drafts)</li>
<li><strong>Vercel preview</strong>: Show all posts (for review)</li>
<li><strong>Production</strong>: Hide drafts</li>
</ul>
<p>I considered using Vercel's <code>VERCEL_ENV</code> at first. That's the "proper" Vercel way. But I was already using <code>NODE_ENV</code> elsewhere in my config, so I stuck with that.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span></code></pre>
<p>This works because:</p>
<ul>
<li>Local dev means <code>NODE_ENV=development</code>, so show drafts</li>
<li>Production build means <code>NODE_ENV=production</code>, so hide drafts</li>
</ul>
<p>For the filtering, I added it in <code>/src/lib/posts.ts</code> rather than the Velite config. Here's what that looks like:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { cache } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'react'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { posts } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '#site/content'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// Filter posts at the application level</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> filteredPosts</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583"> =></span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft) </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> posts</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// All exported functions use filteredPosts instead of raw posts</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> getPosts</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> cache</span><span style="color:#E1E4E8">(() </span><span style="color:#F97583">=></span></span>
<span class="line"><span style="color:#E1E4E8">  filteredPosts.</span><span style="color:#B392F0">sort</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">a</span><span style="color:#E1E4E8">, </span><span style="color:#FFAB70">b</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(b.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">() </span><span style="color:#F97583">-</span><span style="color:#F97583"> new</span><span style="color:#B392F0"> Date</span><span style="color:#E1E4E8">(a.date).</span><span style="color:#B392F0">getTime</span><span style="color:#E1E4E8">())</span></span>
<span class="line"><span style="color:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> const</span><span style="color:#79B8FF"> getPostBySlug</span><span style="color:#F97583"> =</span><span style="color:#B392F0"> cache</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">slug</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span></span>
<span class="line"><span style="color:#E1E4E8">  filteredPosts.</span><span style="color:#B392F0">find</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">p</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> p.slug </span><span style="color:#F97583">===</span><span style="color:#E1E4E8"> slug)</span></span>
<span class="line"><span style="color:#E1E4E8">)</span></span></code></pre>
<p>I filtered at the application level because it keeps the Velite config simple. I just need the schema field. The filtering logic lives with the rest of the post retrieval code, which felt cleaner to me.</p>
<h2>How Draft Filtering Works Across Environments</h2>
<p>Here's how it behaves:</p>
<ul>
<li><strong>Local dev</strong> (<code>NODE_ENV=development</code>) means show drafts</li>
<li><strong>Vercel preview</strong> (<code>NODE_ENV=production</code>) means hide drafts</li>
<li><strong>Production</strong> (<code>NODE_ENV=production</code>) means hide drafts</li>
</ul>
<p>The simple check <code>process.env.NODE_ENV === 'production'</code> handles all cases:</p>
<ul>
<li>Local dev gives <code>isProduction = false</code>, so show drafts</li>
<li>Production build gives <code>isProduction = true</code>, so hide drafts</li>
</ul>
<h2>Why I'm Okay With This Now</h2>
<p>Honestly, defining every field in the schema felt annoying at first. More boilerplate, more config.</p>
<p>But then I thought about what would happen without it:</p>
<p><strong>Typo <code>draft: true</code> as <code>darft: true</code>?</strong>
Build succeeds. Draft goes live. You don't notice until someone emails you.</p>
<p><strong>Inconsistent values</strong> like <code>draft: yes</code> or <code>draft: 1</code>?
No type checking. Your filter logic breaks silently.</p>
<p><strong>Rename <code>draft</code> to <code>published</code> in frontmatter</strong> but forget to update code?
No build error. Filtering stops working.</p>
<p>Schema-first means:</p>
<ul>
<li>Typos in field names → build fails immediately</li>
<li>Wrong types → Velite catches it at build time</li>
<li>Refactoring → TypeScript shows all usage</li>
</ul>
<p>The 30 seconds to add a schema field saves hours of "why isn't this working?" debugging.</p>
<h2>What About Vercel Deployments?</h2>
<p>I initially thought about using <code>VERCEL_ENV</code> since I'm deploying to Vercel. That's what their docs recommend for detecting preview vs production deployments. But <code>NODE_ENV</code> works fine for my use case:</p>
<ul>
<li>I don't need separate preview deployments with different draft visibility</li>
<li><code>NODE_ENV</code> is already set by Vercel in production builds</li>
<li>I was already using <code>NODE_ENV</code> elsewhere in my config</li>
</ul>
<p>If you need draft posts visible in preview deployments but hidden in production, <code>VERCEL_ENV</code> is the way to go:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isProduction</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">VERCEL_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// Then use the same filtering logic</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> filteredPosts</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> isProduction </span><span style="color:#F97583">?</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">filter</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">post</span><span style="color:#F97583"> =></span><span style="color:#F97583"> !</span><span style="color:#E1E4E8">post.draft) </span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> posts</span></span></code></pre>
<p>But for me, <code>NODE_ENV</code> is simpler and works just as well.</p>
<h2>My Actual Testing Experience</h2>
<p>I created a test draft post to verify everything worked:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">---</span></span>
<span class="line"><span style="color:#85E89D">title</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">"Test Draft"</span></span>
<span class="line"><span style="color:#85E89D">slug</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">test-draft</span></span>
<span class="line"><span style="color:#85E89D">date</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2025-01-03</span></span>
<span class="line"><span style="color:#85E89D">draft</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">true</span></span>
<span class="line"><span style="color:#E1E4E8">---</span></span>
<span class="line"></span>
<span class="line"><span style="color:#E1E4E8">This should only appear in dev/preview.</span></span></code></pre>
<p>First, I ran <code>npm run dev</code>. The draft showed up in my post list. I clicked through to <code>/blog/test-draft</code> and saw the content. Good.</p>
<p>Then I ran <code>npm run build</code> followed by <code>npm run start</code> to test production behavior.</p>
<p>Expected: Post appears in list, I can read it.
Actual: Post is gone from the list. <code>/blog/test-draft</code> returns 404.</p>
<p>Exactly what I wanted. But I wanted to double-check the build output, so I opened <code>.velite/posts.json</code>. Wait. The <code>test-draft</code> entry was still there.</p>
<p>That's weird. I thought it would be filtered out by Velite?</p>
<p>Oh right. I'm filtering at the application level in <code>posts.ts</code>, not in the Velite config. Velite builds all posts, including drafts, into <code>.velite/posts.json</code>. Then my application code filters them out when retrieving posts.</p>
<p>I verified this by checking the RSS feed. In the Velite config's <code>complete</code> callback, I filter out drafts before adding to the feed. So draft posts don't show up in the RSS. But <code>.velite/posts.json</code> still contains everything.</p>
<p>This is actually fine. The filtering happens where I retrieve posts, so draft posts never reach my components. The build output includes everything, but my app only serves what's appropriate for the environment.</p>
<h2>What I Learned</h2>
<p><strong>Velite doesn't auto-pick up frontmatter fields.</strong> You must define them in your schema first.</p>
<p>This felt like a constraint at first. Now I see it as a safety net:</p>
<ul>
<li>Build-time validation catches mistakes</li>
<li>TypeScript knows what fields exist</li>
<li>Impossible states become... impossible</li>
</ul>
<p>The schema isn't boilerplate. It's the contract between your content and your code.</p>
<p>That <code>darft: true</code> typo? Can't happen. Velite would ignore the field entirely, and TypeScript would show an error when you try to access <code>post.darft</code>.</p>
<p>Schema-first design saves future-you from current-you's mistakes.</p>]]></content:encoded>
            <category>velite</category>
            <category>nextjs</category>
        </item>
        <item>
            <title><![CDATA[Adding Shiki for a pop of colour in my code blocks was a struggle]]></title>
            <link>https://lukemanning.ie/blog/adding-syntax-highlighting-shiki</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/adding-syntax-highlighting-shiki</guid>
            <pubDate>Sun, 23 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[<h2>Adding Syntax Highlighting to Velite with Shiki</h2>
<p>I already had Velite working with Next.js (<a href="/blog/setting-up-velite-nextjs-revised">covered in my previous post</a>), with posts in <code>/content/posts/*.md</code> and the dev server running on port 3000.</p>
<p>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.</p>
<p><strong>For reference, my versions:</strong></p>
<ul>
<li>Next.js ^16.1.1</li>
<li>Velite ^0.3.0</li>
<li>@shikijs/rehype ^3.20.0</li>
<li>shiki ^3.15.0</li>
<li>Node 20.18.0</li>
</ul>
<p>Things might differ with other versions, especially with Next.js 16.</p>
<p>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.</p>
<p>Turns out "obvious" doesn't mean "straightforward." Here's what I ran into.</p>
<h2>Setting Up Shiki</h2>
<p>I needed two packages: Shiki itself, and the rehype plugin to hook it into Velite's markdown processing:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> @shikijs/rehype</span><span style="color:#9ECBFF"> shiki</span></span></code></pre>
<p>In my case, this grabbed <strong>shiki ^3.15.0</strong> and <strong>@shikijs/rehype ^3.20.0</strong>.</p>
<p>Now it's time to hook this up in the Velite config. This is where I hit my first snag.</p>
<h2>Problem #1: TypeScript Syntax in a JavaScript File</h2>
<p>My first attempt:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeShiki </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@shikijs/rehype'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8">  markdown: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rehypePlugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">      [rehypeShiki </span><span style="color:#F97583">as</span><span style="color:#79B8FF"> any</span><span style="color:#E1E4E8">, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">    ]</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>Error:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>Expected "]" but found "as"</span></span></code></pre>
<p>The error was straightforward - my config file was <code>velite.config.js</code> (JavaScript), but I was using TypeScript syntax (<code>as any</code>). Just remove the type cast:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#B392F0">  rehypePlugins</span><span style="color:#E1E4E8">: [</span></span>
<span class="line"><span style="color:#E1E4E8">    [rehypeShiki, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">  ]</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<h2>Problem #2: Config Structure</h2>
<p>I kept getting syntax errors because I couldn't get the braces right. The <code>markdown</code> key needs to be at the same level as <code>collections</code>, not inside it:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// This doesn't work - markdown inside collections</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8">    markdown: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// What actually works - markdown as sibling to collections</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">  markdown: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rehypePlugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">      [rehypeShiki, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">    ]</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>Count your braces carefully. I had to trace through mine multiple times.</p>
<p>One more thing: after changing <code>velite.config.js</code>, I had to clear the cache or nothing would work:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> clean:dev</span></span></code></pre>
<p>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.</p>
<h2>Problem #3: CSS Conflicts</h2>
<p>Once Shiki was working, I had CSS fighting with it. My existing styles in <code>/src/app/globals.css</code> were overriding Shiki's colors.</p>
<p>I restarted the dev server, refreshed the page, and... nothing had changed. Code blocks were still just monospace text in my custom colors.</p>
<p>Wait, what? Shiki was supposed to be handling colors now.</p>
<p>I opened DevTools and saw inline styles like <code>style="color: #ff79c6"</code> on each code token. I realized Shiki generates these because it needs exact color control for syntax highlighting. My CSS's <code>text-color</code> property was more specific and overriding them.</p>
<p>When I disabled <code>text-color</code> in DevTools, Shiki colors instantly appeared. So I just needed to remove that property from my CSS and let Shiki's colors come through.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">/* My initial approach - this was fighting with Shiki */</span></span>
<span class="line"><span style="color:#85E89D">code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-code-carbon</span><span style="color:#79B8FF"> text-neural-silver</span><span style="color:#79B8FF"> px-</span><span style="color:#E1E4E8">1.5 </span><span style="color:#79B8FF">py-</span><span style="color:#E1E4E8">0.5 </span><span style="color:#79B8FF">rounded</span><span style="color:#79B8FF"> font-mono</span><span style="color:#79B8FF"> text-sm</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-code-carbon</span><span style="color:#E1E4E8">/35 </span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">4 </span><span style="color:#79B8FF">rounded-lg</span><span style="color:#79B8FF"> overflow-x-auto</span><span style="color:#79B8FF"> font-mono</span><span style="color:#79B8FF"> text-sm</span><span style="color:#79B8FF"> m-</span><span style="color:#E1E4E8">4;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-transparent</span><span style="color:#79B8FF"> p-</span><span style="color:#E1E4E8">0 </span><span style="color:#79B8FF">text-code-carbon</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>The problem was that my colors were overriding Shiki's syntax highlighting.</p>
<p>Here's what's happening: Shiki generates inline styles on each code token (like <code>style="color: #ff79c6"</code> 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:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">/* Inline code */</span></span>
<span class="line"><span style="color:#85E89D">article</span><span style="color:#B392F0"> :not</span><span style="color:#E1E4E8">(</span><span style="color:#85E89D">pre</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">></span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">bg-terminal-primary</span><span style="color:#E1E4E8">/15 !</span><span style="color:#79B8FF">text-terminal-primary</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">px-</span><span style="color:#E1E4E8">1.5 !</span><span style="color:#79B8FF">py-</span><span style="color:#E1E4E8">0.5 </span><span style="color:#79B8FF">rounded-sm</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">font-mono</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">text-sm</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">border-none</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">/* Code block container - let Shiki handle colors */</span></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-terminal-dim</span><span style="color:#E1E4E8">/10 </span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">4 </span><span style="color:#79B8FF">rounded-lg</span><span style="color:#79B8FF"> border</span><span style="color:#79B8FF"> border-terminal-dim</span><span style="color:#E1E4E8">/15 </span><span style="color:#79B8FF">font-mono</span><span style="color:#79B8FF"> text-sm</span><span style="color:#79B8FF"> overflow-x-auto</span><span style="color:#79B8FF"> my-</span><span style="color:#E1E4E8">6;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">/* Reset for code inside pre */</span></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">bg-transparent</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">0 !</span><span style="color:#79B8FF">border-none</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">rounded-none</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">text-inherit</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<h3>Verifying It Works</h3>
<p>When I checked a blog post after getting the CSS right, I finally saw:</p>
<ul>
<li>Inline code (like <code>const foo = 'bar'</code> in a paragraph) had my custom background color</li>
<li>Code blocks had colorized syntax highlighting:
<ul>
<li>Keywords (like <code>const</code>, <code>function</code>, <code>import</code>) in purple/pink</li>
<li>Strings (like <code>'github-dark'</code>) in green</li>
<li>Comments in muted gray</li>
<li>Variables in light blue/white</li>
</ul>
</li>
<li>In the example <code>const foo = 'bar'</code>, I saw <code>const</code> in one color and <code>'bar'</code> in a different color</li>
</ul>
<p>When my code blocks were still just monochrome text at first, I checked <a href="https://shiki.style/themes#github-dark">Shiki's theme preview</a> to see what github-dark was supposed to look like. That's when I realized the Velite cache hadn't cleared properly. Ran <code>npm run clean:dev</code> one more time, restarted the dev server, and it worked.</p>
<h2>Untagged Code Blocks Don't Get Highlighting</h2>
<p>I noticed code blocks without a language specified don't get Shiki styling:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">```</span></span>
<span class="line"><span style="color:#E1E4E8">This has no syntax highlighting</span></span>
<span class="line"><span style="color:#E1E4E8">```</span></span></code></pre>
<p>Two options:</p>
<ol>
<li>Always specify a language - use <code>text</code> or <code>plaintext</code> for non-code</li>
<li>Add fallback text color in CSS:</li>
</ol>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">pre</span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-transparent</span><span style="color:#79B8FF"> p-</span><span style="color:#E1E4E8">0 </span><span style="color:#79B8FF">text-inherit</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>I went with always specifying languages since it's more explicit.</p>
<h2>What Actually Worked</h2>
<p>After all that debugging, here's what finally worked. Two files needed changes:</p>
<p><strong>velite.config.js</strong> (mine's in the project root):</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineConfig, s } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeShiki </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@shikijs/rehype'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: s</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">          title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          cover: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          video: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          metadata: s.</span><span style="color:#B392F0">metadata</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          excerpt: s.</span><span style="color:#B392F0">excerpt</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          content: s.</span><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">()</span></span>
<span class="line"><span style="color:#E1E4E8">        })</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">transform</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">data</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> ({ </span><span style="color:#F97583">...</span><span style="color:#E1E4E8">data, permalink: </span><span style="color:#9ECBFF">`/blog/${</span><span style="color:#E1E4E8">data</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">slug</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8"> }))</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">  markdown: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rehypePlugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">      [rehypeShiki, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">    ]</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p><strong>globals.css</strong> (at <code>/src/app/globals.css</code>):</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">/* Inline code */</span></span>
<span class="line"><span style="color:#85E89D">article</span><span style="color:#B392F0"> :not</span><span style="color:#E1E4E8">(</span><span style="color:#85E89D">pre</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">></span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">bg-terminal-primary</span><span style="color:#E1E4E8">/15 !</span><span style="color:#79B8FF">text-terminal-primary</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">px-</span><span style="color:#E1E4E8">1.5 !</span><span style="color:#79B8FF">py-</span><span style="color:#E1E4E8">0.5 </span><span style="color:#79B8FF">rounded-sm</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">font-mono</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">text-sm</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">border-none</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-terminal-dim</span><span style="color:#E1E4E8">/10 </span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">4 </span><span style="color:#79B8FF">rounded-lg</span><span style="color:#79B8FF"> border</span><span style="color:#79B8FF"> border-terminal-dim</span><span style="color:#E1E4E8">/15 </span><span style="color:#79B8FF">font-mono</span><span style="color:#79B8FF"> text-sm</span><span style="color:#79B8FF"> overflow-x-auto</span><span style="color:#79B8FF"> my-</span><span style="color:#E1E4E8">6;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">/* Reset prose code block styling - let Shiki handle it */</span></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">bg-transparent</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">0 !</span><span style="color:#79B8FF">border-none</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">rounded-none</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">text-inherit</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>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.</p>
<p>Other stuff that tripped me up:</p>
<ul>
<li>
<p>File extensions matter - <code>.js</code> files can't use TypeScript syntax. Took me a few minutes of staring at <code>Expected "]" but found "as"</code> before I remembered the config was JavaScript, not TypeScript.</p>
</li>
<li>
<p>Config structure is finicky - Velite has specific expectations about where keys go. Count your braces carefully.</p>
</li>
<li>
<p>Specify languages in markdown - <code>```javascript</code> gives proper highlighting, but <code>```</code> without a language just shows plain text.</p>
</li>
</ul>
<p>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.</p>]]></description>
            <content:encoded><![CDATA[<h2>Adding Syntax Highlighting to Velite with Shiki</h2>
<p>I already had Velite working with Next.js (<a href="/blog/setting-up-velite-nextjs-revised">covered in my previous post</a>), with posts in <code>/content/posts/*.md</code> and the dev server running on port 3000.</p>
<p>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.</p>
<p><strong>For reference, my versions:</strong></p>
<ul>
<li>Next.js ^16.1.1</li>
<li>Velite ^0.3.0</li>
<li>@shikijs/rehype ^3.20.0</li>
<li>shiki ^3.15.0</li>
<li>Node 20.18.0</li>
</ul>
<p>Things might differ with other versions, especially with Next.js 16.</p>
<p>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.</p>
<p>Turns out "obvious" doesn't mean "straightforward." Here's what I ran into.</p>
<h2>Setting Up Shiki</h2>
<p>I needed two packages: Shiki itself, and the rehype plugin to hook it into Velite's markdown processing:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> install</span><span style="color:#9ECBFF"> @shikijs/rehype</span><span style="color:#9ECBFF"> shiki</span></span></code></pre>
<p>In my case, this grabbed <strong>shiki ^3.15.0</strong> and <strong>@shikijs/rehype ^3.20.0</strong>.</p>
<p>Now it's time to hook this up in the Velite config. This is where I hit my first snag.</p>
<h2>Problem #1: TypeScript Syntax in a JavaScript File</h2>
<p>My first attempt:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeShiki </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@shikijs/rehype'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8">  markdown: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rehypePlugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">      [rehypeShiki </span><span style="color:#F97583">as</span><span style="color:#79B8FF"> any</span><span style="color:#E1E4E8">, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">    ]</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>Error:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>Expected "]" but found "as"</span></span></code></pre>
<p>The error was straightforward - my config file was <code>velite.config.js</code> (JavaScript), but I was using TypeScript syntax (<code>as any</code>). Just remove the type cast:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#B392F0">  rehypePlugins</span><span style="color:#E1E4E8">: [</span></span>
<span class="line"><span style="color:#E1E4E8">    [rehypeShiki, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">  ]</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<h2>Problem #2: Config Structure</h2>
<p>I kept getting syntax errors because I couldn't get the braces right. The <code>markdown</code> key needs to be at the same level as <code>collections</code>, not inside it:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">// This doesn't work - markdown inside collections</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#E1E4E8">    markdown: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">// What actually works - markdown as sibling to collections</span></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> }</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">  markdown: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rehypePlugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">      [rehypeShiki, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">    ]</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p>Count your braces carefully. I had to trace through mine multiple times.</p>
<p>One more thing: after changing <code>velite.config.js</code>, I had to clear the cache or nothing would work:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">npm</span><span style="color:#9ECBFF"> run</span><span style="color:#9ECBFF"> clean:dev</span></span></code></pre>
<p>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.</p>
<h2>Problem #3: CSS Conflicts</h2>
<p>Once Shiki was working, I had CSS fighting with it. My existing styles in <code>/src/app/globals.css</code> were overriding Shiki's colors.</p>
<p>I restarted the dev server, refreshed the page, and... nothing had changed. Code blocks were still just monospace text in my custom colors.</p>
<p>Wait, what? Shiki was supposed to be handling colors now.</p>
<p>I opened DevTools and saw inline styles like <code>style="color: #ff79c6"</code> on each code token. I realized Shiki generates these because it needs exact color control for syntax highlighting. My CSS's <code>text-color</code> property was more specific and overriding them.</p>
<p>When I disabled <code>text-color</code> in DevTools, Shiki colors instantly appeared. So I just needed to remove that property from my CSS and let Shiki's colors come through.</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">/* My initial approach - this was fighting with Shiki */</span></span>
<span class="line"><span style="color:#85E89D">code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-code-carbon</span><span style="color:#79B8FF"> text-neural-silver</span><span style="color:#79B8FF"> px-</span><span style="color:#E1E4E8">1.5 </span><span style="color:#79B8FF">py-</span><span style="color:#E1E4E8">0.5 </span><span style="color:#79B8FF">rounded</span><span style="color:#79B8FF"> font-mono</span><span style="color:#79B8FF"> text-sm</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-code-carbon</span><span style="color:#E1E4E8">/35 </span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">4 </span><span style="color:#79B8FF">rounded-lg</span><span style="color:#79B8FF"> overflow-x-auto</span><span style="color:#79B8FF"> font-mono</span><span style="color:#79B8FF"> text-sm</span><span style="color:#79B8FF"> m-</span><span style="color:#E1E4E8">4;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-transparent</span><span style="color:#79B8FF"> p-</span><span style="color:#E1E4E8">0 </span><span style="color:#79B8FF">text-code-carbon</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>The problem was that my colors were overriding Shiki's syntax highlighting.</p>
<p>Here's what's happening: Shiki generates inline styles on each code token (like <code>style="color: #ff79c6"</code> 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:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">/* Inline code */</span></span>
<span class="line"><span style="color:#85E89D">article</span><span style="color:#B392F0"> :not</span><span style="color:#E1E4E8">(</span><span style="color:#85E89D">pre</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">></span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">bg-terminal-primary</span><span style="color:#E1E4E8">/15 !</span><span style="color:#79B8FF">text-terminal-primary</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">px-</span><span style="color:#E1E4E8">1.5 !</span><span style="color:#79B8FF">py-</span><span style="color:#E1E4E8">0.5 </span><span style="color:#79B8FF">rounded-sm</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">font-mono</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">text-sm</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">border-none</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">/* Code block container - let Shiki handle colors */</span></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-terminal-dim</span><span style="color:#E1E4E8">/10 </span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">4 </span><span style="color:#79B8FF">rounded-lg</span><span style="color:#79B8FF"> border</span><span style="color:#79B8FF"> border-terminal-dim</span><span style="color:#E1E4E8">/15 </span><span style="color:#79B8FF">font-mono</span><span style="color:#79B8FF"> text-sm</span><span style="color:#79B8FF"> overflow-x-auto</span><span style="color:#79B8FF"> my-</span><span style="color:#E1E4E8">6;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">/* Reset for code inside pre */</span></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">bg-transparent</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">0 !</span><span style="color:#79B8FF">border-none</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">rounded-none</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">text-inherit</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<h3>Verifying It Works</h3>
<p>When I checked a blog post after getting the CSS right, I finally saw:</p>
<ul>
<li>Inline code (like <code>const foo = 'bar'</code> in a paragraph) had my custom background color</li>
<li>Code blocks had colorized syntax highlighting:
<ul>
<li>Keywords (like <code>const</code>, <code>function</code>, <code>import</code>) in purple/pink</li>
<li>Strings (like <code>'github-dark'</code>) in green</li>
<li>Comments in muted gray</li>
<li>Variables in light blue/white</li>
</ul>
</li>
<li>In the example <code>const foo = 'bar'</code>, I saw <code>const</code> in one color and <code>'bar'</code> in a different color</li>
</ul>
<p>When my code blocks were still just monochrome text at first, I checked <a href="https://shiki.style/themes#github-dark">Shiki's theme preview</a> to see what github-dark was supposed to look like. That's when I realized the Velite cache hadn't cleared properly. Ran <code>npm run clean:dev</code> one more time, restarted the dev server, and it worked.</p>
<h2>Untagged Code Blocks Don't Get Highlighting</h2>
<p>I noticed code blocks without a language specified don't get Shiki styling:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">```</span></span>
<span class="line"><span style="color:#E1E4E8">This has no syntax highlighting</span></span>
<span class="line"><span style="color:#E1E4E8">```</span></span></code></pre>
<p>Two options:</p>
<ol>
<li>Always specify a language - use <code>text</code> or <code>plaintext</code> for non-code</li>
<li>Add fallback text color in CSS:</li>
</ol>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#85E89D">pre</span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-transparent</span><span style="color:#79B8FF"> p-</span><span style="color:#E1E4E8">0 </span><span style="color:#79B8FF">text-inherit</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>I went with always specifying languages since it's more explicit.</p>
<h2>What Actually Worked</h2>
<p>After all that debugging, here's what finally worked. Two files needed changes:</p>
<p><strong>velite.config.js</strong> (mine's in the project root):</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineConfig, s } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span></span>
<span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> rehypeShiki </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '@shikijs/rehype'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: s</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">          title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          cover: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          video: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          metadata: s.</span><span style="color:#B392F0">metadata</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          excerpt: s.</span><span style="color:#B392F0">excerpt</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          content: s.</span><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">()</span></span>
<span class="line"><span style="color:#E1E4E8">        })</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">transform</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">data</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> ({ </span><span style="color:#F97583">...</span><span style="color:#E1E4E8">data, permalink: </span><span style="color:#9ECBFF">`/blog/${</span><span style="color:#E1E4E8">data</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">slug</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8"> }))</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  },</span></span>
<span class="line"><span style="color:#E1E4E8">  markdown: {</span></span>
<span class="line"><span style="color:#E1E4E8">    rehypePlugins: [</span></span>
<span class="line"><span style="color:#E1E4E8">      [rehypeShiki, { theme: </span><span style="color:#9ECBFF">'github-dark'</span><span style="color:#E1E4E8"> }]</span></span>
<span class="line"><span style="color:#E1E4E8">    ]</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p><strong>globals.css</strong> (at <code>/src/app/globals.css</code>):</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#6A737D">/* Inline code */</span></span>
<span class="line"><span style="color:#85E89D">article</span><span style="color:#B392F0"> :not</span><span style="color:#E1E4E8">(</span><span style="color:#85E89D">pre</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">></span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">bg-terminal-primary</span><span style="color:#E1E4E8">/15 !</span><span style="color:#79B8FF">text-terminal-primary</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">px-</span><span style="color:#E1E4E8">1.5 !</span><span style="color:#79B8FF">py-</span><span style="color:#E1E4E8">0.5 </span><span style="color:#79B8FF">rounded-sm</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">font-mono</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">text-sm</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">border-none</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#79B8FF"> bg-terminal-dim</span><span style="color:#E1E4E8">/10 </span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">4 </span><span style="color:#79B8FF">rounded-lg</span><span style="color:#79B8FF"> border</span><span style="color:#79B8FF"> border-terminal-dim</span><span style="color:#E1E4E8">/15 </span><span style="color:#79B8FF">font-mono</span><span style="color:#79B8FF"> text-sm</span><span style="color:#79B8FF"> overflow-x-auto</span><span style="color:#79B8FF"> my-</span><span style="color:#E1E4E8">6;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A737D">/* Reset prose code block styling - let Shiki handle it */</span></span>
<span class="line"><span style="color:#85E89D">pre</span><span style="color:#85E89D"> code</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#E1E4E8">  @</span><span style="color:#79B8FF">apply</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">bg-transparent</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">p-</span><span style="color:#E1E4E8">0 !</span><span style="color:#79B8FF">border-none</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">rounded-none</span><span style="color:#E1E4E8"> !</span><span style="color:#79B8FF">text-inherit</span><span style="color:#E1E4E8">;</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>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.</p>
<p>Other stuff that tripped me up:</p>
<ul>
<li>
<p>File extensions matter - <code>.js</code> files can't use TypeScript syntax. Took me a few minutes of staring at <code>Expected "]" but found "as"</code> before I remembered the config was JavaScript, not TypeScript.</p>
</li>
<li>
<p>Config structure is finicky - Velite has specific expectations about where keys go. Count your braces carefully.</p>
</li>
<li>
<p>Specify languages in markdown - <code>```javascript</code> gives proper highlighting, but <code>```</code> without a language just shows plain text.</p>
</li>
</ul>
<p>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.</p>]]></content:encoded>
            <category>velite</category>
            <category>nextjs</category>
        </item>
        <item>
            <title><![CDATA[Setting Up Velite with Next.js 16]]></title>
            <link>https://lukemanning.ie/blog/setting-up-velite-nextjs-revised</link>
            <guid isPermaLink="true">https://lukemanning.ie/blog/setting-up-velite-nextjs-revised</guid>
            <pubDate>Sun, 16 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[<h2>Setting Up Velite with Next.js 16</h2>
<p>I spent hours yesterday fighting with Velite and Next.js 16. The docs said it should "just work." Instead I got TypeScript errors about exports not found, build failures from missing files in the schema, and a type error that turned out to be a Next.js 16 breaking change.</p>
<p>Here's what happened.</p>
<hr>
<h2>Where This Started</h2>
<p>I got the idea to create a personal blog site where I can share what I'm working on. I did some research on various platforms, and consulted with Claude on potential options. In the end I decided a simple site based on static markdown files seemed like the most suitable option for my needs. I had no idea how to go about doing that so I googled it.</p>
<p>Every article pointed to Contentlayer for managing a simple blog. Except Contentlayer isn't maintained anymore. So I did what any developer does: searched Reddit for "what to use instead of Contentlayer." The consensus seemed to be that Velite was the successor.</p>
<p>Velite offers a lot of additional features like build-time processing and Typescript generation, which I knew nothing about at that point, but those turned out to be useful features I'd use later. I just wanted markdown files to show up as blog posts and Velite seemed to be a suitable option for that.</p>
<p>So I spun up a fresh Next.js project using <code>npx create-next-app@latest</code> and selected: TypeScript, ESLint, Tailwind CSS, App Router, Turbopack.</p>
<p>Then I ran <code>npm install velite</code> and created a basic config.</p>
<p>This is what my setup looked like:</p>
<ul>
<li>Node.js 20.10.0</li>
<li>Next.js 16.1.1 with App Router</li>
<li>React 19.2.0</li>
<li>Velite 0.3.0</li>
<li>TypeScript 5</li>
<li>Running on Ubuntu via WSL2 using Turbopack</li>
</ul>
<p>File structure:</p>
<pre><code>.
├── velite.config.js
├── next.config.ts
├── tsconfig.json
├── .velite/          # Generated by Velite
├── posts/
│   └── my-first-post.md
└── app/
    ├── page.tsx
    └── blog/
        └── [slug]/
            └── page.tsx
</code></pre>
<hr>
<h2>The First Problem: Imports Don't Work</h2>
<p>I installed Velite, created <code>velite.config.js</code>, ran <code>npx velite</code>, and tried to import posts:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { posts } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span><span style="color:#E1E4E8">;</span></span></code></pre>
<p>But immediately encountered a problem:
Error: <code>The export posts was not found in module velite/dist/index.js</code></p>
<p>I stared at this for quite a few minutes. I assumed I'd import from the Velite package itself—that's how most npm packages work, right?</p>
<p>Then I actually checked the <a href="https://velite.js.org/guide/quick-start">Velite docs</a> again. Oh—you import from the <code>.velite</code> folder that Velite generates, not the package itself.</p>
<p>So I tried:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { posts } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> './.velite'</span><span style="color:#E1E4E8">;</span></span></code></pre>
<p>That worked. But then I found <a href="https://velite.js.org/guide/using-collections">the docs mentioned path aliases</a> as a cleaner option. So I created one in <code>tsconfig.json</code>:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  "compilerOptions"</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    "paths"</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">      "@/*"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"./*"</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#79B8FF">      "#site/content"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"./.velite"</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>After restarting the dev server, I tested the path alias approach:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { posts } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '#site/content'</span><span style="color:#E1E4E8">;</span></span></code></pre>
<p>That worked.</p>
<hr>
<h2>The Second Problem: Velite Won't Run</h2>
<p>So I tried running Velite:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>[VELITE] Patterns must be a string (non empty) or an array of strings</span></span></code></pre>
<p>I checked my config—everything looked fine. I'd copied it straight from the <a href="https://velite.js.org/guide/quick-start">Velite quick start docs</a>, so how could it be wrong?</p>
<p>Then I noticed the docs had this <code>others</code> collection:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">collections</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#B392F0">  posts</span><span style="color:#E1E4E8">: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#B392F0">  others</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#6A737D">    // other collection schema options</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>The <code>others</code> bit was just a placeholder showing you can have multiple collections. It wasn't required. I'd pasted it in verbatim without thinking about it.</p>
<p>Velite was complaining because the <code>others</code> collection didn't have a <code>pattern</code> field (like <code>pattern: 'others/**/*.md'</code>). Without a pattern, Velite doesn't know what files to include.</p>
<p>Deleted the <code>others</code> collection entirely. Ran <code>npx velite</code> again. This time: <code>✓ posts (1 documents)</code>.</p>
<hr>
<h2>The Third Problem: Missing Files Break Builds</h2>
<p>Velite was running now. But looking at the <a href="https://velite.js.org/guide/quick-start">Velite docs</a>, I saw examples with <code>cover</code> and <code>video</code> fields. Figured I'd add those too—why not?</p>
<p>So I copied them into my schema:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">cover</span><span style="color:#E1E4E8">: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#B392F0">video</span><span style="color:#E1E4E8">: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">(),</span></span></code></pre>
<p>And put them in my test post:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#85E89D">title</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">My First Post</span></span>
<span class="line"><span style="color:#85E89D">slug</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">my-first-post</span></span>
<span class="line"><span style="color:#85E89D">date</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2025-11-16</span></span>
<span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#85E89D">cover</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">cover.jpg</span></span>
<span class="line"><span style="color:#85E89D">video</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">video.mp4</span></span></code></pre>
<p>But these files didn't exist. Velite errored out.</p>
<p>I should have started with a minimal post:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#85E89D">title</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">My First Post</span></span>
<span class="line"><span style="color:#85E89D">slug</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">my-first-post</span></span>
<span class="line"><span style="color:#85E89D">date</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2025-11-16</span></span>
<span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#9ECBFF">This is some markdown content.</span></span></code></pre>
<p>Same pattern as before—the docs were showing me what's possible, not what I actually need. I made them optional in my schema:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">cover</span><span style="color:#E1E4E8">: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#B392F0">video</span><span style="color:#E1E4E8">: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span></code></pre>
<p>Updated the schema, ran <code>npx velite</code>, and it worked.</p>
<hr>
<h2>The Fourth Problem: Dynamic Routes 404ing</h2>
<p>I got to the point where I could import posts in a listing page, but individual post routes kept 404ing even though the files were there.</p>
<p>I'd copied <a href="https://velite.js.org/guide/using-collections#use-in-your-project">some example code from the docs</a> for dynamic routes:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> PostPage</span><span style="color:#E1E4E8">({ </span><span style="color:#FFAB70">params</span><span style="color:#E1E4E8"> }</span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> { </span><span style="color:#FFAB70">params</span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> { </span><span style="color:#FFAB70">slug</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8"> } }) {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> post</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">find</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">p</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> p.slug </span><span style="color:#F97583">===</span><span style="color:#E1E4E8"> params.slug);</span></span>
<span class="line"><span style="color:#6A737D">  // ...</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>TypeScript was complaining that <code>params</code> should be a <code>Promise&#x3C;{ slug: string }></code> instead of just <code>{ slug: string }</code>. I ignored it at first because I assumed the example code was correct.</p>
<p>Turns out the example was from Next.js 14 or earlier. Next.js 15 changed params to be async for better performance, and Next.js 16 kept that change.</p>
<p>Here's what happens without <code>await</code>: Next.js expects an async function to handle the params promise. If you don't await it, the route handler won't execute properly—it just silently 404s.</p>
<p>So I needed to await the params:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#F97583"> async</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> PostPage</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#FFAB70">  params</span></span>
<span class="line"><span style="color:#E1E4E8">}</span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#FFAB70">  params</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Promise</span><span style="color:#E1E4E8">&#x3C;{ </span><span style="color:#FFAB70">slug</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8"> }></span></span>
<span class="line"><span style="color:#E1E4E8">}) {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#E1E4E8"> { </span><span style="color:#79B8FF">slug</span><span style="color:#E1E4E8"> } </span><span style="color:#F97583">=</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> params;</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> post</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">find</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">p</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> p.slug </span><span style="color:#F97583">===</span><span style="color:#E1E4E8"> slug);</span></span>
<span class="line"><span style="color:#6A737D">  // ..</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>After this change, my dynamic routes loaded. No more 404s.</p>
<hr>
<h2>The Fifth Problem: Watch Mode Just... Didn't Work</h2>
<p>This one took me the longest. I copied the integration code from the <a href="https://velite.js.org/guide/with-nextjs">Velite docs</a> into <code>next.config.ts</code>:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.argv.</span><span style="color:#B392F0">indexOf</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'dev'</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">!==</span><span style="color:#F97583"> -</span><span style="color:#79B8FF">1</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isBuild</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.argv.</span><span style="color:#B392F0">indexOf</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'build'</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">!==</span><span style="color:#F97583"> -</span><span style="color:#79B8FF">1</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> (isDev </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> isBuild)) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: isDev, clean: </span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isDev }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Started the dev server. No errors. But Velite never ran. I could edit markdown files all day—nothing happened.</p>
<h3>Debugging the argv Issue</h3>
<p>I'd been debugging for a while by this point and was stuck. I asked Claude for help, and it suggested adding some console.log statements to see what was actually in <code>process.argv</code>:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'[DEBUG] isDev:'</span><span style="color:#E1E4E8">, isDev, </span><span style="color:#9ECBFF">'isBuild:'</span><span style="color:#E1E4E8">, isBuild, </span><span style="color:#9ECBFF">'argv:'</span><span style="color:#E1E4E8">, process.argv)</span></span></code></pre>
<p>Output showed both <code>isDev</code> and <code>isBuild</code> as false:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>[DEBUG] isDev: false isBuild: false argv: [</span></span>
<span class="line"><span>  '/home/luke/.nvm/versions/node/v24.11.1/bin/node',</span></span>
<span class="line"><span>  '/home/luke/workspace/lukemanning-site/node_modules/next/dist/server/lib/start-server.js'</span></span>
<span class="line"><span>]</span></span></code></pre>
<p>So 'dev' and 'build' weren't in argv at all.</p>
<p>The Velite integration code checks if 'dev' is in <code>process.argv</code> to decide whether to start watch mode. Since Turbopack's <code>start-server.js</code> doesn't include 'dev' in argv, that condition was never true—so Velite never started.</p>
<p>Next.js was using <code>start-server.js</code> internally instead of a simple command-line argument.</p>
<p>Claude suggested checking <code>process.env.NODE_ENV</code> as an alternative—since that's more reliable than parsing command-line arguments. Added that to my logging and saw it was set to 'development'. Worth a shot.</p>
<p>Changed it:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'development'</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isBuild</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> (isDev </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> isBuild)) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: isDev, clean: </span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isDev }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Started the dev server again:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>[VELITE] Building...</span></span>
<span class="line"><span>✓ posts (1 documents)</span></span>
<span class="line"><span>[VELITE] Watching for changes...</span></span></code></pre>
<p>Edited a markdown file:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>[VELITE] Rebuilding...</span></span>
<span class="line"><span>✓ posts (1 documents)</span></span></code></pre>
<p>Finally worked.</p>
<p>I think what's happening is:</p>
<ul>
<li>Next.js 16 with Turbopack changed how the dev server starts internally</li>
<li>It uses <code>start-server.js</code> as an intermediary instead of a simple <code>next dev</code> command</li>
<li>The <code>NODE_ENV</code> environment variable is more reliable because it's set by Next.js regardless of how the server starts</li>
</ul>
<p>Turbopack is enabled by default in Next.js 16 when you choose the recommended defaults in create-next-app, so I didn't even realize I was using it. The Velite docs acknowledge that Turbopack breaks their webpack plugin, and they provide a <code>process.argv</code> workaround—but that workaround doesn't actually work with Next.js 16's Turbopack setup.</p>
<p>My understanding could be wrong—I didn't dig into the Next.js source code—but the <code>NODE_ENV</code> approach has been rock-solid for two weeks now.</p>
<hr>
<h2>Where I Landed</h2>
<p>After all that mess, here's where I ended up:</p>
<p><strong>velite.config.js:</strong></p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineConfig, s } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: s</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">          title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          cover: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          video: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          metadata: s.</span><span style="color:#B392F0">metadata</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          excerpt: s.</span><span style="color:#B392F0">excerpt</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          content: s.</span><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">()</span></span>
<span class="line"><span style="color:#E1E4E8">        })</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">transform</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">data</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> ({ </span><span style="color:#F97583">...</span><span style="color:#E1E4E8">data, permalink: </span><span style="color:#9ECBFF">`/blog/${</span><span style="color:#E1E4E8">data</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">slug</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8"> }))</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p><strong>next.config.ts:</strong></p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { NextConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> "next"</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'development'</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isBuild</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> (isDev </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> isBuild)) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: isDev, clean: </span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isDev }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> nextConfig</span><span style="color:#F97583">:</span><span style="color:#B392F0"> NextConfig</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#E1E4E8"> nextConfig;</span></span></code></pre>
<p><strong>Just the paths section from tsconfig.json:</strong></p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#9ECBFF">"paths"</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">  "@/*"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"./*"</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#79B8FF">  "#site/content"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"./.velite"</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<h2>Next Steps</h2>
<p>Once you have Velite running with Next.js, you might also want to set up <a href="/blog/adding-draft-posts-to-velite">draft post filtering</a> so unfinished posts don't accidentally go live, and <a href="/blog/adding-syntax-highlighting-shiki">add syntax highlighting with Shiki</a> to make your code blocks actually readable.</p>]]></description>
            <content:encoded><![CDATA[<h2>Setting Up Velite with Next.js 16</h2>
<p>I spent hours yesterday fighting with Velite and Next.js 16. The docs said it should "just work." Instead I got TypeScript errors about exports not found, build failures from missing files in the schema, and a type error that turned out to be a Next.js 16 breaking change.</p>
<p>Here's what happened.</p>
<hr>
<h2>Where This Started</h2>
<p>I got the idea to create a personal blog site where I can share what I'm working on. I did some research on various platforms, and consulted with Claude on potential options. In the end I decided a simple site based on static markdown files seemed like the most suitable option for my needs. I had no idea how to go about doing that so I googled it.</p>
<p>Every article pointed to Contentlayer for managing a simple blog. Except Contentlayer isn't maintained anymore. So I did what any developer does: searched Reddit for "what to use instead of Contentlayer." The consensus seemed to be that Velite was the successor.</p>
<p>Velite offers a lot of additional features like build-time processing and Typescript generation, which I knew nothing about at that point, but those turned out to be useful features I'd use later. I just wanted markdown files to show up as blog posts and Velite seemed to be a suitable option for that.</p>
<p>So I spun up a fresh Next.js project using <code>npx create-next-app@latest</code> and selected: TypeScript, ESLint, Tailwind CSS, App Router, Turbopack.</p>
<p>Then I ran <code>npm install velite</code> and created a basic config.</p>
<p>This is what my setup looked like:</p>
<ul>
<li>Node.js 20.10.0</li>
<li>Next.js 16.1.1 with App Router</li>
<li>React 19.2.0</li>
<li>Velite 0.3.0</li>
<li>TypeScript 5</li>
<li>Running on Ubuntu via WSL2 using Turbopack</li>
</ul>
<p>File structure:</p>
<pre><code>.
├── velite.config.js
├── next.config.ts
├── tsconfig.json
├── .velite/          # Generated by Velite
├── posts/
│   └── my-first-post.md
└── app/
    ├── page.tsx
    └── blog/
        └── [slug]/
            └── page.tsx
</code></pre>
<hr>
<h2>The First Problem: Imports Don't Work</h2>
<p>I installed Velite, created <code>velite.config.js</code>, ran <code>npx velite</code>, and tried to import posts:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { posts } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span><span style="color:#E1E4E8">;</span></span></code></pre>
<p>But immediately encountered a problem:
Error: <code>The export posts was not found in module velite/dist/index.js</code></p>
<p>I stared at this for quite a few minutes. I assumed I'd import from the Velite package itself—that's how most npm packages work, right?</p>
<p>Then I actually checked the <a href="https://velite.js.org/guide/quick-start">Velite docs</a> again. Oh—you import from the <code>.velite</code> folder that Velite generates, not the package itself.</p>
<p>So I tried:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { posts } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> './.velite'</span><span style="color:#E1E4E8">;</span></span></code></pre>
<p>That worked. But then I found <a href="https://velite.js.org/guide/using-collections">the docs mentioned path aliases</a> as a cleaner option. So I created one in <code>tsconfig.json</code>:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">{</span></span>
<span class="line"><span style="color:#79B8FF">  "compilerOptions"</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">    "paths"</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">      "@/*"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"./*"</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#79B8FF">      "#site/content"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"./.velite"</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>After restarting the dev server, I tested the path alias approach:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { posts } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> '#site/content'</span><span style="color:#E1E4E8">;</span></span></code></pre>
<p>That worked.</p>
<hr>
<h2>The Second Problem: Velite Won't Run</h2>
<p>So I tried running Velite:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>[VELITE] Patterns must be a string (non empty) or an array of strings</span></span></code></pre>
<p>I checked my config—everything looked fine. I'd copied it straight from the <a href="https://velite.js.org/guide/quick-start">Velite quick start docs</a>, so how could it be wrong?</p>
<p>Then I noticed the docs had this <code>others</code> collection:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">collections</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#B392F0">  posts</span><span style="color:#E1E4E8">: { </span><span style="color:#6A737D">/* ... */</span><span style="color:#E1E4E8"> },</span></span>
<span class="line"><span style="color:#B392F0">  others</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#6A737D">    // other collection schema options</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>The <code>others</code> bit was just a placeholder showing you can have multiple collections. It wasn't required. I'd pasted it in verbatim without thinking about it.</p>
<p>Velite was complaining because the <code>others</code> collection didn't have a <code>pattern</code> field (like <code>pattern: 'others/**/*.md'</code>). Without a pattern, Velite doesn't know what files to include.</p>
<p>Deleted the <code>others</code> collection entirely. Ran <code>npx velite</code> again. This time: <code>✓ posts (1 documents)</code>.</p>
<hr>
<h2>The Third Problem: Missing Files Break Builds</h2>
<p>Velite was running now. But looking at the <a href="https://velite.js.org/guide/quick-start">Velite docs</a>, I saw examples with <code>cover</code> and <code>video</code> fields. Figured I'd add those too—why not?</p>
<p>So I copied them into my schema:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">cover</span><span style="color:#E1E4E8">: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#B392F0">video</span><span style="color:#E1E4E8">: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">(),</span></span></code></pre>
<p>And put them in my test post:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#85E89D">title</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">My First Post</span></span>
<span class="line"><span style="color:#85E89D">slug</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">my-first-post</span></span>
<span class="line"><span style="color:#85E89D">date</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2025-11-16</span></span>
<span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#85E89D">cover</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">cover.jpg</span></span>
<span class="line"><span style="color:#85E89D">video</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">video.mp4</span></span></code></pre>
<p>But these files didn't exist. Velite errored out.</p>
<p>I should have started with a minimal post:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#85E89D">title</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">My First Post</span></span>
<span class="line"><span style="color:#85E89D">slug</span><span style="color:#E1E4E8">: </span><span style="color:#9ECBFF">my-first-post</span></span>
<span class="line"><span style="color:#85E89D">date</span><span style="color:#E1E4E8">: </span><span style="color:#79B8FF">2025-11-16</span></span>
<span class="line"><span style="color:#B392F0">---</span></span>
<span class="line"><span style="color:#9ECBFF">This is some markdown content.</span></span></code></pre>
<p>Same pattern as before—the docs were showing me what's possible, not what I actually need. I made them optional in my schema:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#B392F0">cover</span><span style="color:#E1E4E8">: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#B392F0">video</span><span style="color:#E1E4E8">: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span></code></pre>
<p>Updated the schema, ran <code>npx velite</code>, and it worked.</p>
<hr>
<h2>The Fourth Problem: Dynamic Routes 404ing</h2>
<p>I got to the point where I could import posts in a listing page, but individual post routes kept 404ing even though the files were there.</p>
<p>I'd copied <a href="https://velite.js.org/guide/using-collections#use-in-your-project">some example code from the docs</a> for dynamic routes:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> PostPage</span><span style="color:#E1E4E8">({ </span><span style="color:#FFAB70">params</span><span style="color:#E1E4E8"> }</span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> { </span><span style="color:#FFAB70">params</span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> { </span><span style="color:#FFAB70">slug</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8"> } }) {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> post</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">find</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">p</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> p.slug </span><span style="color:#F97583">===</span><span style="color:#E1E4E8"> params.slug);</span></span>
<span class="line"><span style="color:#6A737D">  // ...</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>TypeScript was complaining that <code>params</code> should be a <code>Promise&#x3C;{ slug: string }></code> instead of just <code>{ slug: string }</code>. I ignored it at first because I assumed the example code was correct.</p>
<p>Turns out the example was from Next.js 14 or earlier. Next.js 15 changed params to be async for better performance, and Next.js 16 kept that change.</p>
<p>Here's what happens without <code>await</code>: Next.js expects an async function to handle the params promise. If you don't await it, the route handler won't execute properly—it just silently 404s.</p>
<p>So I needed to await the params:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#F97583"> async</span><span style="color:#F97583"> function</span><span style="color:#B392F0"> PostPage</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#FFAB70">  params</span></span>
<span class="line"><span style="color:#E1E4E8">}</span><span style="color:#F97583">:</span><span style="color:#E1E4E8"> {</span></span>
<span class="line"><span style="color:#FFAB70">  params</span><span style="color:#F97583">:</span><span style="color:#B392F0"> Promise</span><span style="color:#E1E4E8">&#x3C;{ </span><span style="color:#FFAB70">slug</span><span style="color:#F97583">:</span><span style="color:#79B8FF"> string</span><span style="color:#E1E4E8"> }></span></span>
<span class="line"><span style="color:#E1E4E8">}) {</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#E1E4E8"> { </span><span style="color:#79B8FF">slug</span><span style="color:#E1E4E8"> } </span><span style="color:#F97583">=</span><span style="color:#F97583"> await</span><span style="color:#E1E4E8"> params;</span></span>
<span class="line"><span style="color:#F97583">  const</span><span style="color:#79B8FF"> post</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> posts.</span><span style="color:#B392F0">find</span><span style="color:#E1E4E8">((</span><span style="color:#FFAB70">p</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">=></span><span style="color:#E1E4E8"> p.slug </span><span style="color:#F97583">===</span><span style="color:#E1E4E8"> slug);</span></span>
<span class="line"><span style="color:#6A737D">  // ..</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>After this change, my dynamic routes loaded. No more 404s.</p>
<hr>
<h2>The Fifth Problem: Watch Mode Just... Didn't Work</h2>
<p>This one took me the longest. I copied the integration code from the <a href="https://velite.js.org/guide/with-nextjs">Velite docs</a> into <code>next.config.ts</code>:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.argv.</span><span style="color:#B392F0">indexOf</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'dev'</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">!==</span><span style="color:#F97583"> -</span><span style="color:#79B8FF">1</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isBuild</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.argv.</span><span style="color:#B392F0">indexOf</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'build'</span><span style="color:#E1E4E8">) </span><span style="color:#F97583">!==</span><span style="color:#F97583"> -</span><span style="color:#79B8FF">1</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> (isDev </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> isBuild)) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: isDev, clean: </span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isDev }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Started the dev server. No errors. But Velite never ran. I could edit markdown files all day—nothing happened.</p>
<h3>Debugging the argv Issue</h3>
<p>I'd been debugging for a while by this point and was stuck. I asked Claude for help, and it suggested adding some console.log statements to see what was actually in <code>process.argv</code>:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#E1E4E8">console.</span><span style="color:#B392F0">log</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'[DEBUG] isDev:'</span><span style="color:#E1E4E8">, isDev, </span><span style="color:#9ECBFF">'isBuild:'</span><span style="color:#E1E4E8">, isBuild, </span><span style="color:#9ECBFF">'argv:'</span><span style="color:#E1E4E8">, process.argv)</span></span></code></pre>
<p>Output showed both <code>isDev</code> and <code>isBuild</code> as false:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>[DEBUG] isDev: false isBuild: false argv: [</span></span>
<span class="line"><span>  '/home/luke/.nvm/versions/node/v24.11.1/bin/node',</span></span>
<span class="line"><span>  '/home/luke/workspace/lukemanning-site/node_modules/next/dist/server/lib/start-server.js'</span></span>
<span class="line"><span>]</span></span></code></pre>
<p>So 'dev' and 'build' weren't in argv at all.</p>
<p>The Velite integration code checks if 'dev' is in <code>process.argv</code> to decide whether to start watch mode. Since Turbopack's <code>start-server.js</code> doesn't include 'dev' in argv, that condition was never true—so Velite never started.</p>
<p>Next.js was using <code>start-server.js</code> internally instead of a simple command-line argument.</p>
<p>Claude suggested checking <code>process.env.NODE_ENV</code> as an alternative—since that's more reliable than parsing command-line arguments. Added that to my logging and saw it was set to 'development'. Worth a shot.</p>
<p>Changed it:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'development'</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isBuild</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> (isDev </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> isBuild)) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: isDev, clean: </span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isDev }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<p>Started the dev server again:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>[VELITE] Building...</span></span>
<span class="line"><span>✓ posts (1 documents)</span></span>
<span class="line"><span>[VELITE] Watching for changes...</span></span></code></pre>
<p>Edited a markdown file:</p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span>[VELITE] Rebuilding...</span></span>
<span class="line"><span>✓ posts (1 documents)</span></span></code></pre>
<p>Finally worked.</p>
<p>I think what's happening is:</p>
<ul>
<li>Next.js 16 with Turbopack changed how the dev server starts internally</li>
<li>It uses <code>start-server.js</code> as an intermediary instead of a simple <code>next dev</code> command</li>
<li>The <code>NODE_ENV</code> environment variable is more reliable because it's set by Next.js regardless of how the server starts</li>
</ul>
<p>Turbopack is enabled by default in Next.js 16 when you choose the recommended defaults in create-next-app, so I didn't even realize I was using it. The Velite docs acknowledge that Turbopack breaks their webpack plugin, and they provide a <code>process.argv</code> workaround—but that workaround doesn't actually work with Next.js 16's Turbopack setup.</p>
<p>My understanding could be wrong—I didn't dig into the Next.js source code—but the <code>NODE_ENV</code> approach has been rock-solid for two weeks now.</p>
<hr>
<h2>Where I Landed</h2>
<p>After all that mess, here's where I ended up:</p>
<p><strong>velite.config.js:</strong></p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#E1E4E8"> { defineConfig, s } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> 'velite'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#B392F0"> defineConfig</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">  collections: {</span></span>
<span class="line"><span style="color:#E1E4E8">    posts: {</span></span>
<span class="line"><span style="color:#E1E4E8">      name: </span><span style="color:#9ECBFF">'Post'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      pattern: </span><span style="color:#9ECBFF">'posts/**/*.md'</span><span style="color:#E1E4E8">,</span></span>
<span class="line"><span style="color:#E1E4E8">      schema: s</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">object</span><span style="color:#E1E4E8">({</span></span>
<span class="line"><span style="color:#E1E4E8">          title: s.</span><span style="color:#B392F0">string</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">max</span><span style="color:#E1E4E8">(</span><span style="color:#79B8FF">99</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          slug: s.</span><span style="color:#B392F0">slug</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'posts'</span><span style="color:#E1E4E8">),</span></span>
<span class="line"><span style="color:#E1E4E8">          date: s.</span><span style="color:#B392F0">isodate</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          cover: s.</span><span style="color:#B392F0">image</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          video: s.</span><span style="color:#B392F0">file</span><span style="color:#E1E4E8">().</span><span style="color:#B392F0">optional</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          metadata: s.</span><span style="color:#B392F0">metadata</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          excerpt: s.</span><span style="color:#B392F0">excerpt</span><span style="color:#E1E4E8">(),</span></span>
<span class="line"><span style="color:#E1E4E8">          content: s.</span><span style="color:#B392F0">markdown</span><span style="color:#E1E4E8">()</span></span>
<span class="line"><span style="color:#E1E4E8">        })</span></span>
<span class="line"><span style="color:#E1E4E8">        .</span><span style="color:#B392F0">transform</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">data</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> ({ </span><span style="color:#F97583">...</span><span style="color:#E1E4E8">data, permalink: </span><span style="color:#9ECBFF">`/blog/${</span><span style="color:#E1E4E8">data</span><span style="color:#9ECBFF">.</span><span style="color:#E1E4E8">slug</span><span style="color:#9ECBFF">}`</span><span style="color:#E1E4E8"> }))</span></span>
<span class="line"><span style="color:#E1E4E8">    }</span></span>
<span class="line"><span style="color:#E1E4E8">  }</span></span>
<span class="line"><span style="color:#E1E4E8">})</span></span></code></pre>
<p><strong>next.config.ts:</strong></p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#F97583">import</span><span style="color:#F97583"> type</span><span style="color:#E1E4E8"> { NextConfig } </span><span style="color:#F97583">from</span><span style="color:#9ECBFF"> "next"</span><span style="color:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isDev</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'development'</span></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> isBuild</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> process.env.</span><span style="color:#79B8FF">NODE_ENV</span><span style="color:#F97583"> ===</span><span style="color:#9ECBFF"> 'production'</span></span>
<span class="line"><span style="color:#F97583">if</span><span style="color:#E1E4E8"> (</span><span style="color:#F97583">!</span><span style="color:#E1E4E8">process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> &#x26;&#x26;</span><span style="color:#E1E4E8"> (isDev </span><span style="color:#F97583">||</span><span style="color:#E1E4E8"> isBuild)) {</span></span>
<span class="line"><span style="color:#E1E4E8">  process.env.</span><span style="color:#79B8FF">VELITE_STARTED</span><span style="color:#F97583"> =</span><span style="color:#9ECBFF"> '1'</span></span>
<span class="line"><span style="color:#F97583">  import</span><span style="color:#E1E4E8">(</span><span style="color:#9ECBFF">'velite'</span><span style="color:#E1E4E8">).</span><span style="color:#B392F0">then</span><span style="color:#E1E4E8">(</span><span style="color:#FFAB70">m</span><span style="color:#F97583"> =></span><span style="color:#E1E4E8"> m.</span><span style="color:#B392F0">build</span><span style="color:#E1E4E8">({ watch: isDev, clean: </span><span style="color:#F97583">!</span><span style="color:#E1E4E8">isDev }))</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">const</span><span style="color:#79B8FF"> nextConfig</span><span style="color:#F97583">:</span><span style="color:#B392F0"> NextConfig</span><span style="color:#F97583"> =</span><span style="color:#E1E4E8"> {};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#F97583">export</span><span style="color:#F97583"> default</span><span style="color:#E1E4E8"> nextConfig;</span></span></code></pre>
<p><strong>Just the paths section from tsconfig.json:</strong></p>
<pre class="shiki github-dark" style="background-color:#24292e;color:#e1e4e8" tabindex="0"><code><span class="line"><span style="color:#9ECBFF">"paths"</span><span style="color:#E1E4E8">: {</span></span>
<span class="line"><span style="color:#79B8FF">  "@/*"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"./*"</span><span style="color:#E1E4E8">],</span></span>
<span class="line"><span style="color:#79B8FF">  "#site/content"</span><span style="color:#E1E4E8">: [</span><span style="color:#9ECBFF">"./.velite"</span><span style="color:#E1E4E8">]</span></span>
<span class="line"><span style="color:#E1E4E8">}</span></span></code></pre>
<h2>Next Steps</h2>
<p>Once you have Velite running with Next.js, you might also want to set up <a href="/blog/adding-draft-posts-to-velite">draft post filtering</a> so unfinished posts don't accidentally go live, and <a href="/blog/adding-syntax-highlighting-shiki">add syntax highlighting with Shiki</a> to make your code blocks actually readable.</p>]]></content:encoded>
            <category>velite</category>
            <category>nextjs</category>
        </item>
    </channel>
</rss>