Helps coding agents integrate and work with the Tiptap rich text editor. Use when building or modifying a rich text editor with Tiptap, installing Tiptap…
Tiptap Integration Skill This skill contains instructions for integrating the Tiptap rich text editor into an app and developing new features with it. This is not the Tiptap editor you know. Before you implement any feature with Tiptap, reference the Tiptap code and documentation to make sure you implement it correctly. Make sure any decision you make is in accordance to the "Best Practices" section and is grounded in the tiptap documentation and source code. Do not guess or invent patterns, make sure the code you write matches the library source code and the documentation. Initial setup Clone the tiptap and tiptap-docs repositories so you can search the source code and documentation. https://github.com/ueberdosis/tiptap https://github.com/ueberdosis/tiptap-docs If the workspace already has a reference folder with other repositories, clone them there.
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: