Muhammad Tehseen Khan

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:

terminal
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 structure
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:

velite.config.ts
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:

.gitignore
# Velite output
.velite
public/static

Running Velite

Add the following scripts to your package.json:

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:

next.config.ts
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:

content/blog/hello-world.mdx
---
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:

src/components/mdx-content.tsx
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:

src/app/blog/[slug]/page.tsx
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:

src/app/blog/page.tsx
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:

  1. Type safety - Your content is fully typed, providing excellent IDE support and catching errors early
  2. Performance - Velite is fast and efficient, with incremental builds in development mode
  3. Developer experience - The schema-based approach makes it easy to define and validate your content
  4. 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!