2024-08-27 Web Development
Building a Content Management System for Static MDX Files
By O Wolfson
Let's create a content management system (CMS) for our MDXBlog, where blog posts are stored as static files.
We'll cover and handling form submissions for new blog entries, saving posts to the file system, generating a cache for efficient data retrieval.
Note: this interface will only be relevant in your development environment as you are saving files to the local file system.
Our CMS will:
- Create and save blog posts as MDX files locally.
- Regenerate a cache of posts to improve performance.
- Handle form submissions for creating new posts.
- List blog post titles on the home page with edit and delete functionality.
Caching is crucial for enhancing performance and reducing the load on the file system. When dealing with static files, reading and parsing each file on every request can be inefficient, especially as the number of posts grows. By generating a cache, we can quickly access metadata and content without repeatedly accessing the file system.
Components Involved
- MDX File Handling: A function to save form data as MDX files.
- Cache Generation: A script to create a cache of the posts.
- API Endpoints: Endpoints to handle POST requests for saving, updating, and deleting files.
- Form Component: A React component to capture user input for new blog posts.
- Home Screen Component: A React component to list blog post titles.
1. Saving Form Data as MDX Files
We need a function to save the submitted form data as an MDX file. This function will:
- Format the data properly.
- Ensure unique filenames by checking for existing files.
- Regenerate the posts cache after saving the file.
Here's the saveFileLocally
function:
javascriptconst fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const { generatePostsCache } = require("./posts-utils");
/**
* Save form data as a local MDX file
* @param {Object} data - The form data to save
* @returns {string} - The generated slug for the new post
*/
const fs = require("node:fs");
const path = require("node:path");
const { exec } = require("node:child_process");
const { generatePostsCache } = require("./posts-utils");
const shortUUID = require("short-uuid");
export function saveFileLocally(data) {
return new Promise((resolve, reject) => {
const { date, savedFilename, title, categories, tags, ...rest } = data;
const projectRoot = process.cwd();
// Sanitize the title to remove special characters and replace spaces with hyphens
let filename = `${title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")}.mdx`;
const slug = `${title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")}`;
let filePath = path.join(projectRoot, "content/posts", filename);
// Check if the file already exists and create a unique filename
let counter = 1;
while (fs.existsSync(filePath)) {
filename = `${title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")}-${counter}.mdx`;
filePath = path.join(projectRoot, "content/posts", filename); // Update filePath
counter++;
}
const currentDate = new Date(data.date);
const formattedDate = `${currentDate.getFullYear()}-${String(
currentDate.getMonth() + 1
).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`;
// Generate a short UUID for the id field
const id = shortUUID.generate();
// Format tags and categories for metadata
const formattedTags = tags
.split(", ")
.map((tag) => `"${tag.trim()}"`)
.join(", ");
const formattedCategories = categories
.map((category) => `"${category}"`)
.join(", ");
// Construct the file content
const fileContent = [
"export const metadata = {",
` id: "${id}",`, // Added id field
` type: "blog",`,
` title: "${title}",`,
` author: "${data.author}",`,
` publishDate: "${formattedDate}",`,
` description: "${data.description}",`,
` categories: [${formattedCategories}],`,
` tags: [${formattedTags}]`,
"};",
"",
`${data.content}`,
].join("\n");
// Write the file
fs.writeFile(filePath, fileContent, (err) => {
if (err) {
console.error("Error writing file:", err);
reject(err);
} else {
console.log("File saved to", filePath);
// Regenerate posts cache
generatePostsCache();
// Open the file in VS Code
exec(`code "${filePath}"`, (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
return;
}
console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);
});
resolve(slug);
}
});
});
}
2. Generating the Cache
The cache script reads all MDX files, extracts the necessary metadata, and writes it to a JSON file. This cached data can then be quickly accessed, improving performance.
Here's the cachePosts.js
script:
javascriptimport fs from "fs";
import path from "path";
import matter from "gray-matter";
import { startOfDay } from "date-fns";
/**
* Generate a cache of all posts
* @returns {Array} - An array of post metadata
*/
export function generatePostsCache() {
const postsDirectory = path.join(process.cwd(), "data/posts");
const fileNames = fs
.readdirSync(postsDirectory)
.filter(
(fileName) => !fileName.startsWith(".") && fileName.endsWith(".mdx")
);
const currentDate = startOfDay(new Date()); // Get the start of the current day
const posts = fileNames
.map((fileName) => {
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data: frontMatter } = matter(fileContents);
const postDate = startOfDay(new Date(frontMatter.date)); // Get the start of the post's date
// Skip future-dated posts and include posts for the current day
if (postDate > currentDate) {
return null;
}
return {
slug: fileName.replace(".mdx", ""),
...frontMatter,
};
})
.filter(Boolean); // Filter out null values representing future-dated posts
const cachePath = path.join(process.cwd(), "cache/posts.json");
fs.writeFileSync(cachePath, JSON.stringify(posts, null, 2));
return posts;
}
3. Handling POST Requests
We need an API endpoint to handle the POST requests from our form. This endpoint will call the saveFileLocally
function and respond with the path of the saved file or an error message.
Here's the implementation of the POST handler:
javascriptimport { saveFileLocally } from "@/lib/save-file-locally";
/*
* Handle POST requests to save form data as an MDX file
* @param {Request} req - The request object
* @returns {Response} - The response object
*/
export async function POST(req) {
if (req.method === "POST") {
try {
const data = await req.json();
const filePath = saveFileLocally(data); // Save the file and regenerate the cache
return new Response(JSON.stringify({ filePath }), {
headers: { "content-type": "application/json" },
});
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ message: "Error processing request" }),
{
status: 500,
headers: { "content-type": "application/json" },
}
);
}
} else {
return new Response(
JSON.stringify({ message: "Only POST requests are accepted" }),
{
headers: { "content-type": "application/json" },
}
);
}
}
4. Creating the Form Component
We'll create a form component that captures the user's input and sends it to our API endpoint. We'll use react-hook-form
for form handling and validation, and zod
for schema validation. Additionally, we'll add functionality to update and delete posts. FYI we are using Tailwind CSS for styling and shadcn/ui for most of the UI components.
Here's the CreatePostForm
component code:
jsx"use client";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { v4 as uuidv4 } from "uuid";
import { useRouter } from "next/navigation";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import DatePickerField from "@/components/date-picker";
import { MultiSelect } from "@/components/rs-multi-select";
// Define the schema for form validation using Zod
const formSchema = z.object({
date: z.date(),
type: z.string().optional(),
title: z.string().min(3, { message: "Title must be at least 3 characters." }),
description: z
.string()
.min(15, { message: "Description must be at least 15 characters." }),
content: z
.string()
.min(2, { message: "Content must be at least 2 characters." }),
categories: z.array(z.string()).nonempty(),
tags: z.string().optional(),
});
export function CreatePostForm({ post }) {
const [selectedValue, setSelectedValue] = useState("blog");
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: post || {
date: new Date(),
type: "blog",
title: "",
description: "",
content: "",
categories: ["Web Development"],
tags: "",
},
});
const authorName = "O Wolfson";
const router = useRouter();
// Handle form submission
async function onSubmit(values) {
const endpoint = post ? "/api/update-file" : "/api/save-file-locally";
const submissionData = {
...values,
author: authorName,
id: post ? post.id : uuidv4(),
};
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submissionData),
});
if (!response.ok) throw new Error("Network response was not ok");
const result = await response.json();
console.log("Success:", result);
form.reset();
router.push(`/blog/${result.filePath}`);
} catch (error) {
console.error("Error:", error);
}
}
// Handle file deletion
async function handleDelete() {
try {
const response = await fetch("/api/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: post.path }),
});
if (!response.ok) throw new Error("Network response was not ok");
console.log("File deleted successfully");
router.push("/home");
} catch (error) {
console.error("Error deleting file:", error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Post Type Field */}
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Post Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={selectedValue}
>
<FormControl>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select post type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="blog">Blog</SelectItem>
<SelectItem value="project">Project</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Date Field */}
<FormField
control={form.control}
name="date"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="font-semibold text-md">Date</FormLabel>
<DatePickerField field={field} />
<FormMessage />
</FormItem>
)}
/>
{/* Title Field */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Post Title</FormLabel>
<FormControl>
<Input placeholder="Title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Description Field */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Description" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Content Field */}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<Textarea
id="content"
className="h-[300px]"
placeholder="Content"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Categories Field */}
<FormField
control={form.control}
name="categories"
render={({ field }) => (
<FormItem>
<FormLabel>Categories</FormLabel>
<FormControl>
<MultiSelect
selectedCategories={field.value}
setSelectedCategories={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Tags Field */}
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input placeholder="Enter tags (comma separated)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">{post ? "Update" : "Create"}</Button>
{post && <Button onClick={handleDelete}>Delete</Button>}
</form>
</Form>
);
}
5. Creating the Home Screen Component
We'll create a home screen component that fetches the cached posts and displays their titles. Clicking on a title will open the post in the CreatePostForm
.
HomeScreen.jsx
jsximport React, { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
export default function HomeScreen() {
const [posts, setPosts] = useState([]);
const router = useRouter();
useEffect(() => {
async function fetchPosts() {
const res = await fetch("/cache/posts.json");
const data = await res.json();
setPosts(data);
}
fetchPosts();
}, []);
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Blog Posts</h1>
<ul className="space-y-2">
{posts.map((post) => (
<li key={post.slug} className="flex justify-between items-center">
<Link href={`/edit/${post.slug}`}>
<a className="text-blue-500 hover:underline">{post.title}</a>
</Link>
</li>
))}
</ul>
</div>
);
}
6. Handling Delete Requests
We'll create an API endpoint to handle the deletion of posts.
deleteFile.js
javascriptconst fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const { generatePostsCache } = require("./posts-utils");
export async function POST(req) {
if (req.method === "POST") {
try {
const { path: filePath } = await req.json();
const fullPath = path.join(process.cwd(), "data/posts", filePath);
exec(`rm "${fullPath}"`, (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
return new Response(
JSON.stringify({ message: "Error deleting file" }),
{
status: 500,
headers: { "content-type": "application/json" },
}
);
}
console.log("File deleted successfully");
generatePostsCache();
return new Response(JSON.stringify({ message: "File deleted" }), {
headers: { "content-type": "application/json" },
});
});
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ message: "Error processing request" }),
{
status: 500,
headers: { "content-type": "application/json" },
}
);
}
} else {
return new Response(
JSON.stringify({ message: "Only POST requests are accepted" }),
{
headers: { "content-type": "application/json" },
}
);
}
}
Conclusion
By following these steps, we've built a content management system for our blog. Users can submit new blog posts through a form, which are then saved as MDX files on the server. The posts cache is regenerated to ensure quick access to the latest posts, enhancing performance. Additionally, we added a home screen to list the titles of the blog posts, along with functionality to update and delete posts. This setup leverages the power of Next.js, MDX, and React to create a seamless and dynamic content creation experience in our local development environment.
If you have any questions or need further assistance, feel free to reach out!