# Next.js Quill Editor

A Next.js Quill editor for the App Router and React 19: plain Quill 2 in a client component that server pages can render, with snow and bubble themes.

> This component is only available in **AdminKit PRO**. [Learn more](/pricing/#nextjs).

When people write more than a line, such as a reply or an article, they want bold text, lists and links. `QuillEditor` gives [AdminKit for Next.js](/nextjs/) a rich text editor for that, built on plain [Quill](https://quilljs.com/) 2 (`quill` 2.0.3). You get the text back as HTML.

AdminKit wraps Quill itself instead of using `react-quill`. `react-quill` calls `findDOMNode`, an API that React 19 removed, and Next.js 16 runs on React 19.

Quill needs the browser, so `QuillEditor` is a client component, a file that starts with `"use client"`. It does not need `next/dynamic`, though. On the server, and in the HTML files of the static export, it renders an empty editor with Quill's classes and the placeholder. So the page already has its final layout. In the browser, an effect loads Quill with `import("quill")` and turns that markup into the editor. Quill never runs on the server.

## Example

Pages in the App Router are server components, and they can only pass plain values to a client component. Without `onChange`, the props are strings and JSX, so a page can render the editor directly, as the editors demo page does. Separate the `ql-formats` groups with a space to keep the gaps of the toolbar.

```html
<div id="quill-example-toolbar">
  <span class="ql-formats">
    <button type="button" class="ql-bold" aria-label="Bold"></button>
    <button type="button" class="ql-italic" aria-label="Italic"></button>
    <button type="button" class="ql-underline" aria-label="Underline"></button>
  </span>
  <span class="ql-formats">
    <button type="button" class="ql-list" value="ordered" aria-label="Numbered list"></button>
    <button type="button" class="ql-list" value="bullet" aria-label="Bulleted list"></button>
  </span>
  <span class="ql-formats">
    <button type="button" class="ql-link" aria-label="Link"></button>
    <button type="button" class="ql-clean" aria-label="Clear formatting"></button>
  </span>
</div>
<div id="quill-example"></div>

<script>
new Quill("#quill-example", {
  theme: "snow",
  placeholder: "Write a reply",
  modules: { toolbar: "#quill-example-toolbar" }
});
</script>
```
```tsx code title="src/app/(dashboard)/forms-editors/page.tsx"
import { QuillEditor } from "@/components/plugins/quill-editor";
import { pageMetadata } from "@/lib/site";

export const metadata = pageMetadata("/forms-editors");

export default function Page() {
  return (
    <QuillEditor
      theme="snow"
      placeholder="Write a reply"
      toolbar={
        <>
          <span className="ql-formats">
            <button type="button" className="ql-bold" aria-label="Bold" />
            <button type="button" className="ql-italic" aria-label="Italic" />
            <button type="button" className="ql-underline" aria-label="Underline" />
          </span>{" "}
          <span className="ql-formats">
            <button type="button" className="ql-list" value="ordered" aria-label="Numbered list" />
            <button type="button" className="ql-list" value="bullet" aria-label="Bulleted list" />
          </span>{" "}
          <span className="ql-formats">
            <button type="button" className="ql-link" aria-label="Link" />
            <button type="button" className="ql-clean" aria-label="Clear formatting" />
          </span>
        </>
      }
    />
  );
}
```

Leave `toolbar` out and Quill builds the theme's default toolbar, or the one in `modules.toolbar`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `theme` | `"snow" \| "bubble"` | `"snow"` | A toolbar above the editor, or a tooltip on selected text |
| `toolbar` | `ReactNode` | | The toolbar markup: `ql-formats` groups of `ql-*` buttons and selects |
| `modules` | `Record<string, unknown>` | | [Quill modules](https://quilljs.com/docs/modules) |
| `placeholder` | `string` | | Text shown while the editor is empty |
| `defaultValue` | `string` | | The initial content, as HTML; also in the server HTML |
| `readOnly` | `boolean` | | Shows the content without editing |
| `onChange` | `(html: string, quill: Quill) => void` | | Called after every change, with the HTML and the Quill instance |

Quill reads `theme`, `modules`, `placeholder`, `defaultValue` and `readOnly` once, when it starts. `className` and other props go to `.ql-container`. The snow editor area is at least 15rem high. The component is in `src/components/plugins/quill-editor.tsx`.

## Bubble theme

The bubble theme keeps the editor clean until the user selects text. With `theme="bubble"`, the toolbar shows in a tooltip over the selection. The component keeps the toolbar markup hidden until Quill has moved it there, so it does not flash in the server HTML.

```html
<div id="quill-bubble-toolbar">
  <span class="ql-formats">
    <button type="button" class="ql-bold" aria-label="Bold"></button>
    <button type="button" class="ql-italic" aria-label="Italic"></button>
    <button type="button" class="ql-link" aria-label="Link"></button>
  </span>
</div>
<div id="quill-bubble"><p>Select this text to format it.</p></div>

<script>
new Quill("#quill-bubble", {
  theme: "bubble",
  modules: { toolbar: "#quill-bubble-toolbar" }
});
</script>
```
```tsx code
<QuillEditor
  theme="bubble"
  defaultValue="<p>Select this text to format it.</p>"
  toolbar={
    <span className="ql-formats">
      <button type="button" className="ql-bold" aria-label="Bold" />
      <button type="button" className="ql-italic" aria-label="Italic" />
      <button type="button" className="ql-link" aria-label="Link" />
    </span>
  }
/>
```

## In a form

To save what the user wrote, keep the HTML from `onChange` in state. `onChange` is a function, so that form is a client component. The app builds to static files, so there is no server to run Server Actions. Send the HTML to your API with `fetch` instead:

```tsx title="src/components/article-form.tsx"
"use client";

import { useState } from "react";
import { QuillEditor } from "@/components/plugins/quill-editor";

export function ArticleForm() {
  const [body, setBody] = useState("<p>Draft</p>");

  return (
    <form
      onSubmit={async (event) => {
        event.preventDefault();
        await fetch("/api/articles", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ body }),
        });
      }}
    >
      <QuillEditor theme="snow" defaultValue={body} onChange={(html) => setBody(html)} />
      <button type="submit" className="btn btn-primary mt-3">Save</button>
    </form>
  );
}
```

The HTML comes from `quill.getSemanticHTML()`. Sanitize it on the server before other users see it.

## Accessibility

- Toolbar buttons have no text. Give each one an `aria-label`.
- The component names the dropdowns that Quill builds from unnamed `<select>`s: "Font", "Size", "Heading", "Text color", "Highlight color" and "Alignment".
- Put a visible label or heading next to the editor. The placeholder is gone once the user types.

## Related

- [React Quill Editor](/react/docs/plugins/rich-text-editor/): the same component in AdminKit for React (Vite)
- [Bootstrap 5 rich text editor](/bootstrap/docs/plugins/rich-text-editor/): the same plugin in the HTML version
- [Form control](/nextjs/docs/forms/form-control/): Bootstrap's text inputs and textareas
- [Validation](/nextjs/docs/forms/validation/): validating the rest of the form
- [Editors demo](https://nextjs-demo.adminkit.io/forms-editors)

---

Canonical HTML page: https://adminkit.io/nextjs/docs/plugins/rich-text-editor/

Site overview for agents: https://adminkit.io/llms.txt (expanded: https://adminkit.io/llms-full.txt) · Documentation: https://adminkit.io/bootstrap/docs/ · Questions: support@adminkit.io
