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:
javascript
const 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");
exportfunctionsaveFileLocally(data) {
returnnewPromise((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 hyphenslet 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 filenamelet 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 = newDate(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 fieldconst id = shortUUID.generate();
// Format tags and categories for metadataconst formattedTags = tags
.split(", ")
.map((tag) =>`"${tag.trim()}"`)
.join(", ");
const formattedCategories = categories
.map((category) =>`"${category}"`)
.join(", ");
// Construct the file contentconst 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 cachegeneratePostsCache();
// Open the file in VS Codeexec(`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:
javascript
import 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
*/exportfunctiongeneratePostsCache() {
const postsDirectory = path.join(process.cwd(), "data/posts");
const fileNames = fs
.readdirSync(postsDirectory)
.filter(
(fileName) => !fileName.startsWith(".") && fileName.endsWith(".mdx")
);
const currentDate = startOfDay(newDate()); // Get the start of the current dayconst posts = fileNames
.map((fileName) => {
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data: frontMatter } = matter(fileContents);
const postDate = startOfDay(newDate(frontMatter.date)); // Get the start of the post's date// Skip future-dated posts and include posts for the current dayif (postDate > currentDate) {
returnnull;
}
return {
slug: fileName.replace(".mdx", ""),
...frontMatter,
};
})
.filter(Boolean); // Filter out null values representing future-dated postsconst 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:
javascript
import { 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
*/exportasyncfunctionPOST(req) {
if (req.method === "POST") {
try {
const data = await req.json();
const filePath = saveFileLocally(data); // Save the file and regenerate the cachereturnnewResponse(JSON.stringify({ filePath }), {
headers: { "content-type": "application/json" },
});
} catch (error) {
console.error("Error:", error);
returnnewResponse(
JSON.stringify({ message: "Error processing request" }),
{
status: 500,
headers: { "content-type": "application/json" },
}
);
}
} else {
returnnewResponse(
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";
importReact, { 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";
importDatePickerFieldfrom"@/components/date-picker";
import { MultiSelect } from"@/components/rs-multi-select";
// Define the schema for form validation using Zodconst 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(),
});
exportfunctionCreatePostForm({ post }) {
const [selectedValue, setSelectedValue] = useState("blog");
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: post || {
date: newDate(),
type: "blog",
title: "",
description: "",
content: "",
categories: ["Web Development"],
tags: "",
},
});
const authorName = "O Wolfson";
const router = useRouter();
// Handle form submissionasyncfunctiononSubmit(values) {
const endpoint = post ? "/api/update-file" : "/api/save-file-locally";
const submissionData = {
...values,
author: authorName,
id: post ? post.id : uuidv4(),
};
try {
const response = awaitfetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submissionData),
});
if (!response.ok) thrownewError("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 deletionasyncfunctionhandleDelete() {
try {
const response = awaitfetch("/api/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: post.path }),
});
if (!response.ok) thrownewError("Network response was not ok");
console.log("File deleted successfully");
router.push("/home");
} catch (error) {
console.error("Error deleting file:", error);
}
}
return (
<Form {...form}><formonSubmit={form.handleSubmit(onSubmit)}className="space-y-6">
{/* Post Type Field */}
<FormFieldcontrol={form.control}name="type"render={({field }) => (
<FormItem><FormLabel>Post Type</FormLabel><SelectonValueChange={field.onChange}defaultValue={field.value}value={selectedValue}
><FormControl><SelectTriggerclassName="w-[180px]"><SelectValueplaceholder="Select post type" /></SelectTrigger></FormControl><SelectContent><SelectItemvalue="blog">Blog</SelectItem><SelectItemvalue="project">Project</SelectItem></SelectContent></Select><FormMessage /></FormItem>
)}
/>
{/* Date Field */}
<FormFieldcontrol={form.control}name="date"render={({field }) => (
<FormItemclassName="flex flex-col"><FormLabelclassName="font-semibold text-md">Date</FormLabel><DatePickerFieldfield={field} /><FormMessage /></FormItem>
)}
/>
{/* Title Field */}
<FormFieldcontrol={form.control}name="title"render={({field }) => (
<FormItem><FormLabel>Post Title</FormLabel><FormControl><Inputplaceholder="Title" {...field} /></FormControl><FormMessage /></FormItem>
)}
/>
{/* Description Field */}
<FormFieldcontrol={form.control}name="description"render={({field }) => (
<FormItem><FormLabel>Description</FormLabel><FormControl><Textareaplaceholder="Description" {...field} /></FormControl><FormMessage /></FormItem>
)}
/>
{/* Content Field */}
<FormFieldcontrol={form.control}name="content"render={({field }) => (
<FormItem><FormLabel>Content</FormLabel><FormControl><Textareaid="content"className="h-[300px]"placeholder="Content"
{...field}
/></FormControl><FormMessage /></FormItem>
)}
/>
{/* Categories Field */}
<FormFieldcontrol={form.control}name="categories"render={({field }) => (
<FormItem><FormLabel>Categories</FormLabel><FormControl><MultiSelectselectedCategories={field.value}setSelectedCategories={field.onChange}
/></FormControl><FormMessage /></FormItem>
)}
/>
{/* Tags Field */}
<FormFieldcontrol={form.control}name="tags"render={({field }) => (
<FormItem><FormLabel>Tags</FormLabel><FormControl><Inputplaceholder="Enter tags (comma separated)" {...field} /></FormControl><FormMessage /></FormItem>
)}
/>
<Buttontype="submit">{post ? "Update" : "Create"}</Button>
{post && <ButtononClick={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.
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!