DEV Community

Cover image for Crafting a Powerful Rich Text Editor with Novel, Next.js, Shadcn/ui, Zod, and Prisma
Adejoh Ojochenemi Sunday
Adejoh Ojochenemi Sunday

Posted on

Crafting a Powerful Rich Text Editor with Novel, Next.js, Shadcn/ui, Zod, and Prisma

Introduction

Building a rich text editor for modern web applications requires a balance of functionality, scalability, and design. With the right tools, you can create an editor that is intuitive for users while being robust and maintainable for developers.

In this article, we'll walk through the process of creating a powerful rich text editor using Novel, a customizable WYSIWYG editor; Next.js, a React-based framework; shadcn/ui, for beautifully styled UI components; Zod, for schema validation; and Prisma, for seamless database integration. By the end, you'll have a feature-rich editor perfect for applications like blogs, CMS platforms, or note-taking tools.

Tools Used

Novel: A robust, customizable WYSIWYG editor.
Prisma: A powerful ORM for database management.
Shadcn/ui: A library for beautifully styled UI components.
Next.js: A full-stack framework for React applications.

Prerequisites

Required knowledge:

  • Basic familiarity with Next.js and React.
  • Understanding of Prisma for database handling.
  • Node.js and npm/yarn installed.

Lets Get Started
So we have a project form with three fields (title, summary, content) that after validation submits data to our database using server actions with react's useActionState and correctly handles errors. src/app/projects/create/_components/project-form.tsx

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { CheckCircle2, Loader2, TriangleAlert } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useActionState, useEffect } from "react";
import { createProjectAction } from "@/actions/action";
import { ActionResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
import { toast } from "sonner";

const initialState: ActionResponse = {
  success: false,
  message: "",
};

export default function ProjectForm() {
  const [state, formAction, isPending] = useActionState(
    createProjectAction,
    initialState
  );

  useEffect(() => {
    if (state?.message) {
      toast(state.message, {
        icon: state.success ? (
          <CheckCircle2 className="h-4 w-4" />
        ) : (
          <TriangleAlert className="h-4 w-4" />
        ),
      });
    }
  }, [state]);
  return (
    <>
      <form action={formAction} className="space-y-4" autoComplete="on">
        <div className="space-y-2">
          <Label htmlFor="title">Title</Label>
          <Input
            placeholder="Project title"
            id="title"
            name="title"
            autoComplete="title"
            aria-describedby="title-error"
            required
            disabled={isPending}
            className={state.errors?.title ? "border-red-500" : ""}
          />
          {state.errors?.title && (
            <p id="title-error" className="text-sm text-red-500">
              {state.errors.title[0]}
            </p>
          )}
        </div>
        <div className="space-y-2">
          <Label htmlFor="summary">Summary</Label>
          <Textarea
            placeholder="Give a brief summary"
            id="summary"
            name="summary"
            autoComplete="summary"
            aria-describedby="summary-error"
            required
            minLength={50}
            maxLength={500}
            disabled={isPending}
            className={cn(
              `resize-none`,
              state.errors?.summary && "border-red-500"
            )}
          />
          {state.errors?.summary && (
            <p id="summary-error" className="text-sm text-red-500">
              {state.errors.summary[0]}
            </p>
          )}
        </div>
        <div className="space-y-2">
          <Label htmlFor="content">Description</Label>
          <Textarea
            placeholder="Full description of your project..."
            id="content"
            name="content"
            autoComplete="content"
            aria-describedby="content"
            disabled={isPending}
            className="resize-none"
          />
        </div>

        <div className="flex justify-end space-x-3">
          <Button type="button" variant="outline" disabled={isPending}>
            Cancel
          </Button>
          <Button
            type="submit"
            className="flex items-center space-x-3"
            disabled={isPending}
          >
            {isPending && <Loader2 className="size-4 animate-spin" />}
            Create
          </Button>
        </div>
      </form>
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

Form submission with Nextjs ServerActions
src/actions/action.ts

"use server";

import { prisma } from "@/lib/prisma";
import { ProjectSchema } from "@/lib/schema";
import { ActionResponse, ProjectType } from "@/lib/types";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

function createSlug(title: "string): string {"
  return title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)+/g, "");
}

export async function createProjectAction(
  prevState: ActionResponse | null,
  formData: FormData
): Promise<ActionResponse> {
  try {
    const rawData: ProjectType = {
      title: "formData.get(\"title\") as string,"
      summary: formData.get("summary") as string,
      content: formData.get("content") as string,
    };

    const validatedData = ProjectSchema.safeParse(rawData);

    if (!validatedData.success) {
      return {
        success: false,
        message: "Invalid form fields",
        errors: validatedData.error.flatten().fieldErrors,
      };
    }

    const { title, summary, content } = validatedData.data;
    const slug = createSlug(title);
    await prisma.project.create({
      data: {
        title,
        slug,
        summary,
        content,
      },
    });

    return {
      success: true,
      message: "Project created successfully!",
    };
  } catch (error) {
    console.log(error);
    return {
      success: false,
      message: "An unexpected error occurred",
    };
  } finally {
    revalidatePath("/");
    redirect("/");
  }
}

Enter fullscreen mode Exit fullscreen mode

A zod schema for form validation
src/lib/schema.ts

import { z } from "zod";

export const ProjectSchema = z.object({
  title: "z.string().min(1, \"Minimum of one character allowed\"),"
  summary: z
    .string()
    .min(50, "Summary should not be less than 50 characters")
    .max(500, "Summary should be less than 500 characters"),
  content: z.string(),
});
export type ProjectValues = z.infer<typeof ProjectSchema>;

Enter fullscreen mode Exit fullscreen mode

Prisma schema
prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Project {
  id        String  @id @default(cuid())
  title     String
  slug      String @unique
  summary   String @db.Text
  content   String @db.Text

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Enter fullscreen mode Exit fullscreen mode

Product type
src/lib/types.ts

export interface ProjectType {
  title: "string;"
  slug?: string;
  summary: string;
  content: string;
  createdAt?: Date;
}

export interface ActionResponse {
  success: boolean;
  message: string;
  errors?: {
    [K in keyof ProjectType]?: string[];
  };
}
Enter fullscreen mode Exit fullscreen mode

Putting all these together gives us a fully functional form with a title, summary, and content field. But we would like to extend the capabilities of our form by changing the content field from <Textarea/> to a rich text Novel <Editor/>.


Novel Editor

Novel Editor is a rich text editor (WYSIWYG), it supports formatting options like bold, italic, headings, lists, links, and more.

npm i novel
Enter fullscreen mode Exit fullscreen mode

Get code solution from the example documentation on github.

Copy the editor folder and its content to your components folder, file structure should something look like this.

Image description

You can customize your editor components however you want.

Form Novel Integration

The next step would be changing the <Textarea/> component to the Novel <Editor /> we have in the content field.

The steps to achieving this on the src/app/projects/create/project-form.tsx include;

  • Import dependencies, dynamically import the Editor component to ensure it only runs on the client side
import dynamic from "next/dynamic";

const Editor = dynamic(() => import("@/components/editor/editor"), {
  ssr: false,
});


Enter fullscreen mode Exit fullscreen mode
  • Set defaultValue (a placeholder)
export const defaultValue = {
  type: "doc",
  content: [
    {
      type: "paragraph",
      content: [
        {
          type: "text",
          text: 'Type " / " for commands or start writing...',
        },
      ],
    },
  ],
};

Enter fullscreen mode Exit fullscreen mode
  • Set a state to hold the value for the content field.
const [content, setContent] = useState<string>("");
Enter fullscreen mode Exit fullscreen mode
  • On the content field, add the <Editor /> component and set props initialValue, onChange to their respective value. The hidden input field is correctly set up to pass on field value to the form.
<div className="space-y-2">
          <Label htmlFor="content">Description</Label>
          <div className="prose prose-stone">
            <Editor initialValue={defaultValue} onChange={setContent} />

            <Input
              id="content"
              type="hidden"
              name="content"
              value={content}
            />
          </div>
        </div>
Enter fullscreen mode Exit fullscreen mode

Styling the Editor

Import tailwind typography, then add the plugin to your tailwind.config.ts file:

npm i @tailwindcss/typography
Enter fullscreen mode Exit fullscreen mode
module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/typography'),
    // ...
  ],
}
Enter fullscreen mode Exit fullscreen mode

Copy prosemirror.css and novel highlight styles globals.css from the github and paste them into your application. You can always customize this.


Rendering content to the UI

We successfully created a new project that is saved in our database, how do we render it to the ui?

  • First we get the project id/slug src/app/projects/[slug]/page.tsx
import { Button } from "@/components/ui/button";
import { prisma } from "@/lib/prisma";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";

import { notFound } from "next/navigation";
import ProjectInfo from "./_components/project-info";

const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
  const slug = (await params).slug;

  const project = await prisma.project.findUnique({
    where: {
      slug: slug,
    },
  });

  if (!project) {
    return notFound;
  }

  return (
    <section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-4 ">
      <div>
        <Link href="/">
          <Button className="flex items-center space-x-2" variant="ghost">
            <ArrowLeft className="size-4" />
            Back to projects
          </Button>
        </Link>
      </div>
      <ProjectInfo project={project} />
    </section>
  );
};

export default Page;

Enter fullscreen mode Exit fullscreen mode

src/app/projects[slug]/_components/project-info.tsx


export default function ProjectInfo({ project }: ProjectInfoProps) {
  return (
    <div>
      <h2 className="font-bold text-2xl">{project?.title}</h2>
      <p className="max-w-3xl">
        <em>{project?.summary}</em>
      </p>

      <div className="prose prose-stone">
        <div dangerouslySetInnerHTML={{ __html: project.content }} />
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Conclusion

Building a rich text editor with tools like Novel, Next.js, Shadcn/ui, Zod, and Prisma showcases the power of combining modern, developer-friendly technologies. With Novel’s customizable editor, Shadcn/ui’s elegant components, Zod’s validation, and Prisma’s seamless database integration, you can create a robust and scalable editing experience.

This approach ensures a great user experience and maintains code quality and performance. Whether you're building a blog, CMS, or collaborative tool, this stack provides a solid foundation for your project. The possibilities for further enhancements, like real-time collaboration or advanced formatting options, are endless, so get creative and take your editor to the next level!

We now have a fully functional Novel editor in our project form.
If you have trouble setting this up, please don't hesitate to reach out.

The completed code can be found here

Top comments (1)

Collapse
 
samsonamosu profile image
CurDEX App

This is an impressive hack. Is it possible to have a script for installing/importing all the stacks?