Skip to main content
luke@terminal:/blog$ ls
luke@terminal:/blog$ cat adding-draft-posts-to-velite.md

Adding Draft Posts to Velite: Why 'draft: true' Isn't Enough

Created: 2026-01-05 | 7 min read

I previously wrote about my multi-agent blog post generation system that takes my raw conversations and notes and generates structured posts from them: I Built a Multi-Agent System to Review My Blog Posts.

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.

"Just add draft: true to frontmatter," I thought. Every other static site generator does this, right?

Spoiler: Velite doesn't work that way.

Here's what I tried first:

---
title: "Work in Progress"
date: 2025-01-03
draft: true
---

Still writing this...

Except... it doesn't work that way.

The Question

"Does Velite automatically consider all frontmatter (those YAML settings at the top of my markdown files between the --- markers), or do we need to handle each value separately?"

I assumed Velite would auto-pick up any field I put in frontmatter. Add draft: true, filter posts by post.draft, done.

That's not how Velite works.

What I'm Starting With

Before I get into the solution, here's what my setup looked like:

My existing Velite schema (/velite.config.js):

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

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

Prerequisites: You should have:

  • Velite 0.3.0+ installed
  • A working posts collection
  • Frontmatter with title, slug, date fields

If your setup looks different, the draft field addition should still work. You'll just need to adapt the schema structure.

How Velite Actually Works

I'm using Velite 0.3.0 with Next.js 16.1.1, and this is where I learned something important.

Velite is schema-first. Every field must be explicitly defined in your schema in /velite.config.js.

From Velite's official docs:

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.

No schema field? No access to that data. Period.

This means:

  1. Schema defines fields - What fields exist on your posts
  2. Frontmatter provides values - The actual data for those fields
  3. Velite validates - Ensures frontmatter matches schema at build time

If draft isn't in your schema, Velite ignores it in frontmatter.

What I Actually Did (And Got Wrong First)

Adding draft functionality requires two changes. I thought I could skip the first one. I was wrong.

1. Add the Field to Your Schema

I figured Velite would just... pick up the field. Add draft: true to frontmatter, access post.draft in my code, done.

Nope.

Velite ignored my draft field entirely. No error, no warning, nothing. The post compiled, the field was just... gone.

Here's what I added to my config:

import { defineCollection, defineConfig } from 'velite'
import { MDXPlugin } from 'velite-remark'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'

const posts = defineCollection({
  name: 'Post',
  pattern: 'posts/**/*.md',
  schema: s.object({
    title: s.string().max(99),
    slug: s.slug('posts'),
    date: s.isodate(),
    draft: s.boolean().optional().default(false), // ← Add this line
    // ... other existing fields like description, tags, etc.
  }),
  mdx: MDXPlugin({ rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings] })
})

export default defineConfig({
  collections: { posts }
})

In my case, I had MDX plugins set up for syntax highlighting. If your setup is simpler, the schema addition looks like this:

const posts = defineCollection({
  name: 'Post',
  pattern: 'posts/**/*.md',
  schema: s.object({
    title: s.string().max(99),
    slug: s.slug('posts'),
    date: s.isodate(),
    draft: s.boolean().optional().default(false),
  })
})

export default defineConfig({ collections: { posts } })

Understanding the Schema Builder

The s object you see in the code examples is the schema builder. It's provided by defineCollection and gives you type-safe field definitions:

  • s.string() - text fields
  • s.boolean() - true/false values
  • s.isodate() - date fields (validated as ISO format)
  • s.slug() - URL-friendly slugs
  • .optional() - field doesn't have to exist
  • .default(false) - if missing, use this value
  • .max(99) - string length limit

Why I Set It Up This Way

I chose .optional().default(false) and .boolean() for specific reasons:

  • .optional() — not every post needs a draft flag, so we don't require it
  • .default(false) — posts are published by default (safer than assuming draft)
  • .boolean() — catches typos like draft: yes instead of true, failing at build time

Don't Forget Cache Clearing

I saved the config and restarted the dev server.

Still no draft field in my posts. I checked .velite/posts.json. Nothing.

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.

At this point I remembered: Velite caches everything. Schema changes mean the cached data is invalid.

I ran npm run clean:dev (which removes both .next and .velite folders) and restarted the dev server.

This time, .velite/posts.json had the draft field for every post:

{
  "title": "Work in Progress",
  "slug": "work-in-progress",
  "date": "2025-01-03T00:00:00.000Z",
  "draft": true,
  // ... other fields
}

Posts without draft: true in frontmatter had "draft": false (from the .default(false)). I tested a typo (darft: true) which resulted in the field being ignored entirely, defaulting to false. Nice.

Lesson learned: Schema changes in Velite require clearing the cache. It's annoying, but it prevents stale cached data from causing weird bugs.

2. Add Environment-Aware Filtering

But having the field isn't enough. You also need to filter out drafts based on environment.

I needed:

  • Local dev: Show all posts (including drafts)
  • Vercel preview: Show all posts (for review)
  • Production: Hide drafts

I considered using Vercel's VERCEL_ENV at first. That's the "proper" Vercel way. But I was already using NODE_ENV elsewhere in my config, so I stuck with that.

const isProduction = process.env.NODE_ENV === 'production'

This works because:

  • Local dev means NODE_ENV=development, so show drafts
  • Production build means NODE_ENV=production, so hide drafts

For the filtering, I added it in /src/lib/posts.ts rather than the Velite config. Here's what that looks like:

import { cache } from 'react'
import { posts } from '#site/content'

const isProduction = process.env.NODE_ENV === 'production'

// Filter posts at the application level
const filteredPosts = isProduction ? posts.filter(post => !post.draft) : posts

// All exported functions use filteredPosts instead of raw posts
export const getPosts = cache(() =>
  filteredPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
)

export const getPostBySlug = cache((slug: string) =>
  filteredPosts.find((p) => p.slug === slug)
)

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.

How Draft Filtering Works Across Environments

Here's how it behaves:

  • Local dev (NODE_ENV=development) means show drafts
  • Vercel preview (NODE_ENV=production) means hide drafts
  • Production (NODE_ENV=production) means hide drafts

The simple check process.env.NODE_ENV === 'production' handles all cases:

  • Local dev gives isProduction = false, so show drafts
  • Production build gives isProduction = true, so hide drafts

Why I'm Okay With This Now

Honestly, defining every field in the schema felt annoying at first. More boilerplate, more config.

But then I thought about what would happen without it:

Typo draft: true as darft: true? Build succeeds. Draft goes live. You don't notice until someone emails you.

Inconsistent values like draft: yes or draft: 1? No type checking. Your filter logic breaks silently.

Rename draft to published in frontmatter but forget to update code? No build error. Filtering stops working.

Schema-first means:

  • Typos in field names → build fails immediately
  • Wrong types → Velite catches it at build time
  • Refactoring → TypeScript shows all usage

The 30 seconds to add a schema field saves hours of "why isn't this working?" debugging.

What About Vercel Deployments?

I initially thought about using VERCEL_ENV since I'm deploying to Vercel. That's what their docs recommend for detecting preview vs production deployments. But NODE_ENV works fine for my use case:

  • I don't need separate preview deployments with different draft visibility
  • NODE_ENV is already set by Vercel in production builds
  • I was already using NODE_ENV elsewhere in my config

If you need draft posts visible in preview deployments but hidden in production, VERCEL_ENV is the way to go:

const isProduction = process.env.VERCEL_ENV === 'production'

// Then use the same filtering logic
const filteredPosts = isProduction ? posts.filter(post => !post.draft) : posts

But for me, NODE_ENV is simpler and works just as well.

My Actual Testing Experience

I created a test draft post to verify everything worked:

---
title: "Test Draft"
slug: test-draft
date: 2025-01-03
draft: true
---

This should only appear in dev/preview.

First, I ran npm run dev. The draft showed up in my post list. I clicked through to /blog/test-draft and saw the content. Good.

Then I ran npm run build followed by npm run start to test production behavior.

Expected: Post appears in list, I can read it. Actual: Post is gone from the list. /blog/test-draft returns 404.

Exactly what I wanted. But I wanted to double-check the build output, so I opened .velite/posts.json. Wait. The test-draft entry was still there.

That's weird. I thought it would be filtered out by Velite?

Oh right. I'm filtering at the application level in posts.ts, not in the Velite config. Velite builds all posts, including drafts, into .velite/posts.json. Then my application code filters them out when retrieving posts.

I verified this by checking the RSS feed. In the Velite config's complete callback, I filter out drafts before adding to the feed. So draft posts don't show up in the RSS. But .velite/posts.json still contains everything.

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.

What I Learned

Velite doesn't auto-pick up frontmatter fields. You must define them in your schema first.

This felt like a constraint at first. Now I see it as a safety net:

  • Build-time validation catches mistakes
  • TypeScript knows what fields exist
  • Impossible states become... impossible

The schema isn't boilerplate. It's the contract between your content and your code.

That darft: true typo? Can't happen. Velite would ignore the field entirely, and TypeScript would show an error when you try to access post.darft.

Schema-first design saves future-you from current-you's mistakes.