Integrate, build and debug Tiptap editors in applications. Use for Tiptap setup, installing or creating extensions, collaboration, comments, AI features, and…
Tiptap integration Workflow Identify the application's framework, installed Tiptap versions, and requested feature. Read the relevant documentation and installed source before choosing APIs. Match the implementation to the installed version rather than relying on remembered examples. Load only the references relevant to the task below. For topics not listed, find the relevant page through the documentation index. Task Reference Install or upgrade packages, choose a React API, configure SSR, or install pro extensions Setup and packages Add collaboration, version history, or snapshot comparison Collaboration and document history Add AI generation, agent document editing, or migrate retired AI extensions AI features Add comments, tracked changes, document conversion, or page layout Document features For custom extensions and integration debugging, inspect the relevant installed @tiptap/* source and documentation. Load a reference only if its topic applies. Finding Tiptap source and docs
don't have the plugin yet? install it then click "run inline in claude" again.
tiptap is a headless react rich text editor built on prosemirror. use it when you need inline editing with formatting (bold, italic, lists, headings, links, images), collaborative features, or markdown support. works with next.js/ssr, tailwind v4, and shadcn/ui. this skill prevents 7 common setup pitfalls and handles image uploads, ssr hydration, and extension conflicts.
required dependencies:
optional dependencies (add as needed):
external connections (if using image uploads):
context (if building custom):
run:
npm install @tiptap/react@3.16.0 @tiptap/starter-kit@3.16.0 @tiptap/pm@3.16.0 @tiptap/extension-image@3.16.0 @tiptap/extension-color@3.16.0 @tiptap/extension-text-style@3.16.0 @tiptap/extension-typography@3.16.0 @tailwindcss/typography@0.5.19
input: package.json, npm or yarn output: node_modules updated with tiptap packages, package.json.lock updated
why each package:
input: tailwind.config.ts (or .js)
edit tailwind.config.ts:
import typography from '@tailwindcss/typography'
export default {
plugins: [typography],
// ...rest of config
}
output: tailwind config updated with typography plugin
critical: without this plugin, formatted content (headings, lists, etc.) renders unstyled. alternatives exist (custom .tiptap css classes) but prose is fastest.
input: react component file (.tsx)
create file (e.g., Editor.tsx):
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Typography from '@tiptap/extension-typography'
export function Editor() {
const editor = useEditor({
extensions: [
StarterKit,
Typography,
],
content: '<p>Hello World!</p>',
immediatelyRender: false, // CRITICAL for SSR/Next.js
editorProps: {
attributes: {
class: 'prose prose-sm focus:outline-none min-h-[200px] p-4 dark:prose-invert',
},
},
})
return <EditorContent editor={editor} />
}
output: functional react editor component
critical settings:
input: editor component from step 3, file storage credentials (if uploading images)
update editor config:
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Typography from '@tiptap/extension-typography'
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
bulletList: {
keepMarks: true,
},
}),
Image.configure({
inline: true,
allowBase64: false, // CRITICAL: prevents json bloat
resize: {
enabled: true,
directions: ['top-right', 'bottom-right', 'bottom-left', 'top-left'],
minWidth: 100,
minHeight: 100,
alwaysPreserveAspectRatio: true,
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-primary underline',
},
}),
Typography,
],
immediatelyRender: false,
content: '<p>Hello World!</p>',
})
output: editor with image, link, and typography support configured
critical settings:
input: editor component with image extension, r2/s3 credentials in env vars, api route handler
create api route (e.g., pages/api/upload.ts or app/api/upload/route.ts):
// app/api/upload/route.ts (Next.js app router)
export async function POST(request: Request) {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return Response.json({ error: 'no file' }, { status: 400 })
}
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const filename = `${Date.now()}-${file.name}`
// upload to r2 (or s3)
const s3 = new AWS.S3({
endpoint: process.env.R2_ENDPOINT,
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
region: 'auto',
})
try {
await s3.putObject({
Bucket: process.env.R2_BUCKET!,
Key: filename,
Body: buffer,
ContentType: file.type,
}).promise()
const url = `${process.env.R2_PUBLIC_URL}/${filename}`
return Response.json({ url }, { status: 200 })
} catch (error) {
console.error(error)
return Response.json({ error: 'upload failed' }, { status: 500 })
}
}
update editor component to handle uploads:
const editor = useEditor({
extensions: [
Image.configure({
inline: true,
allowBase64: false,
upload: {
handler: async (file: File) => {
// 1. create base64 preview for immediate display
const reader = new FileReader()
const base64 = await new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(file)
})
// 2. insert preview into editor
editor.chain().focus().setImage({ src: base64 }).run()
// 3. upload to r2/s3 in background
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const { url } = await response.json()
// 4. replace base64 with permanent url
editor.chain()
.focus()
.updateAttributes('image', { src: url })
.run()
return url
} catch (error) {
console.error('image upload failed:', error)
// leave base64 in place as fallback
return base64
}
},
},
}),
],
immediatelyRender: false,
})
input: file dropped/selected in editor, api endpoint running, r2/s3 credentials in env output: image url stored in editor, image rendered immediately via base64, then replaced with permanent url after upload completes
why this pattern:
input: react-hook-form controller, editor component
import { useForm, Controller } from 'react-hook-form'
import { Editor } from './Editor'
function BlogForm() {
const { control, handleSubmit } = useForm({
defaultValues: { content: '<p></p>' },
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
name="content"
control={control}
rules={{ required: 'content required' }}
render={({ field }) => (
<Editor
defaultValue={field.value}
onChange={(html) => field.onChange(html)}
/>
)}
/>
<button type="submit">Save</button>
</form>
)
}
update Editor.tsx to accept props:
export function Editor({ defaultValue = '<p></p>', onChange }: {
defaultValue?: string
onChange?: (html: string) => void
}) {
const editor = useEditor({
extensions: [StarterKit, Typography],
content: defaultValue,
immediatelyRender: false,
editorProps: {
attributes: {
class: 'prose prose-sm focus:outline-none min-h-[200px] p-4 dark:prose-invert',
},
},
onUpdate: ({ editor }) => {
onChange?.(editor.getHTML())
},
})
return <EditorContent editor={editor} />
}
input: form data, editor component with onChange callback output: editor value synced to form state, ready for submission
input: editor component, markdown content string
install: npm install @tiptap/markdown@3.16.0
update editor:
import { Markdown } from '@tiptap/markdown'
const editor = useEditor({
extensions: [StarterKit, Markdown],
content: '# Hello World\n\nThis is **Markdown**!',
contentType: 'markdown', // CRITICAL: must specify or html assumed
immediatelyRender: false,
})
// get markdown output
const markdownOutput = editor.getMarkdown()
// set markdown content
editor.commands.setContent('## New heading', { contentType: 'markdown' })
input: markdown string output: editor renders markdown as rich text, getMarkdown() returns markdown string
critical: always specify contentType: 'markdown' when setting markdown content, or it will be parsed as html.
input: editor component, y.js WebSocket provider or backend
install: npm install yjs @tiptap/extension-collaboration@3.16.0 y-websocket@0.3.2
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
const ydoc = new Y.Doc()
const provider = new WebsocketProvider(
'ws://localhost:1234',
'shared-doc',
ydoc
)
const editor = useEditor({
extensions: [
StarterKit.configure({
history: false, // disable history, collab provides version control
}),
Collaboration.configure({
document: ydoc,
}),
],
immediatelyRender: false,
})
input: websocket endpoint running y.js server, editor component output: editor synced across clients, all edits propagated in real-time
limits: do not load more than 100 concurrent widgets in collaborative mode (performance degradation).
input: completed editor component, tailwind config, api routes (if images)
verification checklist:
npm list @tiptap/react)output: green checklist, ready for development/production
if using next.js or any ssr framework:
if images needed:
if storing html in database:
if building quick setup:
if typing lags or performance poor:
if build fails in create react app:
if prosemirror conflicts appear ("multiple versions" error):
if headings/lists unstyled:
if using both editorprovider and useeditor:
if react 19 and drag handles needed:
on success, you will have: