Skip to main content
luke@terminal:/blog$ ls
luke@terminal:/blog$ cat setting-up-velite-nextjs-revised.md

Setting Up Velite with Next.js 16

Created: 2025-11-16 | 6 min read

Setting Up Velite with Next.js 16

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.

Here's what happened.


Where This Started

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.

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.

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.

So I spun up a fresh Next.js project using npx create-next-app@latest and selected: TypeScript, ESLint, Tailwind CSS, App Router, Turbopack.

Then I ran npm install velite and created a basic config.

This is what my setup looked like:

  • Node.js 20.10.0
  • Next.js 16.1.1 with App Router
  • React 19.2.0
  • Velite 0.3.0
  • TypeScript 5
  • Running on Ubuntu via WSL2 using Turbopack

File structure:

.
├── velite.config.js
├── next.config.ts
├── tsconfig.json
├── .velite/          # Generated by Velite
├── posts/
│   └── my-first-post.md
└── app/
    ├── page.tsx
    └── blog/
        └── [slug]/
            └── page.tsx

The First Problem: Imports Don't Work

I installed Velite, created velite.config.js, ran npx velite, and tried to import posts:

import { posts } from 'velite';

But immediately encountered a problem: Error: The export posts was not found in module velite/dist/index.js

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?

Then I actually checked the Velite docs again. Oh—you import from the .velite folder that Velite generates, not the package itself.

So I tried:

import { posts } from './.velite';

That worked. But then I found the docs mentioned path aliases as a cleaner option. So I created one in tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"],
      "#site/content": ["./.velite"]
    }
  }
}

After restarting the dev server, I tested the path alias approach:

import { posts } from '#site/content';

That worked.


The Second Problem: Velite Won't Run

So I tried running Velite:

[VELITE] Patterns must be a string (non empty) or an array of strings

I checked my config—everything looked fine. I'd copied it straight from the Velite quick start docs, so how could it be wrong?

Then I noticed the docs had this others collection:

collections: {
  posts: { /* ... */ },
  others: {
    // other collection schema options
  }
}

The others 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.

Velite was complaining because the others collection didn't have a pattern field (like pattern: 'others/**/*.md'). Without a pattern, Velite doesn't know what files to include.

Deleted the others collection entirely. Ran npx velite again. This time: ✓ posts (1 documents).


The Third Problem: Missing Files Break Builds

Velite was running now. But looking at the Velite docs, I saw examples with cover and video fields. Figured I'd add those too—why not?

So I copied them into my schema:

cover: s.image(),
video: s.file(),

And put them in my test post:

---
title: My First Post
slug: my-first-post
date: 2025-11-16
---
cover: cover.jpg
video: video.mp4

But these files didn't exist. Velite errored out.

I should have started with a minimal post:

---
title: My First Post
slug: my-first-post
date: 2025-11-16
---
This is some markdown content.

Same pattern as before—the docs were showing me what's possible, not what I actually need. I made them optional in my schema:

cover: s.image().optional(),
video: s.file().optional(),

Updated the schema, ran npx velite, and it worked.


The Fourth Problem: Dynamic Routes 404ing

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.

I'd copied some example code from the docs for dynamic routes:

export default function PostPage({ params }: { params: { slug: string } }) {
  const post = posts.find((p) => p.slug === params.slug);
  // ...
}

TypeScript was complaining that params should be a Promise<{ slug: string }> instead of just { slug: string }. I ignored it at first because I assumed the example code was correct.

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.

Here's what happens without await: 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.

So I needed to await the params:

export default async function PostPage({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params;
  const post = posts.find((p) => p.slug === slug);
  // ..
}

After this change, my dynamic routes loaded. No more 404s.


The Fifth Problem: Watch Mode Just... Didn't Work

This one took me the longest. I copied the integration code from the Velite docs into next.config.ts:

const isDev = process.argv.indexOf('dev') !== -1
const isBuild = process.argv.indexOf('build') !== -1
if (!process.env.VELITE_STARTED && (isDev || isBuild)) {
  process.env.VELITE_STARTED = '1'
  import('velite').then(m => m.build({ watch: isDev, clean: !isDev }))
}

Started the dev server. No errors. But Velite never ran. I could edit markdown files all day—nothing happened.

Debugging the argv Issue

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 process.argv:

console.log('[DEBUG] isDev:', isDev, 'isBuild:', isBuild, 'argv:', process.argv)

Output showed both isDev and isBuild as false:

[DEBUG] isDev: false isBuild: false argv: [
  '/home/luke/.nvm/versions/node/v24.11.1/bin/node',
  '/home/luke/workspace/lukemanning-site/node_modules/next/dist/server/lib/start-server.js'
]

So 'dev' and 'build' weren't in argv at all.

The Velite integration code checks if 'dev' is in process.argv to decide whether to start watch mode. Since Turbopack's start-server.js doesn't include 'dev' in argv, that condition was never true—so Velite never started.

Next.js was using start-server.js internally instead of a simple command-line argument.

Claude suggested checking process.env.NODE_ENV 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.

Changed it:

const isDev = process.env.NODE_ENV === 'development'
const isBuild = process.env.NODE_ENV === 'production'
if (!process.env.VELITE_STARTED && (isDev || isBuild)) {
  process.env.VELITE_STARTED = '1'
  import('velite').then(m => m.build({ watch: isDev, clean: !isDev }))
}

Started the dev server again:

[VELITE] Building...
✓ posts (1 documents)
[VELITE] Watching for changes...

Edited a markdown file:

[VELITE] Rebuilding...
✓ posts (1 documents)

Finally worked.

I think what's happening is:

  • Next.js 16 with Turbopack changed how the dev server starts internally
  • It uses start-server.js as an intermediary instead of a simple next dev command
  • The NODE_ENV environment variable is more reliable because it's set by Next.js regardless of how the server starts

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 process.argv workaround—but that workaround doesn't actually work with Next.js 16's Turbopack setup.

My understanding could be wrong—I didn't dig into the Next.js source code—but the NODE_ENV approach has been rock-solid for two weeks now.


Where I Landed

After all that mess, here's where I ended up:

velite.config.js:

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(),
          cover: s.image().optional(),
          video: s.file().optional(),
          metadata: s.metadata(),
          excerpt: s.excerpt(),
          content: s.markdown()
        })
        .transform(data => ({ ...data, permalink: `/blog/${data.slug}` }))
    }
  }
})

next.config.ts:

import type { NextConfig } from "next";

const isDev = process.env.NODE_ENV === 'development'
const isBuild = process.env.NODE_ENV === 'production'
if (!process.env.VELITE_STARTED && (isDev || isBuild)) {
  process.env.VELITE_STARTED = '1'
  import('velite').then(m => m.build({ watch: isDev, clean: !isDev }))
}

const nextConfig: NextConfig = {};

export default nextConfig;

Just the paths section from tsconfig.json:

"paths": {
  "@/*": ["./*"],
  "#site/content": ["./.velite"]
}