How to build a blog with Next.js and Velite.js

Written by
auth-avtHieu.BuiMinh
Published onNovember 18, 2024
Views0
Comments0

Preface

Velite is a powerful configuration tool for building type-safe data layer for Next.js with Zod schema, allowing developers to manage their apps with streamlined settings. Here, we will explore how to set up Velite for a Next.js project and enhance the development process.

The packages to be used:

  • next Next.js framework
  • velite Handling and loading mdx content
  • tailwindcss A utility-first CSS framework packed with classes

Why Use Velite in Next.js?

  • Efficiency: Simplifies configuration management with structured files.
  • Scalability: Ideal for projects with complex routing or multilingual setups.
  • Developer-Friendly: Easy to integrate with minimal setup.

Step-by-Step Guide

Getting started with Next js

1. Create a Next js project

Use the following commands to create a Next.js project and navigate to it:

bash
npx create-next-app@latest my-next-app
# or
cd my-next-app

2. Run Development Server

Start the development server:

bash
npm run dev

Visit http://localhost:3000.

Read the Next.js Documentation:

Velite configuration

1. Install Velite

To get started, ensure you have Node.js installed. Then, install Velite using npm or yarn:

bash
npm install velite -D
# or
yarn add velite -D

2. Folder structured

page.tsx
page.tsx
layout.tsx

heading.tsx
mdx.tsx

hello-world.mdx

...velite auto genarating json files
blog.json
package.json
next.config.mjs
velite.config.ts

3. In next.config.mjs

Config for Velite plugins with webpack

next.config.mjs
import { build } from 'velite'
 
/** @type {import('next').NextConfig} */
// eslint-disable-next-line import/no-anonymous-default-export
export default {
	webpack: (config) => {
		config.plugins.push(new VeliteWebpackPlugin())
		return config
	},
}
 
class VeliteWebpackPlugin {
	static started = false
	constructor(/** @type {import('velite').Options} */ options = {}) {
		this.options = options
	}
	apply(/** @type {import('webpack').Compiler} */ compiler) {
		compiler.hooks.beforeCompile.tapPromise('VeliteWebpackPlugin', async () => {
			if (VeliteWebpackPlugin.started) return
			VeliteWebpackPlugin.started = true
			const dev = compiler.options.mode === 'development'
			this.options.watch = this.options.watch ?? dev
			this.options.clean = this.options.clean ?? !dev
			await build(this.options)
		})
	}
}

4. Velite configuration and Define Collections in velite.config.ts

After creating a velite.config.ts file in the root directory of your project to define collections config.

Velite uses velite.config.js/ts as the config file. You can see it in the root directory of your project. Now we can put our blog in to velite defineConfig function

velite.config.ts
import { defineCollection, defineConfig, s } from 'velite'
 
export const computedFields = <T extends { slug: string }>(data: T) => {
	return { ...data, slugAsParams: data.slug.split('/').slice(1).join('/') }
	// blog/hello-world => ['blog', 'hello-world'] => ['hello-world] => '/hello-world'
}
 
const posts = defineCollection({
	name: 'Post', // name of colection
	pattern: 'blog/**/*.mdx', // watch all mdx file in blog folder
	schema: s
		.object({
			id: s.string(),
			slug: s.path(), // return blog path
			title: s.string().max(999),
			date: s.isodate(),
			lastUpdated: s.isodate().optional(),
			metadata: s.metadata().optional(),
			description: s.string().max(999).optional(),
			published: s.boolean().default(true),
			hashTags: s
				.object({
					category: s.string(),
					tags: s.array(s.string()),
				})
				.optional(),
			body: s.mdx(), // mdx content
			toc: s.toc({ tight: true, ordered: true, maxDepth: 6 }), // toc stand for table-of-content
			//slugAsParams <=> needed transform
		})
		.transform(computedFields),
})
 
export default defineConfig({
	root: 'content',
	output: {
		data: '.velite',
		assets: 'public/static',
		base: '/static/',
		name: '[name]-[hash:6].[ext]',
		clean: true,
	},
	collections: { posts },
	mdx: {
		rehypePlugins: [],
		remarkPlugins: [],
	},
})

5. Create the first content 🔥

content/blog/hello-world.mdx
---
id: 'xxxxxxxx-xxxx-xxxx-...'
title: 'This is the first post'
date: '2024-11-18T00:00:00Z'
lastUpdated: '2024-11-18T00:00:00Z'
description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
published: true
hashTags: { category: 'Blog', tags: ['Lorem', 'Ipsum'] }
---
 
`##` Hello world
 
`###` This is my first post
 
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
 

After creating your content in hello-world.mdx, check your folder structured, you would see a folder named .velite, inside it you can see 2 files named index.d.ts and blog.json, that means ours hello-world.mdx have been parsed successfuly.

Inside components/mdx.tsx you can dynamically renders MDX content in a Next.js application by customizing specific HTML elements such as headings (h2, h3, etc.) and links (a) using React components, providing enhanced styling and functionality.

components/mdx.tsx
import Link from 'next/link'
import * as runtime from 'react/jsx-runtime'
 
import Heading from '@/components/heading'
 
const useMDXComponent = (code: string) => {
	const fn = new Function(code)
	return fn({ ...runtime }).default
}
 
const components = {
	// overwrite heading or other components here...
	h2: (props: React.ComponentPropsWithoutRef<'h2'>) => <Heading as="h2" {...props} />,
	h3: (props: React.ComponentPropsWithoutRef<'h3'>) => <Heading as="h3" {...props} />,
	h4: (props: React.ComponentPropsWithoutRef<'h4'>) => <Heading as="h4" {...props} />,
	h5: (props: React.ComponentPropsWithoutRef<'h5'>) => <Heading as="h5" {...props} />,
	h6: (props: React.ComponentPropsWithoutRef<'h6'>) => <Heading as="h6" {...props} />,
	a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
		const { children, href, ...rest } = props
 
		if (!href) {
			return (
				<span className="text-muted-foreground line-through transition-colors hover:text-foreground" {...rest}>
					{children}
				</span>
			)
		}
 
		return (
			<Link
				className="font-bold text-green-600 no-underline transition-colors hover:text-foreground hover:underline dark:text-green-400"
				href={href}
				{...rest}
			>
				{children}
			</Link>
		)
	},
}
 
interface MdxProps {
	code: string
}
 
export function MDXContent({ code }: MdxProps) {
	const Component = useMDXComponent(code)
	return <Component components={components} />
}

app/blog/page.tsx
import Link from 'next/link'
 
import { posts } from '@/.velite'
 
function BlogPage() {
	return (
		<div className="flex h-screen w-screen items-center justify-center">
			{posts.map((post) => {
				return (
					<Link key={post.id} href={post.slug}>
						<div className="flex h-[200px] w-[400px] flex-col justify-between rounded-md border p-4">
							<p className="text-md font-bold">{post.title}</p>
							<p className="text-sm">{post.description}</p>
							<span>{post.date}</span>
						</div>
					</Link>
				)
			})}
		</div>
	)
}
 
export default BlogPage

app/blog/[...slug]/page.tsx
import { notFound } from 'next/navigation'
 
import { posts } from '@/.velite'
import { MDXContent } from '@/components/mdx'
 
interface PostPageProps {
	params: Promise<{ slug: string[] }>
}
 
async function getPostFromParams(params: PostPageProps['params']) {
	const resolvedParams = await params
	const slug = resolvedParams.slug.join('/')
	const post = posts.find((post) => post.slugAsParams === slug)
 
	return post
}
 
export default async function BlogDetailPageView({ params }: PostPageProps) {
	const post = await getPostFromParams(params)
 
	if (!post || !post.published) {
		notFound()
	}
 
	return (
		<div className="relative flex justify-between gap-10">
			<article>
				<MDXContent code={post.body} />
			</article>
		</div>
	)
}

The @tailwindcss/typography plugin is a powerful tool in TailwindCSS that provides a set of pre-designed styles for rich text content. It's perfect for styling elements like blog posts, documentation, or any page with long-form content.

When installed using:

bash
npm i @tailwindcss/typography

You integrate this plugin into your Tailwind configuration file (tailwind.config.js or tailwind.config.ts). This enables you to apply sophisticated typography styles with minimal effort.

In the tailwind.config.ts file, you add the plugin:

tailwind.config.ts
const config: Config = {
	plugins: [require('@tailwindcss/typography')],
}

After this, you can use the prose class to style your blog post content. For example:

app/blog/[...slug]/page.tsx
export default async function BlogDetailPageView({ params }: PostPageProps) {
	const post = await getPostFromParams(params)
 
	if (!post || !post.published) {
		notFound()
	}
 
	return (
		<div className="relative flex justify-between gap-10">
			<article className="prose lg:prose-xl">
				<MDXContent code={post.body} />
			</article>
		</div>
	)
}

Final result

Your blog should now:

  • Dynamically render posts from .mdx files.
  • Be fully responsive and styled using TailwindCSS.
  • Include SEO-friendly metadata for better visibility on search engines.
  • Support features like categories, tags, and pagination.

Here's a preview of the final product:

first-post.png
first-post-detail.png

Conclusion

With Next.js and Velite.js, building a feature-rich, SEO-friendly blog is simple and efficient. This guide covered everything from setting up the project to deploying it live, ensuring you have a strong foundation for future enhancements.

As your blog scales, the flexibility of Velite allows you to add new features like multilingual support, custom layouts, or advanced analytics with ease.

Happy coding (づ ̄3 ̄)づ╭❤️~

Last updated: November 18, 2024