Software Engineer
Setting Up Velite for Next.js Content Management
If you're building a Next.js application and looking for a type-safe, efficient way to manage your content, Velite is an excellent choice. Unlike traditional CMS solutions, Velite works directly with your local content files (Markdown, MDX, YAML, etc.) and generates type-safe content that integrates seamlessly with your codebase.
In this guide, I'll walk you through setting up Velite for a Next.js project, focusing on MDX support for blog posts.
What is Velite?
Velite is a content management framework that:
- Provides type-safe content schemas using Zod
- Supports various content formats (Markdown, MDX, YAML, JSON, etc.)
- Handles asset optimization and management
- Integrates seamlessly with modern frameworks like Next.js
- Offers excellent developer experience with fast build times
Prerequisites
Before we start, make sure you have:
- Node.js version 18.17 or higher
- A Next.js project set up (or create a new one)
Installation
Let's start by installing Velite as a development dependency:
npm install velite -D
# or with yarn
yarn add velite -D
# or with pnpm
pnpm add velite -D
Project Structure
We'll organize our content like this:
project-root/
├── content/
│ └── blog/
│ ├── first-post.mdx
│ └── second-post.mdx
├── public/
├── src/
├── velite.config.ts
└── package.json
Configuration
Create a velite.config.ts
file in your project root:
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypePrettyCode from 'rehype-pretty-code'
import rehypeSlug from 'rehype-slug'
import { defineCollection, defineConfig, s } from 'velite'
// Helper function to compute additional fields
const computedFields = <T extends { permalink: string }>(data: T) => ({
...data,
slug: data.permalink.split('/').slice(1).join('/'),
})
// Define the posts collection
const posts = defineCollection({
name: 'Post',
pattern: 'blog/**/*.mdx', // Matches all MDX files in the blog directory
schema: s
.object({
title: s.string().max(99),
description: s.string().max(999).optional(),
date: s.isodate(),
published: s.boolean().default(true),
tags: s.array(s.string()).optional(),
body: s.mdx(), // Compiles MDX content
permalink: s.path(), // Generates a path based on the file path
metadata: s.metadata(), // Extracts metadata like reading time
})
.transform(computedFields),
})
// Define the projects collection (if needed)
const projects = defineCollection({
name: 'Project',
pattern: 'projects/**/*.mdx',
schema: s
.object({
title: s.string().max(99),
description: s.string().max(999),
date: s.isodate(),
published: s.boolean().default(true),
body: s.mdx(),
permalink: s.path(),
})
.transform(computedFields),
})
// Export the Velite configuration
export default defineConfig({
root: 'content', // Content root directory
collections: { posts, projects }, // Your content collections
output: {
data: '.velite', // Output directory for the generated data
assets: 'public/static', // Output directory for the static assets
base: '/static/', // Base path for the static assets
name: '[name]-[hash:6].[ext]', // Output file naming pattern
clean: true, // Clean the output directory before building
},
mdx: {
rehypePlugins: [
rehypeSlug, // Adds IDs to headings
[rehypePrettyCode, { theme: 'github-dark' }], // Syntax highlighting
[
rehypeAutolinkHeadings, // Adds links to headings
{
behavior: 'wrap',
properties: {
className: ['subheading-anchor'],
ariaLabel: 'Link to section',
},
},
],
],
remarkPlugins: [], // Add any remark plugins here
},
})
Ignoring Generated Files
Add the following to your .gitignore
file to prevent committing generated files:
# Velite output
.velite
public/static
Running Velite
Add the following scripts to your package.json
:
{
"scripts": {
"velite": "velite",
"velite:dev": "velite dev"
}
}
Integrating with Next.js
There are several ways to integrate Velite with Next.js. Here's the recommended approach using the Next.js config file:
import type { NextConfig } from 'next';
const isDev = process.argv.indexOf('dev') !== -1;
const isBuild = process.argv.indexOf('build') !== -1;
const buildVelite = async () => {
if (!process.env.VELITE_STARTED && (isDev || isBuild)) {
process.env.VELITE_STARTED = '1';
const { build } = await import('velite');
await build({ watch: isDev, clean: !isDev });
}
};
buildVelite().catch(console.error);
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default nextConfig;
This approach automatically runs Velite when you start your Next.js development server or build your project.
Creating Content
Now, let's create a sample blog post:
---
title: Hello World
description: My first blog post using Velite and Next.js
date: 2024-08-14
published: true
tags: ['nextjs', 'velite', 'mdx']
---
# Hello World
This is my first blog post using **Velite** and *Next.js*.
## Features
- Type-safe content
- MDX support
- Fast build times
<Callout>
This is a custom component that will be injected at render time.
</Callout>
Creating the MDX Content Component
To render MDX content, we need to create a component that can process the compiled MDX code:
import * as runtime from 'react/jsx-runtime'
import type { ComponentPropsWithoutRef } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { cn } from '@/utils'
// Custom components that will be available in MDX files
const components = {
Image,
h1: (props: ComponentPropsWithoutRef<"h1">) => (
<h1 className="text-3xl font-bold mt-8 mb-4" {...props} />
),
h2: (props: ComponentPropsWithoutRef<"h2">) => (
<h2 className="text-2xl font-semibold mt-6 mb-3" {...props} />
),
// Add more component overrides as needed
a: ({ href, ...props }: ComponentPropsWithoutRef<"a">) => {
if (href?.startsWith("/")) {
return <Link href={href} {...props} />
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
{...props}
/>
)
},
// Add your custom components here
Callout: ({ children }: { children: React.ReactNode }) => (
<div className="border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20 p-4 my-4">
{children}
</div>
),
}
// Function to parse the MDX code
const useMDXComponent = (code: string) => {
const fn = new Function(code)
return fn({ ...runtime }).default
}
interface MDXContentProps {
code: string
className?: string
}
// MDX content component
export function MDXContent({ code, className }: MDXContentProps) {
const Component = useMDXComponent(code)
return (
<div className={cn("prose prose-slate dark:prose-invert max-w-none", className)}>
<Component components={components} />
</div>
)
}
Creating the Blog Post Page
Now, let's create a page to display our blog posts:
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { MDXContent } from '@/components/mdx-content'
import { posts } from '@/.velite'
// Helper function to get a post by slug
const getPostBySlug = (slug: string) => {
return posts.find((p) => p.slug === slug)
}
// Generate metadata for the page
export function generateMetadata({
params,
}: {
params: { slug: string }
}): Metadata {
const post = getPostBySlug(params.slug)
if (!post) return {}
const { title, description, tags } = post
return {
title: `${title} | My Blog`,
description,
keywords: tags,
}
}
// Blog post page component
export default function BlogPostPage({
params,
}: {
params: { slug: string }
}) {
const post = getPostBySlug(params.slug)
if (!post) return notFound()
return (
<article className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-500 mb-8">
{new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
{post.tags && (
<div className="mt-2 flex gap-2">
{post.tags.map((tag) => (
<span key={tag} className="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{tag}
</span>
))}
</div>
)}
</div>
<MDXContent code={post.body} />
</article>
)
}
Creating the Blog Index Page
Finally, let's create an index page to list all our blog posts:
import type { Metadata } from 'next'
import Link from 'next/link'
import { posts } from '@/.velite'
export const metadata: Metadata = {
title: 'Blog | My Website',
description: 'Read my latest blog posts',
}
export default function BlogPage() {
// Sort posts by date (newest first)
const sortedPosts = [...posts]
.filter(post => post.published)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid gap-8">
{sortedPosts.map((post) => (
<article key={post.slug} className="border-b pb-8">
<h2 className="text-2xl font-semibold mb-2">
<Link href={`/blog/${post.slug}`} className="hover:underline">
{post.title}
</Link>
</h2>
<div className="text-gray-500 mb-2">
{new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
{post.description && (
<p className="text-gray-700 dark:text-gray-300">{post.description}</p>
)}
<div className="mt-4">
<Link
href={`/blog/${post.slug}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Read more →
</Link>
</div>
</article>
))}
</div>
</div>
)
}
Conclusion
Velite provides a powerful, type-safe way to manage content in your Next.js applications. With its MDX support, you can create rich, interactive content that integrates seamlessly with your React components.
Some key benefits of using Velite include:
- Type safety - Your content is fully typed, providing excellent IDE support and catching errors early
- Performance - Velite is fast and efficient, with incremental builds in development mode
- Developer experience - The schema-based approach makes it easy to define and validate your content
- Flexibility - Support for various content formats and customization options
By following this guide, you've set up a complete blog system with Velite and Next.js. You can extend this foundation to support more content types, add more custom components, or integrate with other tools in your ecosystem.
Happy coding!