<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Adejoh Ojochenemi Sunday</title>
    <description>The latest articles on DEV Community by Adejoh Ojochenemi Sunday (@adejohos).</description>
    <link>https://dev.to/adejohos</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F700137%2F30b9bbe6-4106-4539-8bc5-658458de7c19.jpeg</url>
      <title>DEV Community: Adejoh Ojochenemi Sunday</title>
      <link>https://dev.to/adejohos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adejohos"/>
    <language>en</language>
    <item>
      <title>Crafting a Powerful Rich Text Editor with Novel, Next.js, Shadcn/ui, Zod, and Prisma</title>
      <dc:creator>Adejoh Ojochenemi Sunday</dc:creator>
      <pubDate>Sun, 19 Jan 2025 08:13:21 +0000</pubDate>
      <link>https://dev.to/adejohos/crafting-a-powerful-rich-text-editor-with-novel-nextjs-shadcnui-zod-and-prisma-3ppc</link>
      <guid>https://dev.to/adejohos/crafting-a-powerful-rich-text-editor-with-novel-nextjs-shadcnui-zod-and-prisma-3ppc</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools Used&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://novel.sh/" rel="noopener noreferrer"&gt;&lt;strong&gt;Novel:&lt;/strong&gt;&lt;/a&gt; A robust, customizable WYSIWYG editor.&lt;br&gt;
&lt;a href="https://www.prisma.io/" rel="noopener noreferrer"&gt;&lt;strong&gt;Prisma:&lt;/strong&gt;&lt;/a&gt; A powerful ORM for database management.&lt;br&gt;
&lt;a href="https://ui.shadcn.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;Shadcn/ui:&lt;/strong&gt;&lt;/a&gt; A library for beautifully styled UI components.&lt;br&gt;
&lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;&lt;strong&gt;Next.js:&lt;/strong&gt;&lt;/a&gt; A full-stack framework for React applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Required knowledge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Basic familiarity with Next.js and React.&lt;/li&gt;
&lt;li&gt;Understanding of Prisma for database handling.&lt;/li&gt;
&lt;li&gt;Node.js and npm/yarn installed.&lt;/li&gt;
&lt;/ul&gt;



&lt;p&gt;&lt;strong&gt;Lets Get Started&lt;/strong&gt;&lt;br&gt;
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. &lt;code&gt;src/app/projects/create/_components/project-form.tsx&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"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(() =&amp;gt; {
    if (state?.message) {
      toast(state.message, {
        icon: state.success ? (
          &amp;lt;CheckCircle2 className="h-4 w-4" /&amp;gt;
        ) : (
          &amp;lt;TriangleAlert className="h-4 w-4" /&amp;gt;
        ),
      });
    }
  }, [state]);
  return (
    &amp;lt;&amp;gt;
      &amp;lt;form action={formAction} className="space-y-4" autoComplete="on"&amp;gt;
        &amp;lt;div className="space-y-2"&amp;gt;
          &amp;lt;Label htmlFor="title"&amp;gt;Title&amp;lt;/Label&amp;gt;
          &amp;lt;Input
            placeholder="Project title"
            id="title"
            name="title"
            autoComplete="title"
            aria-describedby="title-error"
            required
            disabled={isPending}
            className={state.errors?.title ? "border-red-500" : ""}
          /&amp;gt;
          {state.errors?.title &amp;amp;&amp;amp; (
            &amp;lt;p id="title-error" className="text-sm text-red-500"&amp;gt;
              {state.errors.title[0]}
            &amp;lt;/p&amp;gt;
          )}
        &amp;lt;/div&amp;gt;
        &amp;lt;div className="space-y-2"&amp;gt;
          &amp;lt;Label htmlFor="summary"&amp;gt;Summary&amp;lt;/Label&amp;gt;
          &amp;lt;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 &amp;amp;&amp;amp; "border-red-500"
            )}
          /&amp;gt;
          {state.errors?.summary &amp;amp;&amp;amp; (
            &amp;lt;p id="summary-error" className="text-sm text-red-500"&amp;gt;
              {state.errors.summary[0]}
            &amp;lt;/p&amp;gt;
          )}
        &amp;lt;/div&amp;gt;
        &amp;lt;div className="space-y-2"&amp;gt;
          &amp;lt;Label htmlFor="content"&amp;gt;Description&amp;lt;/Label&amp;gt;
          &amp;lt;Textarea
            placeholder="Full description of your project..."
            id="content"
            name="content"
            autoComplete="content"
            aria-describedby="content"
            disabled={isPending}
            className="resize-none"
          /&amp;gt;
        &amp;lt;/div&amp;gt;

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

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Form submission with Nextjs ServerActions&lt;br&gt;
&lt;code&gt;src/actions/action.ts&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"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&amp;lt;ActionResponse&amp;gt; {
  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("/");
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A zod schema for form validation&lt;br&gt;
&lt;code&gt;src/lib/schema.ts&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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&amp;lt;typeof ProjectSchema&amp;gt;;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prisma schema&lt;br&gt;
&lt;code&gt;prisma/schema.prisma&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Product type&lt;br&gt;
&lt;code&gt;src/lib/types.ts&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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[];
  };
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;&amp;lt;Textarea/&amp;gt;&lt;/code&gt; to a rich text Novel &lt;code&gt;&amp;lt;Editor/&amp;gt;&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Novel Editor&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Novel Editor is a rich text editor (WYSIWYG), it supports formatting options like bold, italic, headings, lists, links, and more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm i novel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get code solution from the example documentation on &lt;a href="https://github.com/steven-tey/novel/tree/main/examples/novel-tailwind/src/components" rel="noopener noreferrer"&gt;github&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Copy the editor folder and its content to your components folder, file structure should something look like this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi1borwdvev8tnttuypes.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi1borwdvev8tnttuypes.png" alt="Image description" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can customize your editor components however you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Form Novel Integration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The next step would be changing the &lt;code&gt;&amp;lt;Textarea/&amp;gt;&lt;/code&gt; component to the Novel &lt;code&gt;&amp;lt;Editor /&amp;gt;&lt;/code&gt; we have in the content field.&lt;/p&gt;

&lt;p&gt;The steps to achieving this on the &lt;code&gt;src/app/projects/create/project-form.tsx&lt;/code&gt; include;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Import dependencies, dynamically import the Editor component to ensure it only runs on the client side
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import dynamic from "next/dynamic";

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


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Set defaultValue (a placeholder)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;export const defaultValue = {
  type: "doc",
  content: [
    {
      type: "paragraph",
      content: [
        {
          type: "text",
          text: 'Type " / " for commands or start writing...',
        },
      ],
    },
  ],
};

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Set a state to hold the value for the content field.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const [content, setContent] = useState&amp;lt;string&amp;gt;("");
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;On the content field, add the &lt;code&gt;&amp;lt;Editor /&amp;gt;&lt;/code&gt; 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.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div className="space-y-2"&amp;gt;
          &amp;lt;Label htmlFor="content"&amp;gt;Description&amp;lt;/Label&amp;gt;
          &amp;lt;div className="prose prose-stone"&amp;gt;
            &amp;lt;Editor initialValue={defaultValue} onChange={setContent} /&amp;gt;

            &amp;lt;Input
              id="content"
              type="hidden"
              name="content"
              value={content}
            /&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Styling the Editor&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Import tailwind typography, then add the plugin to your &lt;code&gt;tailwind.config.ts&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm i @tailwindcss/typography
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/typography'),
    // ...
  ],
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy &lt;code&gt;prosemirror.css&lt;/code&gt; and novel highlight styles &lt;code&gt;globals.css&lt;/code&gt; from the &lt;a href="https://github.com/steven-tey/novel/tree/main/examples/novel-tailwind/src/app" rel="noopener noreferrer"&gt;github&lt;/a&gt; and paste them into your application. You can always customize this.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Rendering content to the UI&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We successfully created a new project that is saved in our database, how do we render it to the ui?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First we get the project id/slug
&lt;code&gt;src/app/projects/[slug]/page.tsx&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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&amp;lt;{ slug: string }&amp;gt; }) =&amp;gt; {
  const slug = (await params).slug;

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

  if (!project) {
    return notFound;
  }

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

export default Page;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;src/app/projects[slug]/_components/project-info.tsx&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
export default function ProjectInfo({ project }: ProjectInfoProps) {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;h2 className="font-bold text-2xl"&amp;gt;{project?.title}&amp;lt;/h2&amp;gt;
      &amp;lt;p className="max-w-3xl"&amp;gt;
        &amp;lt;em&amp;gt;{project?.summary}&amp;lt;/em&amp;gt;
      &amp;lt;/p&amp;gt;

      &amp;lt;div className="prose prose-stone"&amp;gt;
        &amp;lt;div dangerouslySetInnerHTML={{ __html: project.content }} /&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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!&lt;/p&gt;

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

&lt;p&gt;The completed code can be found &lt;a href="https://github.com/AdejohOS/novel-editor-article.git" rel="noopener noreferrer"&gt;here&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>nextjs</category>
      <category>prisma</category>
    </item>
  </channel>
</rss>
