
Creating a blog editor is a powerful feature for content-driven websites, especially for businesses offering CMS solutions like GitNexa. Using Next.js, a React framework renowned for SEO and performance, you can build a robust editor with React Hook Form for form management, **Markdown **for rich text, and file uploads for cover images. This setup is ideal for developers and businesses aiming to streamline content creation in 2025.
In this Next.js blog editor tutorial, we’ll guide you through building a custom blog editor with dynamic slugs, Markdown support, and file uploads. Whether you’re enhancing your CMS or creating a blog platform, this guide provides a scalable foundation. Need a custom CMS? Contact GitNexa for expert website development and CMS solutions.
Benefits of a Custom Blog Editor
- Flexibility: Tailor the editor to your CMS needs.
- SEO-Friendly: Next.js’s SSR/SSG ensures content is crawlable.
- User-Friendly: Markdown simplifies rich text editing.
- Scalability: Integrate with databases like MongoDB or headless CMS.
Why Next.js? Powering sites like GitNexa’s, Next.js offers hybrid rendering and developer-friendly features.
Start with a Next.js project to leverage its server-side rendering for SEO.
Initialize a new project:
npx create-next-app@latest my-blog
cd my-blog
npm run dev
Add required packages for form handling, Markdown, and slug generation:
npm install react-hook-form slugify @uiw/react-md-editor rehype-rewrite
Pro Tip: Use TypeScript (enabled during create-next-app) for type safety, as we do at GitNexa for robust apps.
The BlogForm component handles form inputs, Markdown editing, dynamic slugs, tags, and file uploads. Below is the optimized code, with improvements for clarity and functionality.
import React, { useState, useEffect } from 'react';
import MDEditor from '@uiw/react-md-editor';
import { useForm, Controller } from 'react-hook-form';
import slugify from 'slugify';
import { BlogFormValues } from '@/types/BlogFormValues';
const categories = ['Technology', 'Science', 'Health', 'Lifestyle'];
interface BlogFormProps {
onSubmit: (data: FormData) => void;
}
const BlogForm: React.FC<BlogFormProps> = ({ onSubmit }) => {
const { register, handleSubmit, control, watch, setValue, formState: { errors } } = useForm<BlogFormValues>({
defaultValues: {
title: '',
slug: '',
description: '',
blogCategory: '',
tags: [],
newTag: '',
coverImage: null,
},
});
const [tags, setTags] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSlugTouched, setIsSlugTouched] = useState(false);
const title = watch('title');
const slug = watch('slug');
const newTag = watch('newTag');
// Auto-generate slug from title
useEffect(() => {
if (!isSlugTouched && title) {
const generatedSlug = slugify(title, { lower: true, strict: true });
setValue('slug', generatedSlug);
}
}, [title, isSlugTouched, setValue]);
// Handle manual slug changes
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setIsSlugTouched(true);
setValue('slug', slugify(e.target.value, { lower: true, strict: true }));
};
// Add new tag
const handleAddTag = () => {
if (newTag && !tags.includes(newTag)) {
const updatedTags = [...tags, newTag];
setTags(updatedTags);
setValue('tags', updatedTags);
setValue('newTag', '');
}
};
// Remove tag
const handleRemoveTag = (tagToRemove: string) => {
const updatedTags = tags.filter((tag) => tag !== tagToRemove);
setTags(updatedTags);
setValue('tags', updatedTags);
};
// Handle file upload
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null;
setValue('coverImage', file);
};
// Form submission
const handleFormSubmit = async (data: Omit<BlogFormValues, 'newTag'>) => {
setIsSubmitting(true);
try {
const formData = new FormData();
formData.append('title', data.title);
formData.append('slug', data.slug);
formData.append('description', data.description);
formData.append('blogCategory', data.blogCategory);
formData.append('tags', JSON.stringify(data.tags));
if (data.coverImage) {
formData.append('coverImage', data.coverImage as File);
}
await onSubmit(formData);
} catch (error) {
console.error('Submission error:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6 max-w-2xl mx-auto p-4">
<div>
<label className="block font-medium">Title</label>
<input
{...register('title', { required: 'Title is required' })}
className="w-full p-2 border rounded"
placeholder="Enter blog title"
/>
{errors.title && <p className="text-red-500">{errors.title.message}</p>}
</div>
<div>
<label className="block font-medium">Slug</label>
<input
{...register('slug', { required: 'Slug is required' })}
className="w-full p-2 border rounded"
placeholder="Slug will be auto-generated"
onChange={handleSlugChange}
onFocus={() => setIsSlugTouched(true)}
/>
{errors.slug && <p className="text-red-500">{errors.slug.message}</p>}
</div>
<div>
<label className="block font-medium">Category</label>
<select
{...register('blogCategory', { required: 'Category is required' })}
className="w-full p-2 border rounded"
>
<option value="">Select a category</option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
{errors.blogCategory && <p className="text-red-500">{errors.blogCategory.message}</p>}
</div>
<div>
<label className="block font-medium">Description (Markdown)</label>
<Controller
name="description"
control={control}
rules={{ required: 'Description is required' }}
render={({ field }) => (
<MDEditor
{...field}
value={field.value}
onChange={field.onChange}
preview="edit"
className="min-h-[300px]"
/>
)}
/>
{errors.description && <p className="text-red-500">{errors.description.message}</p>}
</div>
<div>
<label className="block font-medium">Tags</label>
<div className="flex gap-2">
<input
{...register('newTag')}
className="w-full p-2 border rounded"
placeholder="Add a tag"
/>
<button
type="button"
onClick={handleAddTag}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Add Tag
</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-2 px-2 py-1 bg-gray-200 rounded"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</span>
))}
</div>
</div>
<div>
<label className="block font-medium">Cover Image</label>
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="w-full p-2 border rounded"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:bg-gray-400"
>
{isSubmitting ? 'Submitting...' : 'Submit Post'}
</button>
</form>
);
};
export default BlogForm;
"Need a Custom CMS?: GitNexa builds tailored content solutions with Next.js. Get a free consultation."
To make the editor production-ready:
Create a CustomMDEditor.tsx for tailored Markdown rendering:
import React from 'react';
import MDEditor from '@uiw/react-md-editor';
import { CodeBlock } from './CodeBlock';
interface CustomMDEditorProps {
value: string;
onChange: (value: string) => void;
}
const CustomMDEditor: React.FC<CustomMDEditorProps> = ({ value, onChange }) => {
return (
<MDEditor
value={value}
onChange={onChange}
preview="edit"
previewOptions={{
components: {
code: CodeBlock,
},
}}
className="min-h-[300px] border rounded"
/>
);
};
export default CustomMDEditor;
Improve code rendering:
import React from 'react';
const CodeBlock: React.FC<{ children: string; className?: string }> = ({ children, className }) => {
const language = className?.replace(/language-/, '') || 'text';
return (
<pre className="bg-gray-800 text-white p-4 rounded">
<code className={`language-${language}`}>{children}</code>
</pre>
);
};
export default CodeBlock;
Define BlogFormValues for TypeScript:
export interface BlogFormValues {
title: string;
slug: string;
description: string;
blogCategory: string;
tags: string[];
newTag: string;
coverImage: File | null;
}
To save blog posts, integrate with a backend (e.g., MongoDB via Mongoose):
Create pages/api/posts.ts:
import type { NextApiRequest, NextApiResponse } from 'next';
import formidable from 'formidable';
import fs from 'fs/promises';
import path from 'path';
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
const form = formidable({ multiples: true });
const uploadDir = path.join(process.cwd(), 'public/uploads');
await fs.mkdir(uploadDir, { recursive: true });
form.uploadDir = uploadDir;
form.parse(req, async (err, fields, files) => {
if (err) {
return res.status(500).json({ message: 'Error parsing form' });
}
const { title, slug, description, blogCategory, tags } = fields;
const coverImage = files.coverImage as formidable.File;
// Save to database (e.g., MongoDB)
// Example: await Blog.create({ title, slug, description, blogCategory, tags: JSON.parse(tags as string), coverImage: coverImage?.newFilename });
res.status(201).json({ message: 'Post created', data: fields });
});
}
Update BlogForm to call the API:
const handleFormSubmit = async (data: Omit<BlogFormValues, 'newTag'>) => {
setIsSubmitting(true);
try {
const formData = new FormData();
formData.append('title', data.title);
formData.append('slug', data.slug);
formData.append('description', data.description);
formData.append('blogCategory', data.blogCategory);
formData.append('tags', JSON.stringify(data.tags));
if (data.coverImage) {
formData.append('coverImage', data.coverImage as File);
}
const response = await fetch('/api/posts', {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Failed to submit');
console.log('Post created:', await response.json());
} catch (error) {
console.error('Submission error:', error);
} finally {
setIsSubmitting(false);
}
};
Building a custom blog editor is complex, but GitNexa simplifies it with:
Book Free Consultency to streamline your content workflow, or contact us for a tailored solution."
This Next.js blog editor tutorial equips you to build a powerful content creation tool with React Hook Form, Markdown, and file uploads. Extend it with backend integration, authentication, or advanced styling for a production-ready CMS.
At GitNexa, we specialize in custom CMS and website development. Ready to elevate your content platform? Let’s talk and bring your vision to life.
Loading comments...