Draft Posts Still Showing in Production: My Velite Filtering Journey
I deployed my blog to Vercel, and there they were. All 17 draft posts, live on the internet. Great.
I had previously set up draft posts following my guide on adding draft posts to Velite. The frontmatter clearly said draft: true, 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.
My Initial Setup
I'm using Velite 0.3.0 with Next.js 16.1.1. Here's what my velite.config.js looked like initially:
// velite.config.js (original)
export default defineConfig({
collections: [
{
name: 'post',
pattern: 'posts/**/*.md',
schema: {
// ... schema definition
},
filter: (post) => {
const isProduction = process.env.VERCEL_ENV === 'production'
return isProduction ? !post.draft : true
}
}
],
// ... rest of config
})
The logic seemed sound: check if we're on Vercel production, and if so, filter out draft posts. In development, show everything including drafts.
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.
First Suspicion: Maybe VERCEL_ENV Isn't Set at Build Time?
I had a suspicion that VERCEL_ENV 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.
I changed the config to use NODE_ENV instead, since that's definitely set during the build:
// velite.config.js (attempt 1)
filter: (post) => {
const isProduction = process.env.NODE_ENV === 'production'
return isProduction ? !post.draft : true
}
I was hoping this would work because NODE_ENV is set by Next.js when running npm run build, so it should be available when Velite processes the posts.
First Test: Still There
I ran the build:
NODE_ENV=production npm run build
Then checked .velite/posts.json to see what was generated. All 18 posts. Including 17 drafts.
I also checked what VERCEL_ENV was actually set to during the build:
// Quick debug check
filter: (post) => {
console.log('VERCEL_ENV:', process.env.VERCEL_ENV)
const isProduction = process.env.NODE_ENV === 'production'
return isProduction ? !post.draft : true
}
Output: VERCEL_ENV: undefined - exactly what I suspected. The environment variable wasn't being set during Velite's build process.
Okay, so the filter function wasn't working as I expected.
Next I Tried: Check Timing of Environment Evaluation
I wondered if NODE_ENV was being evaluated when the module loads instead of when Velite actually processes each post. If the module loads before NODE_ENV is set to 'production', the filter would always see development mode.
But wait - that didn't make sense. I was explicitly running NODE_ENV=production npm run build, so the environment variable should be set before any Node modules load. The timing wasn't the issue.
Something else was going on.
Does the Filter Function Even Run?
Let me see if the filter function is even being called:
filter: (post) => {
console.log('Filtering post:', post.title, 'draft:', post.draft)
const isProduction = process.env.NODE_ENV === 'production'
return isProduction ? !post.draft : true
}
Ran the build. I was watching the terminal where I ran npm run build, 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.
So I Tested the Filter Logic Itself
Maybe the filter function syntax was wrong? Let me test by making it always return false, which should exclude all posts:
filter: (post) => {
console.log('Filtering post:', post.title)
return false
}
Built again. Checked .velite/posts.json. All 18 posts still there.
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.
Filter in the complete() Callback
Velite has a complete() callback that runs after it generates all the data. Maybe filtering needs to happen there, since it's called after processing is complete?
I thought this might work because in my mental model, Velite processed the posts first, then wrote the JSON files, then called complete() to do final cleanup. If that was the case, modifying data.posts in complete() would affect what got written to the JSON file.
complete: (data) => {
const isProduction = process.env.NODE_ENV === 'production'
console.log('isProduction:', isProduction)
console.log('Posts before filter:', data.posts.length)
if (isProduction) {
const draftCount = data.posts.filter(p => p.draft).length
console.log('Filtering out', draftCount, 'draft posts')
data.posts = data.posts.filter(post => !post.draft)
}
console.log('Posts after filter:', data.posts.length)
// ... rest of function
}
Ran NODE_ENV=production npm run build.
Output:
isProduction: true
Posts before filter: 18
Filtering out 17 draft posts
Posts after filter: 1
Okay, so the filtering logic itself works! The logs show 1 post after filtering.
But when I opened .velite/posts.json to verify, I was surprised - all 18 posts were there. My mental model was wrong. The complete() callback must run AFTER the JSON files are written, or it doesn't affect the generated output files at all.
Either way, the filtering I was doing wasn't making it into the JSON file.
The Breakthrough
Then it clicked. Velite generates the JSON, but I don't have to use all of it. My app imports posts from .velite/posts.json in src/lib/posts.ts, where I sort them and prepare them for display. Why not filter there too?
This is runtime filtering - it happens right when the posts are accessed by my app, not during Velite's build process. That means NODE_ENV would definitely be set correctly because it's happening in the Next.js runtime.
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.
Here's what src/lib/posts.ts looked like before:
// src/lib/posts.ts (before)
import posts from '.velite/posts.json'
import { cache } from 'react'
export const getPosts = cache(() =>
posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
)
And here's what I changed it to:
// src/lib/posts.ts (after)
import posts from '.velite/posts.json'
import { cache } from 'react'
const isProduction = process.env.NODE_ENV === 'production'
const filteredPosts = isProduction ? posts.filter(post => !post.draft) : posts
export const getPosts = cache(() =>
filteredPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
)
Now when getPosts() is called, it checks NODE_ENV and returns filtered posts if we're in production.
Before deploying, I wanted to verify locally:
NODE_ENV=production npm run build
NODE_ENV=production npm start
Opened localhost:3000 in the browser. Only the published post showed up. The drafts were gone.
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.
Quick note about preview deployments: The NODE_ENV check I used means preview deployments on Vercel will also hide draft posts, since NODE_ENV is 'production' in preview builds. If you want drafts to show in preview deployments, you'd need to check VERCEL_ENV === 'production' instead. But for my use case, hiding drafts in all non-dev environments works fine.
Cleaning Up
I removed the filter logic from velite.config.js since it wasn't working anyway:
// velite.config.js (final)
export default defineConfig({
collections: [
{
name: 'post',
pattern: 'posts/**/*.md',
schema: {
// ... schema definition
}
// No filter function - filtering happens in src/lib/posts.ts
}
],
// ... rest of config
})
Committed with message: "Fix draft post filtering to work in production"
Ran npm run lint to make sure everything was clean. Deployed. Verified.
Now my drafts stay drafts, and published posts are the only ones visible in production.
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.