Bunny Honey ClubBunny Honey/blog
Subscribe
← back to indexblog / engineering / building-a-production-mdx-blog-with-nextjs-16-and-velite
Engineering

Building a production MDX blog with Next.js 16 and Velite

How we shipped a production MDX blog on Next.js 16 with Velite: the content layer, typed frontmatter, and why Velite vs Contentlayer was a short debate.

A
ArthurFounder, Bunny Honey Club AI
publishedNov 04, 2025
read6 min
Building a production MDX blog with Next.js 16 and Velite

A month after we moved our blog off Webflow we were ready to post the technical piece about what replaced it. The first draft assumed Contentlayer; the published version doesn't. Between those two drafts we migrated the content layer twice,

A month after we moved our blog off Webflow we were ready to post the technical piece about what replaced it. The first draft assumed Contentlayer; the published version doesn't. Between those two drafts we migrated the content layer twice, pinned Next.js 16 the week it released, and learned more about MDX compilation pipelines than anyone should have to. If you're building a production MDX blog on Next.js 16 in late 2025, the stack that works without drama is Velite on the content side, rehype-pretty-code on the syntax side, and a small custom MDX runtime you evaluate at render. This piece is the full setup — the Velite config, the Next 16 wiring, the Velite vs Contentlayer decision, and the component runtime — so you can skip the forty hours we spent finding it.

Why Velite, not Contentlayer

The short version: Contentlayer's maintenance story wobbled through most of 2024 and never quite recovered. It still works — we started our rewrite on it — but the compatibility matrix with React 19 and Next 15 was always a half-step behind, and we ran into one edge case per afternoon. Velite, by contrast, is a single maintainer's project that has stayed small and kept shipping.

~400lines of config
11 scold build
80posts in corpus
0Velite issues blocking ship

The deeper reason is architectural. Contentlayer wants to generate a package at build time — it produces a typed module you import. Velite produces a directory of JSON and JS module strings. The Velite output is inert data; your app decides what to do with it. That shape suits a Next.js 16 App Router project because the data can be imported server-side with no bundler coupling, and because MDX compiled to a JS module string is a primitive you can evaluate in whatever runtime you want.

The Contentlayer approach wasn't wrong — it just depended on a toolchain we don't control. Velite's approach gives us fewer moving parts and fewer upstream dependencies that can break on a Tuesday.

The Velite config, annotated

Here is our entire content schema. Copy it.

// velite.config.ts
import { defineConfig, defineCollection, s } from 'velite'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypePrettyCode from 'rehype-pretty-code'
import remarkGfm from 'remark-gfm'
 
const posts = defineCollection({
  name: 'Post',
  pattern: 'posts/**/*.mdx',
  schema: s
    .object({
      slug: s.path(),
      title: s.string().max(120),
      description: s.string().max(280),
      date: s.isodate(),
      updated: s.isodate().optional(),
      tags: s.array(s.string()).default([]),
      author: s.string().default('arthur'),
      cover: s.string().optional(),
      draft: s.boolean().default(false),
      accent: s.enum(['honey', 'bunny', 'mint', 'lilac']).default('honey'),
      featured: s.boolean().default(false),
      pillar: s.boolean().default(false),
      faq: s
        .array(s.object({ question: s.string(), answer: s.string() }))
        .optional(),
      metadata: s.metadata(),   // computed: reading time, word count
      excerpt: s.excerpt({ length: 240 }),
      toc: s.toc(),
      body: s.mdx(),
    })
    .transform((data) => ({
      ...data,
      slug: data.slug.replace(/^posts\//, ''),
      permalink: `/posts/${data.slug.replace(/^posts\//, '')}`,
    })),
})
 
export default defineConfig({
  root: 'content',
  output: { data: '.velite', clean: true },
  collections: { posts },
  mdx: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [
      rehypeSlug,
      [rehypePrettyCode, { theme: 'github-dark', keepBackground: false }],
      [rehypeAutolinkHeadings, { behavior: 'wrap' }],
    ],
  },
})

Three things to notice. s.object is a Zod schema, so every frontmatter field is validated on build; the build fails if a post is missing cover or has a typo in accent. s.metadata, s.excerpt, and s.toc are computed fields — you don't write them, Velite derives them. That means our post pages can render a table of contents or a reading-time badge without any runtime work. s.mdx() compiles the body to a JS module string, which is the key piece that makes the runtime work.

The MDX runtime, in 25 lines

The compiled body is a string of JavaScript that, when evaluated against the React JSX runtime, returns a component. We run it through a memoized evaluator so the identity is stable across renders.

// src/components/mdx/mdx-content.tsx
import * as runtime from 'react/jsx-runtime'
import { Callout, Stat, Pullquote, FAQ, Takeaways } from './components'
 
type MDXModule = { default: React.ComponentType<{ components?: object }> }
 
const cache = new Map<string, React.ComponentType<{ components?: object }>>()
 
function getMDXComponent(code: string) {
  let C = cache.get(code)
  if (!C) {
    C = (new Function(code)({ ...runtime }) as MDXModule).default
    cache.set(code, C)
  }
  return C
}
 
export function MDXContent({ code }: { code: string }) {
  const Component = getMDXComponent(code)
  return <Component components={{ Callout, Stat, Pullquote, FAQ, Takeaways }} />
}

This is the canonical Velite pattern and it's load-bearing. The new Function call evaluates the compiled module string; the { ...runtime } argument provides the JSX runtime the module was compiled against; the cache ensures that the component identity doesn't churn on every render.

Wiring Velite into Next.js 16

Next.js 16 defaults to Turbopack for both dev and build. Velite runs before Turbopack, as a separate build step, so there is no plugin integration to worry about. We wire it into two npm scripts:

// package.json — scripts, trimmed.
{
  "scripts": {
    "predev": "velite --watch &",
    "dev": "next dev",
    "prebuild": "velite build",
    "build": "next build",
    "start": "next start"
  }
}

The predev line uses & to background the watcher so next dev starts in the same terminal. In practice we run them in two panes — Velite's errors are easier to read when they're not interleaved with Next's. The output ends up in .velite/, which we import from server components:

// src/lib/posts.ts
import { posts as allPosts } from '#site/content'
// Velite aliases '#site/content' → '.velite' in tsconfig paths.
 
export const getPosts = () =>
  allPosts
    .filter((p) => !p.draft)
    .sort((a, b) => b.date.localeCompare(a.date))
 
export const getPost = (slug: string) =>
  getPosts().find((p) => p.slug === slug)

The #site/content alias is Velite's convention. It's a virtual module that resolves to .velite/index.js; we add it to tsconfig.json paths and everything downstream gets types for free.

Wait, the build fails if I forget the cover? Good. I want the build to fail if I forget the cover. I've been asking for this for three years.

a long-suffering teammate, looking at the Velite config

The component library you actually need

A production MDX blog needs about six components, not sixty. Our library:

  • <Stat items> — a grid of value/label pairs. Used for inline numbers that want attention.
  • <Pullquote cite> — a typographic blockquote with a citation.
  • <Callout variant title> — info/warn/success/note tinted boxes.
  • <FAQ items> — a styled <dl> for the article's FAQ section, mirrored into frontmatter for JSON-LD.
  • <Takeaways items> — the summary box at the end. LLMs retrieve from document ends.
  • <Gallery items> — a small image grid for anything that isn't the cover.

Everything else is a normal HTML tag styled by the .prose-bhc class. The discipline is important: every component you add is a new thing a writer has to remember and a new surface that can break. We keep the list short on purpose.

// src/components/mdx/callout.tsx — as short as it gets.
type Variant = 'info' | 'warn' | 'success' | 'note'
 
const tint: Record<Variant, { ring: string; bg: string }> = {
  info:    { ring: '#9FD8C4', bg: 'rgba(159,216,196,0.06)' },
  warn:    { ring: '#E8C064', bg: 'rgba(232,192,100,0.06)' },
  success: { ring: '#9FD8C4', bg: 'rgba(159,216,196,0.06)' },
  note:    { ring: '#F5E6C8', bg: 'rgba(245,230,200,0.06)' },
}
 
export function Callout({
  variant = 'note', title, children,
}: { variant?: Variant; title?: string; children: React.ReactNode }) {
  const v = tint[variant]
  return (
    <aside
      className="my-7 rounded-xl border p-5"
      style={{ borderColor: v.ring, background: v.bg }}
      role="note"
    >
      {title && <p className="mono-eyebrow" style={{ color: v.ring }}>{title}</p>}
      {children}
    </aside>
  )
}

Three things Velite doesn't solve

Images. Velite doesn't do image optimization. We let Next's <Image> component handle that, with covers stored as SVGs generated from a small script, because SVGs are text and diff nicely in PRs.

Search. Velite doesn't ship with a search index. For a blog under a hundred posts, browser-side <dl> search over titles and tags is enough. For larger corpora, we'd add a tiny Meilisearch instance; we haven't needed it.

Drafts. Velite has a draft: boolean field but no preview mode. We branch-deploy drafts on Vercel and share preview URLs directly. Works fine; not fancy.

None of these are Velite's job. The point of a tight content layer is that it doesn't try to be everything.

What this stack costs us, on the month

Zero dollars. The Velite dependency is free. The Next.js deployment is on Vercel's Hobby tier because the blog is under their limits. The whole thing ships for the cost of Vercel's Pro tier for other reasons, which we already pay for our marketing site. The migration that preceded this, which we wrote up separately, paid for itself in saved Webflow subscriptions inside two months.

The stack we keep recommending — Next.js 16 App Router, Velite, TypeScript strict, Tailwind — is the one we'd pick again tomorrow. It is not the flashiest stack. It is the one that, six weeks after shipping, we still haven't had to think about.

— share
— keep reading

Three more from the log.