Sub Category

Latest Blogs
Build a Custom Blog Editor in Next.js: React Hook Form, Markdown, and File Upload (2025 Guide)

Build a Custom Blog Editor in Next.js: React Hook Form, Markdown, and File Upload (2025 Guide)

Introduction: Why Build a Custom Blog Editor in Next.js?

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.


Step 1: Setting Up Your Next.js Project

Start with a Next.js project to leverage its server-side rendering for SEO.

Installation Steps

1. Create a Next.js App

Initialize a new project:

npx create-next-app@latest my-blog
cd my-blog
npm run dev

2. Install Dependencies

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.

Troubleshooting Tips

  • Node Version Issues: Ensure Node.js is updated (node -v should show v18+).
  • Dependency Conflicts: Run npm install --force if issues arise.
  • Dev Server: Access at http://localhost:3000.

Step 2: Building the BlogForm Component

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;

Key Features of the BlogForm Component

  • 1, Dynamic Slug Generation: Slugs are auto-generated from the title using slugify, with manual override for flexibility.
  • 2. Markdown Editor Integration: The @uiw/react-md-editor provides a rich text editor with live preview and code syntax highlighting.
  • 3, Form Validation: React Hook Form validates inputs, ensuring required fields are filled.
  • 4. Tag Management: Dynamically add or remove tags for better content categorization.
  • 5. File Upload Handling: Supports cover image uploads via FormData for seamless backend integration.

"Need a Custom CMS?: GitNexa builds tailored content solutions with Next.js. Get a free consultation."

Step 3: Enhancing the Editor with Custom Features

To make the editor production-ready:

1. Custom Markdown Rendering

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;

2. CodeBlock Component

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;

3. Type Definitions

Define BlogFormValues for TypeScript:

export interface BlogFormValues {
  title: string;
  slug: string;
  description: string;
  blogCategory: string;
  tags: string[];
  newTag: string;
  coverImage: File | null;
}

Step 4: Integrating with a Backend

To save blog posts, integrate with a backend (e.g., MongoDB via Mongoose):

1. API Route in Next.js

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 });
  });
}

2. Submit Form to API

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);
  }
};

Why Choose GitNexa for CMS Development?

Building a custom blog editor is complex, but GitNexa simplifies it with:

  • 1. Custom CMS Solutions: Scalable editors with Next.js and Markdown.
  • 2. Website Development: SEO-optimized platforms with React and Next.js.
  • 3. Backend Integration: Secure APIs with Node.js and MongoDB.

Book Free Consultency to streamline your content workflow, or contact us for a tailored solution."

Conclusion

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.

Share this article:
Comments

Loading comments...

Write a comment
Article Tags
Next.jsblog editorReact Hook Form tutorialMarkdown editor Next.jsfile upload Next.jscustom blog CMSNext.js content editorbuild blog editor React