# React Rich Text Editor: Tiptap, Markdown and Mentions

> Build a React rich text editor on Tiptap with Markdown shortcuts, @mentions and image uploads that saves HTML, JSON or Markdown and works well on a phone.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 19, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-rich-text-editor

## TL;DR

Build a React rich text editor on Tiptap: it is MIT licensed, runs on ProseMirror and gives you Markdown shortcuts, mentions and a schema that only lets known nodes in. Pick one storage format (HTML, Tiptap JSON or Markdown) and render saved content with the same styles, then make the toolbar reachable on a phone keyboard. The Wingo UI Rich Text Editor wraps all of this in one component with a format prop.

You need a React rich text editor for notes, comments, product descriptions or a help center. The first version is quick: install Tiptap, add StarterKit, render a toolbar. Then the questions arrive. What do you store in the database? How do pasted images get uploaded? Why does iOS zoom into the text on focus? Where does the toolbar go when the phone keyboard covers half the screen? This guide answers those, compares the editor frameworks, and shows the props that matter in the Wingo UI [Rich Text Editor](https://wingo-ui.com/components/rich-text-editor), with code for loading, saving, mentions and uploads.

## Which React rich text editor framework should I pick?

Pick Tiptap unless you have a reason not to. It wraps ProseMirror in a React API, it is MIT licensed, and its extensions cover what product editors need: lists, task lists, links, images, mentions and Markdown. Facts below are from each project's docs and repository, as of October 2026.

| Option | Built on | License | What you get |
|---|---|---|---|
| [Tiptap](https://tiptap.dev/docs/editor/getting-started/install/react) | ProseMirror | MIT (editor and open source extensions) | A headless editor: you build the toolbar and menus |
| [Lexical](https://lexical.dev/) | Its own engine, from Meta | MIT | A headless framework with React bindings |
| [Plate](https://github.com/udecode/plate) | Slate | MIT | A plugin framework plus shadcn-style UI copied through the shadcn CLI |
| [Novel](https://github.com/steven-tey/novel) | Tiptap | Apache-2.0 | A Notion-style editor with AI autocompletion through OpenAI |
| [Wingo UI Rich Text Editor](https://wingo-ui.com/components/rich-text-editor) | Tiptap | Pro license | One component: toolbar, bubble menu, mentions, uploads, counts, phone layout |

Lexical is a good choice when you want Meta's engine and do not mind building more of the UI. Plate fits if you already think in Slate. Tiptap itself sells a [Notion-like editor template](https://tiptap.dev/docs/ui-components/templates/notion-like-editor) with a slash menu, block reordering, AI and real-time collaboration; its docs say production use needs at least the Start plan, which [Tiptap's pricing page](https://tiptap.dev/pricing) lists at $49 a month billed annually or $59 monthly.

## Can I build a Notion like editor in React?

Yes, for the three features a notion like editor react build usually starts with: Markdown typed straight into the page, @mentions, and images that paste and upload. The Rich Text Editor does all three. Type `# ` for a heading, `- ` for a list, `[] ` for a checklist, `> ` for a quote, three backticks for a code block or `**word**` for bold, and paste Markdown from a README to get it formatted.

It does not ship a slash command menu or block drag handles. If you need them, the `extensions` prop appends your own Tiptap extensions after the built-in ones, and Tiptap's open source Suggestion utility, which already powers the mentions here, is the usual base for a "/" menu. If you want the full Notion feel with AI and collaboration out of the box, Novel or Tiptap's paid template will get you there faster.

Try the shortcuts in the live editor below, then switch the Output tabs to see the same document as HTML, JSON and Markdown.

![Type # and a space for a heading or [] for a checklist, type @ to mention a teammate, then switch the Output tabs between HTML, JSON and Markdown](#figure-demo:rich-text-editor?h=640)

## What props does a Tiptap React editor need?

Three for the content: `value` (or `defaultValue`), `onValueChange` and `format`. A Tiptap React editor built by hand needs `useEditor`, `EditorContent`, the extension list and `immediatelyRender: false` for Next.js; this component sets those up for you.

```bash
npx wingo-ui@latest add rich-text-editor
```

```tsx
"use client";

import { useState } from "react";
import { RichTextEditor, type RichTextValue } from "@/components/ui/rich-text-editor";

export function ProductDescription() {
  const [html, setHtml] = useState<RichTextValue>("<p>Handmade in Portland from <strong>full-grain leather</strong>.</p>");

  return (
    <RichTextEditor
      label="Description"
      value={html}
      onValueChange={(next) => setHtml(next)}
      placeholder="Describe the product…"
      characterLimit={2000}
      showCount="both"
      locale="en-US"
    />
  );
}
```

`onValueChange` fires 150ms after the last keystroke (`updateDebounce`), and at once when the editor loses focus, so serializing a long document never slows typing. An empty editor reports `""` in the HTML and Markdown formats. Passing back the value it just emitted never moves the caret; passing a different value, such as a document loaded from the server, replaces the content.

The props you will reach for next:

- `toolbar`: `true` shows `DEFAULT_TOOLBAR`, `false` hides it while keys and shortcuts keep working, or pass the items in order with `"|"` between groups.
- `toolbarPosition="bottom"` for a comment box, `variant` (`outline`, `soft`, `ghost`) and `size` (`sm`, `md`, `lg`).
- `headingLevels` (1 to 3 by default), `characterLimit`, `minHeight` and `maxHeight`.
- `readOnly` renders the document with the same styles and no toolbar, with links opening in a new tab.
- `setApi` hands you `getHTML`, `getJSON`, `getMarkdown`, `getText`, `focus`, `clear`, `insertContent`, `isEmpty` and the Tiptap `editor` itself.

## Should the editor store HTML, JSON or Markdown?

Pick one and stick to it, because each format changes how you render, search and migrate. The `format` prop decides what `value`, `defaultValue` and `onValueChange` speak.

| Store | Good for | Watch out for |
|---|---|---|
| `html` (default) | Web pages, emails, server rendering without Tiptap | Sanitize it before you render it |
| `json` | An editor that is the only writer, migrations, custom renderers | Needs Tiptap's schema to render |
| `markdown` | Content people or tools also read raw: docs, AI prompts, Git | Some marks have no standard syntax |

The usual question about a react rich text editor markdown setup is whether the round trip loses anything. Through the official `@tiptap/markdown` extension, headings, lists, links, code and images map to standard Markdown, and task lists to the GitHub flavored `- [ ]` syntax. Underline has no Markdown syntax and comes out as `++text++`, and a mention comes out as a shortcode like `[@ id="emma-carter" label="Emma Carter"]`. Tiptap's [Markdown docs](https://tiptap.dev/docs/editor/markdown) call the extension an early release that can change. If you store Markdown, hide the underline button with a custom `toolbar` (Mod+U still applies underline, so expect the occasional `++text++`) and test mentions against whatever reads the text later.

For plain form posts, `name` adds a hidden input with the value in the current format, so a Server Action receives it like any other field:

```tsx
"use client";

import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { Button } from "@/components/ui/button";
import { saveNote } from "./actions";

export function NoteForm() {
  return (
    <form action={saveNote} className="flex flex-col gap-3">
      <RichTextEditor name="body" format="markdown" label="Note" required toolbar={["heading", "|", "bold", "italic", "link", "|", "bulletList", "taskList"]} />
      <Button type="submit">Save note</Button>
    </form>
  );
}
```

Inside `saveNote`, `formData.get("body")` is the Markdown string. Because the editor emits on blur, a click on Save right after typing still sends the latest text.

> Live demo (Rich Text Editor): This editor starts from Markdown (format="markdown"): format a line with a shortcut, then open the Markdown tab under it to see the source it would save. Try it at [Rich Text Editor](https://wingo-ui.com/components/rich-text-editor) and install it with `npx wingo-ui@latest add rich-text-editor`.

## How do I add mentions and image uploads?

Pass `mentions` with an `items` function and `onImageUpload` with an upload function. Both can be async.

```tsx
"use client";

import {
  RichTextEditor,
  type MentionItem,
  type RichTextImageUploadOptions,
} from "@/components/ui/rich-text-editor";

async function searchPeople(query: string): Promise<MentionItem[]> {
  const response = await fetch(`/api/people?q=${encodeURIComponent(query)}`);
  return response.ok ? response.json() : [];
}

// xhr instead of fetch, because fetch reports no upload progress
function uploadImage(file: File, { onProgress, signal }: RichTextImageUploadOptions) {
  return new Promise<string>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const body = new FormData();
    body.append("file", file);
    xhr.open("POST", "/api/uploads");
    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    xhr.onload = () => (xhr.status < 300 ? resolve(JSON.parse(xhr.responseText).url) : reject(new Error("Upload failed")));
    xhr.onerror = () => reject(new Error("Upload failed"));
    signal.addEventListener("abort", () => xhr.abort());
    xhr.send(body);
  });
}

export function TicketReply() {
  return (
    <RichTextEditor
      label="Reply"
      placeholder="Write a reply, @ to loop someone in"
      mentions={{ items: searchPeople }}
      onImageUpload={uploadImage}
      imageAccept="image/png,image/jpeg,image/webp"
      maxImageSize={5 * 1024 * 1024}
    />
  );
}
```

Type `@` and the list follows the caret. Arrows move, Enter or Tab picks, Escape closes, and the person becomes a chip with a `data-id`. An async `items` function waits 120ms between keystrokes, so you do not query the server on every letter. Use `trigger` for a different character and `onMention` to notify someone.

Images can come from the toolbar's file picker, a paste or a drop. A dimmed preview with a progress bar holds the spot while `uploadImage` runs; if it rejects, the placeholder disappears and a screen reader hears "The image could not be uploaded". Files over `maxImageSize` (10 MB by default) or outside `imageAccept` are refused before upload, and base64 images are not allowed into the document. Without `onImageUpload` the image button asks for an image address instead. If all you need is mentions in plain text, the lighter [Mention Input](https://wingo-ui.com/components/mention-input) stores them as id-based markup; the [React mentions guide](https://wingo-ui.com/blog/react-mentions) explains why ids beat names.

## How do I render saved rich text without loading Tiptap?

Put the exported `PROSE` classes on the wrapper of your saved HTML, and the page matches the editor without shipping the editor. Sanitize the HTML on the server first. The editor's schema drops unknown tags, and the link editor refuses `javascript:` addresses through `normalizeUrl`, but anyone can POST any HTML to your API, so run it through a sanitizer such as DOMPurify before you store or render it.

```tsx
import { PROSE } from "@/components/ui/rich-text-editor";
import { toneClasses } from "@/lib/tones";
import { cn } from "@/lib/utils";

export function NoteBody({ html }: { html: string }) {
  // html was sanitized on the server before it was saved
  return <div className={cn(PROSE, toneClasses.neutral)} dangerouslySetInnerHTML={{ __html: html }} />;
}
```

`toneClasses` sets the tone variables the `MENTION` chip classes read. `PROSE` covers headings, lists, quotes, code, links and images; checklists need their own styles here, or render the note with `readOnly` on the editor instead.

For stored Markdown, use the [Markdown](https://wingo-ui.com/components/markdown) renderer. It renders GitHub flavored Markdown (tables, task lists, footnotes) in the same look, never renders raw HTML from the source, and has a streaming mode for AI answers, covered in the [React Markdown streaming tutorial](https://wingo-ui.com/blog/react-markdown-streaming).

> Live demo (Markdown): Saved Markdown rendered with the Markdown component: a table that scrolls sideways inside its own box when it is too wide, a numbered list and inline code. Try it at [Markdown](https://wingo-ui.com/components/markdown) and install it with `npx wingo-ui@latest add markdown`.

## How does the editor behave on a phone?

It treats the phone keyboard as part of the layout. The text is 16px on phones at every size, so iOS Safari never zooms in on focus. While you type on a touch screen, the toolbar docks above the keyboard as a horizontally scrolling rail of 44px buttons, so formatting stays one thumb away. Below 640px the top toolbar keeps undo, redo, text style, bold, italic, link and the two lists, and moves the rest into a ⋯ menu. The text style menu, the ⋯ menu and the link editor open as bottom sheets.

The bubble menu over selected text only shows with a mouse or trackpad, because phones already have their own selection menus and a second one would cover them.

## Is the editor accessible with a keyboard?

Yes. Toolbar buttons that have a shortcut show it in their tooltip (⌘B on a Mac, Ctrl+B elsewhere), and the pressed state follows the caret. Alt+F10 jumps from the text to the toolbar, Left and Right move between buttons, Home and End jump to the ends, and Escape returns to the text. Mod+K opens the link editor at the caret: Enter applies, an empty address removes the link, and addresses without a scheme get `https://`. Tab and Shift+Tab indent list items.

With `label`, `description` and `error` the editor uses the same Field anatomy as the other inputs, so `aria-describedby`, `aria-invalid` and `aria-required` are wired for you. The character counter speaks only when it crosses 90% and 100% of the limit, not on every keystroke. All strings live in `labels` and merge over `DEFAULT_RICH_TEXT_EDITOR_LABELS`, so you can translate the editor without forking it. The same field rules apply to every control in the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide).

## Is there a shadcn rich text editor?

Not in shadcn/ui itself: its [component list](https://ui.shadcn.com/docs/components) has a Textarea but no editor, as of October 2026. Community registries fill the gap. [Minimal Tiptap](https://github.com/Aslam97/minimal-tiptap) (MIT) installs through the shadcn CLI from its registry URL and outputs HTML, JSON, text or Markdown, and Plate installs its editor UI with `npx shadcn@latest add @plate/plate-ui`. The Wingo UI Rich Text Editor uses the same copy-the-source model. Pick Minimal Tiptap for a free toolbar on Tiptap, Plate for a Slate plugin stack, and this editor if you want mentions, uploads, counts and the phone toolbar already built.

## What are the common pitfalls?

- **Rendering on the server.** Tiptap needs `immediatelyRender: false` in Next.js, or the server and client HTML disagree. Set it in your own editor; the component already does.
- **Using a rich text editor for a plain field.** A bio or a support message rarely needs headings. The free [Textarea](https://wingo-ui.com/components/textarea) grows with its content and counts characters.
- **Mixing formats.** Saving HTML from one screen and Markdown from another makes every later migration harder.
- **Comment boxes built from scratch.** If you need replies, reactions and an optimistic composer, the [Comment Thread](https://wingo-ui.com/components/comment-thread) already has them.

## How do I add it to my project?

Run `npx wingo-ui@latest add rich-text-editor`. The CLI copies the source into `components/ui`, adds the components and hooks it imports, and installs the Tiptap packages, `@radix-ui/react-popover`, `motion` and `lucide-react`. The Rich Text Editor and the Markdown renderer are Pro components ([pricing](https://wingo-ui.com/pricing)). More single-component deep dives are in the [component guides category](https://wingo-ui.com/blog/category/components).

## Components in this post

- [Rich Text Editor](https://wingo-ui.com/components/rich-text-editor) (Pro): A React rich text editor on Tiptap with a toolbar, Markdown shortcuts, mentions, images and counts that outputs HTML, JSON or Markdown. Install: `npx wingo-ui@latest add rich-text-editor`
- [Markdown](https://wingo-ui.com/components/markdown) (Pro): A React Markdown renderer in the library's look: scrolling tables, highlighted code, task lists and a flicker-free streaming mode for AI. Install: `npx wingo-ui@latest add markdown`

## FAQ

### What is the best rich text editor for React?

For most product editors, Tiptap: it is MIT licensed, built on ProseMirror and has React bindings, Markdown and mention extensions. Lexical from Meta is the main alternative, and Plate fits if you want a Slate-based framework with shadcn-style UI.

### Is Tiptap free?

The Tiptap editor and its open source extensions, including StarterKit, Mention and Markdown, are MIT licensed. Tiptap's cloud features, such as hosted collaboration, are paid plans, and its Notion-like editor template needs at least the Start plan ($49 a month billed annually, as of October 2026) in production.

### How do I use Tiptap with Next.js?

Put the editor in a client component and pass immediatelyRender: false to useEditor, as the Tiptap Next.js guide does, so the editor initializes on the client after hydration and avoids a mismatch. The Wingo UI Rich Text Editor already sets it and reserves its min height so nothing jumps on mount.

### Should I store rich text as HTML, JSON or Markdown?

Store Tiptap JSON if the editor is the only writer, HTML if you mostly render it on web pages or in email, and Markdown if people or tools also read the raw text. Markdown cannot hold everything: underline and mentions come out as Tiptap-specific syntax.

### Is the Wingo UI Rich Text Editor free?

No. The Rich Text Editor and the Markdown renderer are Pro components. The Textarea, which covers plain multi-line input, is free.

---

Source: https://wingo-ui.com/blog/react-rich-text-editor
