How to Build a Notion-like Editor with Nuxt and Tiptap
What we're building
By the end of this tutorial, you'll have a Notion-like editor with:
- Markdown content edited as rich text with the
UEditorcomponent, built on Tiptap - Fixed and bubble toolbars to format text and turn blocks into headings, lists or quotes
- Mentions and emojis with
@and: - Slash commands and a drag handle to insert, reorder, duplicate and delete blocks
- Image uploads stored with NuxtHub Blob
- AI completions and text transformations powered by the AI SDK
- Real-time collaboration with Y.js and PartyKit
Editor template on GitHub for a production-ready implementation with tables, code highlighting and more.Prerequisites
Before we start, make sure you have:
- Node.js 22.19+ or 24.11+ installed
- A Vercel AI Gateway API key for the AI features
- A PartyKit account to deploy the collaboration server, only needed in production
Project setup
Start by creating a new Nuxt project:
npm create nuxt@latest nuxt-editor
cd nuxt-editor
Installing dependencies
Install Nuxt UI and the Tiptap packages:
pnpm add @nuxt/ui tailwindcss @tiptap/core @tiptap/vue-3 @tiptap/pm @tiptap/extension-emoji
yarn add @nuxt/ui tailwindcss @tiptap/core @tiptap/vue-3 @tiptap/pm @tiptap/extension-emoji
npm install @nuxt/ui tailwindcss @tiptap/core @tiptap/vue-3 @tiptap/pm @tiptap/extension-emoji
bun add @nuxt/ui tailwindcss @tiptap/core @tiptap/vue-3 @tiptap/pm @tiptap/extension-emoji
The UEditor component already comes with Tiptap and its StarterKit. The Tiptap packages are installed so your own code can import from them.
Configuration
Register the Nuxt UI module in your nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css']
})
Create the main CSS file to import Tailwind CSS and Nuxt UI:
@import "tailwindcss";
@import "@nuxt/ui";
Nuxt UI requires wrapping your app with UApp for tooltips, toasts and overlays to work properly:
<template>
<UApp>
<NuxtPage />
</UApp>
</template>
Creating the editor
The UEditor component turns its v-model into rich text. With content-type="markdown", the content goes in and comes out as Markdown:
<script setup lang="ts">
const content = ref(`# Welcome to your editor
Start writing here. Everything you type is kept as **Markdown**, from *emphasis* to [links](https://ui.nuxt.com).
- Headings, lists and quotes
- **Bold**, *italic* and \`inline code\``)
</script>
<template>
<UEditor
v-model="content"
content-type="markdown"
placeholder="Start writing..."
autofocus
class="min-h-screen"
:ui="{ base: 'p-4 sm:p-14', content: 'max-w-4xl mx-auto' }"
/>
</template>
The StarterKit already handles headings, lists, quotes, code blocks, links and the usual marks. The ui prop centers the content and gives it some room.
Adding toolbars
The UEditorToolbar component displays buttons and dropdown menus that act on the editor. Each item with a kind runs one of the editor's handlers, which also tell the button when it's active or disabled.
Defining the toolbar items
Keep the items in a composable so the page stays readable. The fixed toolbar holds undo and redo, while the bubble toolbar formats the selected text:
import type { EditorToolbarItem } from '@nuxt/ui'
export function useEditorToolbar() {
const toolbarItems: EditorToolbarItem[][] = [[{
kind: 'undo',
icon: 'i-lucide-undo',
tooltip: { text: 'Undo' }
}, {
kind: 'redo',
icon: 'i-lucide-redo',
tooltip: { text: 'Redo' }
}]]
const bubbleToolbarItems: EditorToolbarItem[][] = [[{
label: 'Turn into',
trailingIcon: 'i-lucide-chevron-down',
activeColor: 'neutral',
activeVariant: 'ghost',
content: {
align: 'start'
},
items: [{
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type'
}, {
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1'
}, {
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2'
}, {
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3'
}, {
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list'
}, {
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered'
}, {
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote'
}, {
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code'
}]
}], [{
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' }
}, {
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' }
}, {
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' }
}, {
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' }
}, {
kind: 'mark',
mark: 'code',
icon: 'i-lucide-code',
tooltip: { text: 'Code' }
}, {
kind: 'link',
icon: 'i-lucide-link',
tooltip: { text: 'Link' }
}]]
return {
toolbarItems,
bubbleToolbarItems
}
}
The link handler prompts for a URL, and the Turn into dropdown converts the current block with the paragraph, heading, list, blockquote and codeBlock handlers.
Displaying the toolbars
The default slot of UEditor exposes the editor instance, which every toolbar and menu needs:
<script setup lang="ts">
const { toolbarItems, bubbleToolbarItems } = useEditorToolbar()
const content = ref(`# Welcome to your editor
Start writing here. Everything you type is kept as **Markdown**, from *emphasis* to [links](https://ui.nuxt.com).
- Headings, lists and quotes
- **Bold**, *italic* and \`inline code\``)
</script>
<template>
<UEditor
v-slot="{ editor }"
v-model="content"
content-type="markdown"
placeholder="Start writing..."
autofocus
class="min-h-screen"
:ui="{ base: 'p-4 sm:p-14', content: 'max-w-4xl mx-auto' }"
>
<div class="sticky top-0 z-10 flex items-center justify-end gap-2 px-4 sm:px-14 py-2 border-b border-default bg-default">
<UEditorToolbar :editor="editor" :items="toolbarItems" />
</div>
<UEditorToolbar
:editor="editor"
:items="bubbleToolbarItems"
layout="bubble"
:should-show="({ view, state }) => view.hasFocus() && !state.selection.empty && !editor.isActive('image')"
/>
</UEditor>
</template>
The fixed toolbar sits in a sticky header at the top of the editor. With layout="bubble", the second toolbar floats above the selection. Its should-show prop shows it only when some text is selected, not when an image is.
Adding mentions and emojis
The UEditorMentionMenu and UEditorEmojiMenu components open when you type @ or : and insert the item you pick.
Mentions
The mention menu needs a list of people to mention. Start with a fixed list, it gets replaced by the people editing the document in the collaboration section:
import type { EditorMentionMenuItem } from '@nuxt/ui'
export function useEditorMentions() {
const items: EditorMentionMenuItem[] = [{
label: 'benjamincanac',
avatar: { src: 'https://avatars.githubusercontent.com/u/739984?v=4' }
}, {
label: 'atinux',
avatar: { src: 'https://avatars.githubusercontent.com/u/904724?v=4' }
}, {
label: 'danielroe',
avatar: { src: 'https://avatars.githubusercontent.com/u/28706372?v=4' }
}]
return {
items
}
}
Emojis
The emoji menu uses the Emoji extension from Tiptap and its list of GitHub emojis:
import type { EditorEmojiMenuItem } from '@nuxt/ui'
import { Emoji, gitHubEmojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
export function useEditorEmojis() {
const items: EditorEmojiMenuItem[] = gitHubEmojis.filter(
emoji => !emoji.name.startsWith('regional_indicator_')
)
// Read `:shortcode:` from Markdown content and write the emoji back
const extension = Emoji.extend({
markdownTokenName: 'emoji',
markdownTokenizer: {
name: 'emoji',
level: 'inline',
start: ':',
tokenize(src) {
const match = src.match(/^:([a-zA-Z0-9_+-]+):/)
if (!match?.[1] || !shortcodeToEmoji(match[1], gitHubEmojis)) {
return undefined
}
return { type: 'emoji', raw: match[0], name: match[1] }
}
},
parseMarkdown(token, { createNode }) {
return createNode('emoji', { name: token.name })
},
renderMarkdown(node) {
const emoji = node.attrs?.name ? shortcodeToEmoji(node.attrs.name, gitHubEmojis) : undefined
return emoji?.emoji || ''
}
})
return {
items,
extension
}
}
Extending the extension with a Markdown tokenizer makes a :sparkles: shortcode in the Markdown content show as an emoji, and writes the emoji back when the content is saved.
Displaying the mention and emoji menus
Add both menus to the editor, and the emoji extension with the extensions prop, which adds Tiptap extensions on top of the StarterKit:
<script setup lang="ts">
const { toolbarItems, bubbleToolbarItems } = useEditorToolbar()
const { items: mentionItems } = useEditorMentions()
const { items: emojiItems, extension: Emoji } = useEditorEmojis()
const content = ref(`# Welcome to your editor
Start writing here. Everything you type is kept as **Markdown**, from *emphasis* to [links](https://ui.nuxt.com) :sparkles:
- Headings, lists and quotes
- **Bold**, *italic* and \`inline code\``)
</script>
<template>
<UEditor
v-slot="{ editor }"
v-model="content"
content-type="markdown"
:extensions="[Emoji]"
placeholder="Start writing..."
autofocus
class="min-h-screen"
:ui="{ base: 'p-4 sm:p-14', content: 'max-w-4xl mx-auto' }"
>
<div class="sticky top-0 z-10 flex items-center justify-end gap-2 px-4 sm:px-14 py-2 border-b border-default bg-default">
<UEditorToolbar :editor="editor" :items="toolbarItems" />
</div>
<UEditorToolbar
:editor="editor"
:items="bubbleToolbarItems"
layout="bubble"
:should-show="({ view, state }) => view.hasFocus() && !state.selection.empty && !editor.isActive('image')"
/>
<UEditorMentionMenu :editor="editor" :items="mentionItems" />
<UEditorEmojiMenu :editor="editor" :items="emojiItems" />
</UEditor>
</template>
Adding slash commands and a drag handle
The UEditorSuggestionMenu component opens a list of blocks to insert when you type /. The UEditorDragHandle component shows a handle next to the block under the cursor, to drag it somewhere else.
Slash commands
The suggestion menu items use the same handlers as the toolbar. The mention and emoji handlers type @ or : to open their menu:
import type { EditorSuggestionMenuItem } from '@nuxt/ui'
export function useEditorSuggestions() {
const items: EditorSuggestionMenuItem[][] = [[{
type: 'label',
label: 'Style'
}, {
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type'
}, {
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1'
}, {
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2'
}, {
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3'
}, {
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list'
}, {
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered'
}, {
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote'
}, {
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code'
}], [{
type: 'label',
label: 'Insert'
}, {
kind: 'mention',
label: 'Mention',
icon: 'i-lucide-at-sign'
}, {
kind: 'emoji',
label: 'Emoji',
icon: 'i-lucide-smile-plus'
}, {
kind: 'horizontalRule',
label: 'Horizontal Rule',
icon: 'i-lucide-separator-horizontal'
}]]
return {
items
}
}
Block actions
Clicking the drag handle can also open a menu of actions on the block. The mapEditorItems utility turns the kind of each item into an onSelect that runs its handler on the block at pos:
import type { DropdownMenuItem } from '@nuxt/ui'
import type { Editor, JSONContent } from '@tiptap/vue-3'
import { mapEditorItems } from '@nuxt/ui/utils/editor'
export function useEditorDragHandle() {
// The block the drag handle is next to
const selectedNode = ref<{ node: JSONContent | null, pos: number }>()
function getItems(editor: Editor): DropdownMenuItem[][] {
const pos = selectedNode.value?.pos
if (pos === undefined) {
return []
}
// Turns the `kind` of each item into an `onSelect` that runs its handler
return mapEditorItems(editor, [[{
label: 'Turn into',
icon: 'i-lucide-repeat-2',
children: [
{ kind: 'paragraph', label: 'Paragraph', icon: 'i-lucide-type' },
{ kind: 'heading', level: 1, label: 'Heading 1', icon: 'i-lucide-heading-1' },
{ kind: 'heading', level: 2, label: 'Heading 2', icon: 'i-lucide-heading-2' },
{ kind: 'heading', level: 3, label: 'Heading 3', icon: 'i-lucide-heading-3' },
{ kind: 'bulletList', label: 'Bullet List', icon: 'i-lucide-list' },
{ kind: 'orderedList', label: 'Numbered List', icon: 'i-lucide-list-ordered' },
{ kind: 'blockquote', label: 'Blockquote', icon: 'i-lucide-text-quote' },
{ kind: 'codeBlock', label: 'Code Block', icon: 'i-lucide-square-code' }
]
}, {
kind: 'clearFormatting',
pos,
label: 'Reset formatting',
icon: 'i-lucide-rotate-ccw'
}], [{
kind: 'duplicate',
pos,
label: 'Duplicate',
icon: 'i-lucide-copy'
}, {
kind: 'moveUp',
pos,
label: 'Move up',
icon: 'i-lucide-arrow-up'
}, {
kind: 'moveDown',
pos,
label: 'Move down',
icon: 'i-lucide-arrow-down'
}], [{
kind: 'delete',
pos,
label: 'Delete',
icon: 'i-lucide-trash',
color: 'error'
}]]) as DropdownMenuItem[][]
}
function onNodeChange(event: { node: JSONContent | null, pos: number }) {
selectedNode.value = event
}
return {
getItems,
onNodeChange
}
}
Displaying the slash commands and drag handle
Add the suggestion menu and the drag handle to the editor:
<script setup lang="ts">
const { toolbarItems, bubbleToolbarItems } = useEditorToolbar()
const { items: mentionItems } = useEditorMentions()
const { items: emojiItems, extension: Emoji } = useEditorEmojis()
const { items: suggestionItems } = useEditorSuggestions()
const { getItems: getDragHandleItems, onNodeChange } = useEditorDragHandle()
const content = ref(`# Welcome to your editor
Start writing here. Everything you type is kept as **Markdown**, from *emphasis* to [links](https://ui.nuxt.com) :sparkles:
- Headings, lists and quotes
- **Bold**, *italic* and \`inline code\``)
</script>
<template>
<UEditor
v-slot="{ editor, handlers }"
v-model="content"
content-type="markdown"
:extensions="[Emoji]"
placeholder="Write, type '/' for commands..."
autofocus
class="min-h-screen"
:ui="{ base: 'p-4 sm:p-14', content: 'max-w-4xl mx-auto' }"
>
<div class="sticky top-0 z-10 flex items-center justify-end gap-2 px-4 sm:px-14 py-2 border-b border-default bg-default">
<UEditorToolbar :editor="editor" :items="toolbarItems" />
</div>
<UEditorToolbar
:editor="editor"
:items="bubbleToolbarItems"
layout="bubble"
:should-show="({ view, state }) => view.hasFocus() && !state.selection.empty && !editor.isActive('image')"
/>
<UEditorMentionMenu :editor="editor" :items="mentionItems" />
<UEditorEmojiMenu :editor="editor" :items="emojiItems" />
<UEditorSuggestionMenu :editor="editor" :items="suggestionItems" />
<UEditorDragHandle v-slot="{ ui, onClick }" :editor="editor" @node-change="onNodeChange">
<UButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
size="sm"
aria-label="Add a block"
:class="ui.handle()"
@click="(e: MouseEvent) => {
e.stopPropagation()
const node = onClick()
handlers.suggestion?.execute(editor, { pos: node?.pos }).run()
}"
/>
<UDropdownMenu
v-slot="{ open }"
:modal="false"
:items="getDragHandleItems(editor)"
:content="{ side: 'left' }"
:ui="{ content: 'w-48' }"
@update:open="editor.chain().setMeta('lockDragHandle', $event).run()"
>
<UButton
icon="i-lucide-grip-vertical"
color="neutral"
variant="ghost"
active-variant="soft"
size="sm"
aria-label="Block actions"
:active="open"
:class="ui.handle()"
/>
</UDropdownMenu>
</UEditorDragHandle>
</UEditor>
</template>
Here's how the drag handle works:
The default slot
Replacing the default handle with your own buttons gives you the ui classes to style them like the handle, and an onClick function that selects the block and returns it.
The plus button
The suggestion handler, from the handlers exposed by the UEditor slot, opens the slash commands on a new line after the block.
The block actions
The node-change event tells the composable which block the handle is next to. While the dropdown menu is open, the lockDragHandle meta keeps the handle on that block instead of following the cursor.
Uploading images
Images are uploaded to a blob storage with NuxtHub. It stores them on the filesystem in development, and in Vercel Blob, Cloudflare R2 or Amazon S3 in production.
Installing NuxtHub
pnpm add @nuxthub/core
yarn add @nuxthub/core
npm install @nuxthub/core
bun add @nuxthub/core
Register the module and enable the blob storage:
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@nuxthub/core'],
css: ['~/assets/css/main.css'],
hub: {
blob: true
}
})
Creating the server routes
Create an endpoint that validates and stores the uploaded image with blob.handleUpload:
import { defineEventHandler } from 'nuxt/server'
import { blob } from 'hub:blob'
export default defineEventHandler((event) => {
return blob.handleUpload(event, {
formKey: 'file',
multiple: false,
ensure: {
maxSize: '2MB',
types: ['image']
},
put: {
addRandomSuffix: true
}
})
})
Then a route that serves the stored images. The Content-Security-Policy header stops an uploaded SVG from running scripts when it's opened on its own:
import { defineEventHandler, getRouterParam } from 'nuxt/server'
import { blob } from 'hub:blob'
export default defineEventHandler((event) => {
const pathname = getRouterParam(event, 'pathname')!
// An uploaded SVG can't run scripts when opened on its own
event.res.headers.set('Content-Security-Policy', 'default-src \'none\';')
return blob.serve(event, pathname)
})
Creating the upload block
The upload happens in a block of the document, displayed with the UFileUpload component. Once the image is uploaded with NuxtHub's useUpload composable, the block replaces itself with the image:
<script setup lang="ts">
import type { NodeViewProps } from '@tiptap/vue-3'
import { NodeViewWrapper } from '@tiptap/vue-3'
const props = defineProps<NodeViewProps>()
const loading = ref(false)
const error = ref<string>()
const upload = useUpload('/api/upload', { formKey: 'file', multiple: false })
async function onUpload(file: File | null | undefined) {
if (!file) {
return
}
loading.value = true
error.value = undefined
try {
const result = await upload(file)
const pos = props.getPos()
if (typeof pos !== 'number') {
return
}
// Replace the upload block with the uploaded image
props.editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.setImage({ src: result.url || `/images/${result.pathname}` })
.run()
} catch (e) {
error.value = (e as { data?: { message?: string } }).data?.message || 'The image could not be uploaded'
} finally {
loading.value = false
}
}
</script>
<template>
<NodeViewWrapper>
<UFileUpload
accept="image/*"
label="Upload an image"
:description="error || 'SVG, PNG, JPG or GIF (max. 2MB)'"
:preview="false"
class="min-h-48"
:ui="{ description: error ? 'text-error' : '' }"
@update:model-value="onUpload"
>
<template #leading>
<UAvatar
:icon="error ? 'i-lucide-alert-circle' : loading ? 'i-lucide-loader-circle' : 'i-lucide-image'"
size="xl"
:ui="{ icon: [loading && 'animate-spin', error && 'text-error'] }"
/>
</template>
</UFileUpload>
</NodeViewWrapper>
</template>
A custom Tiptap node renders this component in the editor:
import { Node, mergeAttributes } from '@tiptap/core'
import type { CommandProps, NodeViewRenderer } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import ImageUploadNodeComponent from './ImageUploadNode.vue'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageUpload: {
insertImageUpload: () => ReturnType
}
}
}
export const ImageUpload = Node.create({
name: 'imageUpload',
group: 'block',
atom: true,
draggable: true,
addAttributes() {
return {}
},
parseHTML() {
return [{
tag: 'div[data-type="image-upload"]'
}]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-upload' })]
},
addNodeView(): NodeViewRenderer {
return VueNodeViewRenderer(ImageUploadNodeComponent)
},
addCommands() {
return {
insertImageUpload: () => ({ commands }: CommandProps) => {
return commands.insertContent({ type: this.name })
}
}
}
})
export default ImageUpload
Inserting the upload block
Add an imageUpload item to the fixed toolbar:
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
// The handlers type the custom `kind` of the items, like `imageUpload`
export function useEditorToolbar<T extends EditorCustomHandlers>(_customHandlers?: T) {
const toolbarItems: EditorToolbarItem<T>[][] = [[{
kind: 'undo',
icon: 'i-lucide-undo',
tooltip: { text: 'Undo' }
}, {
kind: 'redo',
icon: 'i-lucide-redo',
tooltip: { text: 'Redo' }
}], [{
kind: 'imageUpload',
label: 'Add',
icon: 'i-lucide-image',
tooltip: { text: 'Add image' }
}]]
const bubbleToolbarItems: EditorToolbarItem<T>[][] = [[{
label: 'Turn into',
trailingIcon: 'i-lucide-chevron-down',
activeColor: 'neutral',
activeVariant: 'ghost',
content: {
align: 'start'
},
items: [{
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type'
}, {
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1'
}, {
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2'
}, {
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3'
}, {
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list'
}, {
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered'
}, {
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote'
}, {
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code'
}]
}], [{
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' }
}, {
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' }
}, {
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' }
}, {
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' }
}, {
kind: 'mark',
mark: 'code',
icon: 'i-lucide-code',
tooltip: { text: 'Code' }
}, {
kind: 'link',
icon: 'i-lucide-link',
tooltip: { text: 'Link' }
}]]
return {
toolbarItems,
bubbleToolbarItems
}
}
And to the slash commands:
import type { EditorCustomHandlers, EditorSuggestionMenuItem } from '@nuxt/ui'
// The handlers type the custom `kind` of the items, like `imageUpload`
export function useEditorSuggestions<T extends EditorCustomHandlers>(_customHandlers?: T) {
const items: EditorSuggestionMenuItem<T>[][] = [[{
type: 'label',
label: 'Style'
}, {
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type'
}, {
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1'
}, {
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2'
}, {
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3'
}, {
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list'
}, {
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered'
}, {
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote'
}, {
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code'
}], [{
type: 'label',
label: 'Insert'
}, {
kind: 'mention',
label: 'Mention',
icon: 'i-lucide-at-sign'
}, {
kind: 'emoji',
label: 'Emoji',
icon: 'i-lucide-smile-plus'
}, {
kind: 'imageUpload',
label: 'Image',
icon: 'i-lucide-image'
}, {
kind: 'horizontalRule',
label: 'Horizontal Rule',
icon: 'i-lucide-separator-horizontal'
}]]
return {
items
}
}
The editor doesn't know the imageUpload kind yet, so the page defines it with a custom handler and passes it to the handlers prop:
<script setup lang="ts">
import type { EditorCustomHandlers } from '@nuxt/ui'
import type { Editor } from '@tiptap/vue-3'
import { ImageUpload } from '~/components/editor/ImageUploadExtension'
const customHandlers = {
imageUpload: {
canExecute: (editor: Editor) => editor.can().insertContent({ type: 'imageUpload' }),
execute: (editor: Editor) => editor.chain().focus().insertContent({ type: 'imageUpload' }),
isActive: (editor: Editor) => editor.isActive('imageUpload'),
isDisabled: undefined
}
} satisfies EditorCustomHandlers
const { toolbarItems, bubbleToolbarItems } = useEditorToolbar(customHandlers)
const { items: mentionItems } = useEditorMentions()
const { items: emojiItems, extension: Emoji } = useEditorEmojis()
const { items: suggestionItems } = useEditorSuggestions(customHandlers)
const { getItems: getDragHandleItems, onNodeChange } = useEditorDragHandle()
const content = ref(`# Welcome to your editor
Start writing here. Everything you type is kept as **Markdown**, from *emphasis* to [links](https://ui.nuxt.com) :sparkles:
- Headings, lists and quotes
- **Bold**, *italic* and \`inline code\``)
</script>
<template>
<UEditor
v-slot="{ editor, handlers }"
v-model="content"
content-type="markdown"
:extensions="[Emoji, ImageUpload]"
:handlers="customHandlers"
placeholder="Write, type '/' for commands..."
autofocus
class="min-h-screen"
:ui="{ base: 'p-4 sm:p-14', content: 'max-w-4xl mx-auto' }"
>
<div class="sticky top-0 z-10 flex items-center justify-end gap-2 px-4 sm:px-14 py-2 border-b border-default bg-default">
<UEditorToolbar :editor="editor" :items="toolbarItems" />
</div>
<UEditorToolbar
:editor="editor"
:items="bubbleToolbarItems"
layout="bubble"
:should-show="({ view, state }) => view.hasFocus() && !state.selection.empty && !editor.isActive('image') && !editor.isActive('imageUpload')"
/>
<UEditorMentionMenu :editor="editor" :items="mentionItems" />
<UEditorEmojiMenu :editor="editor" :items="emojiItems" />
<UEditorSuggestionMenu :editor="editor" :items="suggestionItems" />
<UEditorDragHandle v-slot="{ ui, onClick }" :editor="editor" @node-change="onNodeChange">
<UButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
size="sm"
aria-label="Add a block"
:class="ui.handle()"
@click="(e: MouseEvent) => {
e.stopPropagation()
const node = onClick()
handlers.suggestion?.execute(editor, { pos: node?.pos }).run()
}"
/>
<UDropdownMenu
v-slot="{ open }"
:modal="false"
:items="getDragHandleItems(editor)"
:content="{ side: 'left' }"
:ui="{ content: 'w-48' }"
@update:open="editor.chain().setMeta('lockDragHandle', $event).run()"
>
<UButton
icon="i-lucide-grip-vertical"
color="neutral"
variant="ghost"
active-variant="soft"
size="sm"
aria-label="Block actions"
:active="open"
:class="ui.handle()"
/>
</UDropdownMenu>
</UEditorDragHandle>
</UEditor>
</template>
Both composables receive the custom handlers so TypeScript accepts the imageUpload kind in their items. The bubble toolbar also stays hidden on the upload block.
Adding AI completions
The AI features use the useCompletion composable from the AI SDK to stream text from a server endpoint. They come in two forms:
- Ghost text that continues your sentence, accepted with ⇥
- Text transformations that fix, extend, reduce, simplify, summarize or translate the selected text
Installing the AI SDK
pnpm add ai @ai-sdk/vue @vueuse/core
yarn add ai @ai-sdk/vue @vueuse/core
npm install ai @ai-sdk/vue @vueuse/core
bun add ai @ai-sdk/vue @vueuse/core
Create a .env file with your AI Gateway API key:
AI_GATEWAY_API_KEY=your-api-key-here
Creating the completion endpoint
The endpoint picks the instructions for the requested mode and streams the result as plain text with streamText:
import { createError, defineEventHandler, readBody } from 'nuxt/server'
import { streamText, createTextStreamResponse } from 'ai'
export default defineEventHandler(async (event) => {
const { prompt, mode, language } = await readBody<{ prompt?: string, mode?: string, language?: string }>(event)
if (!prompt) {
throw createError({ status: 400, message: 'Prompt is required' })
}
if (prompt.length > 10_000) {
throw createError({ status: 413, message: 'Prompt is too long' })
}
let instructions: string
let maxOutputTokens: number
const preserveMarkdown = 'IMPORTANT: Preserve all markdown formatting (bold, italic, links, etc.) exactly as in the original.'
switch (mode) {
case 'fix':
instructions = `You are a writing assistant. Fix all spelling and grammar errors in the given text. ${preserveMarkdown} Only output the corrected text, nothing else.`
maxOutputTokens = 500
break
case 'extend':
instructions = `You are a writing assistant. Extend the given text with more details, examples, and explanations while maintaining the same style. ${preserveMarkdown} Only output the extended text, nothing else.`
maxOutputTokens = 500
break
case 'reduce':
instructions = `You are a writing assistant. Make the given text more concise by removing unnecessary words while keeping the meaning. ${preserveMarkdown} Only output the reduced text, nothing else.`
maxOutputTokens = 300
break
case 'simplify':
instructions = `You are a writing assistant. Simplify the given text to make it easier to understand, using simpler words and shorter sentences. ${preserveMarkdown} Only output the simplified text, nothing else.`
maxOutputTokens = 400
break
case 'summarize':
instructions = 'You are a writing assistant. Summarize the given text concisely while keeping the key points. Only output the summary, nothing else.'
maxOutputTokens = 200
break
case 'translate':
instructions = `You are a writing assistant. Translate the given text to ${language || 'English'}. ${preserveMarkdown} Only output the translated text, nothing else.`
maxOutputTokens = 500
break
case 'continue':
default:
instructions = `You are a writing assistant providing inline autocompletions.
CRITICAL RULES:
- Output ONLY the NEW text that comes AFTER the user's input
- NEVER repeat any words from the end of the user's text
- Keep completions short (1 sentence max)
- Match the tone and style of the existing text
- Output plain text only, never use markdown syntax`
maxOutputTokens = 25
break
}
const result = streamText({
model: 'deepseek/deepseek-v4.1-flash',
instructions,
prompt,
maxOutputTokens,
reasoning: 'none',
providerOptions: {
gateway: {
order: ['baseten'],
zeroDataRetention: true,
disallowPromptTraining: true
}
}
})
return createTextStreamResponse({ stream: result.textStream })
})
With reasoning: 'none', the model answers without a thinking step, so suggestions come back fast. The gateway options prefer Baseten and only use providers with zero data retention and no prompt training. Request-level zero data retention is only available on Vercel Pro and Enterprise plans. Prompts over 10,000 characters are rejected.
Creating the ghost text extension
This Tiptap extension displays the suggestion after the cursor as a decoration, which isn't part of the document until you accept it:
import { Extension } from '@tiptap/core'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/vue-3'
import { useDebounceFn } from '@vueuse/core'
export interface CompletionOptions {
/**
* Debounce delay in ms before triggering completion
* @defaultValue 250
*/
debounce?: number
/**
* Whether to automatically trigger completion while typing
* @defaultValue true
*/
autoTrigger?: boolean
/**
* Characters that prevent completion while typing the word they start (slash commands, emojis, mentions)
* @defaultValue ['/', ':', '@']
*/
triggerCharacters?: string[]
/**
* Called when completion should be triggered, receives the editor instance.
* Return `false` to skip showing a suggestion.
*/
onTrigger?: (editor: Editor) => boolean | undefined
/**
* Called when suggestion is accepted
*/
onAccept?: () => void
/**
* Called when suggestion is dismissed
*/
onDismiss?: () => void
}
export interface CompletionStorage {
suggestion: string
position: number | undefined
visible: boolean
debouncedTrigger: ((editor: Editor) => void) | null
setSuggestion: (text: string) => void
clearSuggestion: () => void
}
export const completionPluginKey = new PluginKey('completion')
function dismiss(editor: Editor, storage: CompletionStorage, options: CompletionOptions) {
storage.clearSuggestion()
// Force decoration update
editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
options.onDismiss?.()
}
export const Completion = Extension.create<CompletionOptions, CompletionStorage>({
name: 'completion',
addOptions() {
return {
debounce: 250,
autoTrigger: true,
triggerCharacters: ['/', ':', '@'],
onTrigger: undefined,
onAccept: undefined,
onDismiss: undefined
}
},
addStorage() {
return {
suggestion: '',
position: undefined as number | undefined,
visible: false,
debouncedTrigger: null as ((editor: Editor) => void) | null,
setSuggestion(text: string) {
this.suggestion = text
},
clearSuggestion() {
this.suggestion = ''
this.position = undefined
this.visible = false
}
}
},
addProseMirrorPlugins() {
const storage = this.storage
return [
new Plugin({
key: completionPluginKey,
props: {
decorations(state) {
if (!storage.visible || !storage.suggestion || storage.position === undefined) {
return DecorationSet.empty
}
const widget = Decoration.widget(storage.position, () => {
const span = document.createElement('span')
span.className = 'completion-suggestion'
span.textContent = storage.suggestion
span.style.cssText = 'color: var(--ui-text-muted); opacity: 0.6; pointer-events: none;'
return span
}, { side: 1 })
return DecorationSet.create(state.doc, [widget])
}
}
})
]
},
addKeyboardShortcuts() {
return {
Tab: ({ editor }) => {
if (!this.storage.visible || !this.storage.suggestion || this.storage.position === undefined) {
return false
}
// Store values before clearing
const suggestion = this.storage.suggestion
const position = this.storage.position
// Clear suggestion first
this.storage.clearSuggestion()
// Force decoration update
editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
// Insert the suggestion text
editor.chain().focus().insertContentAt(position, suggestion).run()
this.options.onAccept?.()
return true
},
Escape: ({ editor }) => {
if (!this.storage.visible) {
return false
}
dismiss(editor as Editor, this.storage, this.options)
return true
}
}
},
onUpdate({ editor }) {
// Clear suggestion on any edit
if (this.storage.visible) {
dismiss(editor as Editor, this.storage, this.options)
}
if (this.options.autoTrigger) {
this.storage.debouncedTrigger?.(editor as Editor)
}
},
onSelectionUpdate({ editor }) {
if (this.storage.visible) {
dismiss(editor as Editor, this.storage, this.options)
}
},
onCreate() {
const storage = this.storage
const options = this.options
// Create debounced trigger function for this instance
this.storage.debouncedTrigger = useDebounceFn((editor: Editor) => {
if (!options.onTrigger) return
const { state } = editor
const { selection } = state
const { $from } = selection
// Only suggest at end of block with content
const isAtEndOfBlock = $from.parentOffset === $from.parent.content.size
const hasContent = $from.parent.textContent.trim().length > 0
const textContent = $from.parent.textContent
// Don't trigger if sentence is complete (ends with punctuation)
const endsWithPunctuation = /[.!?]\s*$/.test(textContent)
// Don't trigger while typing a word that starts with a trigger character
const lastWord = textContent.split(/\s/).pop() || ''
const endsWithTrigger = (options.triggerCharacters || []).some(char => lastWord.startsWith(char))
if (!isAtEndOfBlock || !hasContent || endsWithPunctuation || endsWithTrigger) {
return
}
// Pass editor to let the handler extract content (e.g., as markdown)
if (options.onTrigger(editor) === false) {
return
}
// Set position and mark as visible
storage.position = selection.from
storage.visible = true
}, options.debounce || 250)
},
onDestroy() {
this.storage.debouncedTrigger = null
}
})
export default Completion
It asks for a suggestion while you type, accepts it with ⇥ and dismisses it with Esc. Set its autoTrigger option to false to turn it off.
Creating the completion composable
The composable connects the extension to useCompletion, and exposes a handler for each AI action:
import { useCompletion } from '@ai-sdk/vue'
import type { MappablePosition } from '@tiptap/core'
import type { Editor } from '@tiptap/vue-3'
import { Completion } from '~/components/editor/CompletionExtension'
import type { CompletionStorage } from '~/components/editor/CompletionExtension'
type TransformMode = 'fix' | 'extend' | 'reduce' | 'simplify' | 'summarize' | 'translate'
type CompletionMode = 'continue' | TransformMode
// Only the end of the document is sent as context to continue writing
const MAX_CONTEXT_LENGTH = 2000
export interface UseEditorCompletionOptions {
api?: string
}
export function useEditorCompletion(editorRef: Ref<{ editor: Editor | undefined } | null | undefined>, options: UseEditorCompletionOptions = {}) {
// Range toolbar actions replace with their result, kept in sync with edits made while the request runs
let target: { from: MappablePosition, to: MappablePosition } | undefined
const mode = ref<CompletionMode>('continue')
const language = ref<string>()
function getCompletionStorage() {
const storage = editorRef.value?.editor?.storage as Record<string, CompletionStorage> | undefined
return storage?.completion
}
const { completion, complete, isLoading, stop, setCompletion } = useCompletion({
api: options.api || '/api/completion',
streamProtocol: 'text',
body: computed(() => ({
mode: mode.value,
language: language.value
})),
onFinish: (_prompt, completionText) => {
const editor = editorRef.value?.editor
// Transform modes replace the selection with the full result
if (mode.value !== 'continue' && target && completionText && editor) {
const range = { from: target.from.position, to: target.to.position }
editor.chain()
.focus()
.insertContentAt(range, parseResult(editor, range, completionText))
.run()
}
target = undefined
},
onError: (error) => {
console.error('AI completion error:', error)
target = undefined
getCompletionStorage()?.clearSuggestion()
// Remove the ghost text already displayed
const editor = editorRef.value?.editor
editor?.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
}
})
// Stream "continue" results into the inline suggestion, or into the document for the toolbar action
watch(completion, (newCompletion, oldCompletion) => {
const editor = editorRef.value?.editor
if (!editor || !newCompletion || mode.value !== 'continue') return
const storage = getCompletionStorage()
if (storage?.visible && storage.position !== undefined) {
storage.setSuggestion(withLeadingSpace(editor, storage.position, newCompletion))
editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
} else if (target) {
// The target moves past each inserted chunk
const pos = target.to.position
let delta = newCompletion.slice(oldCompletion?.length || 0)
if (!oldCompletion) {
delta = withLeadingSpace(editor, pos, delta)
}
editor.chain().focus().command(({ tr }) => {
tr.insertText(delta, pos)
return true
}).run()
}
})
// Map the target through every transaction, `editor.utils` handles collaborators' edits too.
// Positions are recreated each time since the Yjs relative position isn't updated by local edits.
watch(() => editorRef.value?.editor, (editor) => {
editor?.on('transaction', ({ transaction }) => {
if (!target) return
setTarget(
editor,
editor.utils.getUpdatedPosition(target.from, transaction).position.position,
editor.utils.getUpdatedPosition(target.to, transaction).position.position
)
})
}, { immediate: true })
function setTarget(editor: Editor, from: number, to: number) {
target = {
from: editor.utils.createMappablePosition(from),
to: editor.utils.createMappablePosition(to)
}
}
// Add a space before the text if it would stick to the previous word
function withLeadingSpace(editor: Editor, pos: number, text: string) {
const charBefore = editor.state.doc.textBetween(Math.max(0, pos - 1), pos)
return charBefore && !/\s/.test(charBefore) && !/^\s/.test(text) ? ` ${text}` : text
}
function getMarkdown(editor: Editor, from: number, to: number) {
const { doc } = editor.state
if (editor.markdown) {
return editor.markdown.serialize(doc.cut(from, to).toJSON())
}
// Fallback to plain text
return doc.textBetween(from, to, '\n')
}
// Parse the result as markdown. Inside a single block, only insert its inline content so the block keeps its type.
function parseResult(editor: Editor, { from, to }: { from: number, to: number }, text: string) {
const textblock = getTextblock(editor, from, to)
// Code is inserted as is
if (textblock?.type.spec.code) {
return [{ type: 'text', text }]
}
const blocks = editor.markdown?.parse(text).content
if (!blocks) return text
if (textblock && blocks.length === 1 && blocks[0]?.type === 'paragraph') {
return blocks[0].content ?? []
}
return blocks
}
// Within a single block, only send its inline content so list or quote markers aren't inserted again
function getSelectionMarkdown(editor: Editor) {
const { $from, $to, from, to } = editor.state.selection
const textblock = getTextblock(editor, from, to)
if (!editor.markdown || !textblock) {
return getMarkdown(editor, from, to)
}
if (textblock.type.spec.code) {
return editor.state.doc.textBetween(from, to)
}
const content = textblock.cut($from.parentOffset, $to.parentOffset).content.toJSON()
return editor.markdown.serialize({ type: 'doc', content: [{ type: 'paragraph', content }] })
}
// The text block containing the whole range, if any
function getTextblock(editor: Editor, from: number, to: number) {
const { doc } = editor.state
const $from = doc.resolve(from)
return $from.parent.isTextblock && $from.sameParent(doc.resolve(to)) ? $from.parent : undefined
}
function completeContinue(editor: Editor, pos: number) {
mode.value = 'continue'
complete(getMarkdown(editor, 0, pos).slice(-MAX_CONTEXT_LENGTH))
}
function triggerContinue(editor: Editor) {
if (isLoading.value) return
getCompletionStorage()?.clearSuggestion()
// Continue after the selection, or from the cursor
const { to } = editor.state.selection
setTarget(editor, to, to)
completeContinue(editor, to)
}
function triggerTransform(editor: Editor, transformMode: TransformMode, lang?: string) {
const { from, to, empty } = editor.state.selection
if (isLoading.value || empty) return
getCompletionStorage()?.clearSuggestion()
mode.value = transformMode
language.value = lang
setTarget(editor, from, to)
complete(getSelectionMarkdown(editor))
}
// Configure Completion extension
const extension = Completion.configure({
onTrigger: (editor) => {
if (isLoading.value) return false
completeContinue(editor, editor.state.selection.from)
},
onAccept: () => {
setCompletion('')
},
onDismiss: () => {
stop()
setCompletion('')
}
})
function createTransformHandler(transformMode: TransformMode) {
return {
canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
execute: (editor: Editor, cmd?: { language?: string }) => {
triggerTransform(editor, transformMode, cmd?.language)
return editor.chain()
},
isActive: (_editor: Editor, cmd?: { language?: string }) => !!(isLoading.value && mode.value === transformMode && language.value === cmd?.language),
isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
}
}
// Create handlers for toolbar
const handlers = {
aiContinue: {
canExecute: () => !isLoading.value,
execute: (editor: Editor) => {
triggerContinue(editor)
return editor.chain()
},
isActive: () => !!(isLoading.value && mode.value === 'continue'),
isDisabled: () => !!isLoading.value
},
aiFix: createTransformHandler('fix'),
aiExtend: createTransformHandler('extend'),
aiReduce: createTransformHandler('reduce'),
aiSimplify: createTransformHandler('simplify'),
aiSummarize: createTransformHandler('summarize'),
aiTranslate: createTransformHandler('translate')
}
return {
extension,
handlers,
isLoading,
mode
}
}
The ghost text streams into the extension, while the text transformations replace the selection once the full answer has arrived. Both send Markdown so the model sees the structure, up to the last 2,000 characters before the cursor to continue writing. A transformation inside a single block only replaces its inline content, so lists, quotes and headings keep their type, and code blocks get plain text. The range to replace is tracked with the mappable positions of editor.utils, so edits made while the model answers don't shift it.
Displaying the AI actions
Add an Improve dropdown to the bubble toolbar, with a loading state while the model answers:
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
// The handlers type the custom `kind` of the items, like `imageUpload`
export function useEditorToolbar<T extends EditorCustomHandlers>(_customHandlers?: T, options: { aiLoading?: Ref<boolean | undefined> } = {}) {
const toolbarItems: EditorToolbarItem<T>[][] = [[{
kind: 'undo',
icon: 'i-lucide-undo',
tooltip: { text: 'Undo' }
}, {
kind: 'redo',
icon: 'i-lucide-redo',
tooltip: { text: 'Redo' }
}], [{
kind: 'imageUpload',
label: 'Add',
icon: 'i-lucide-image',
tooltip: { text: 'Add image' }
}]]
const bubbleToolbarItems = computed(() => [[{
icon: 'i-lucide-sparkles',
label: 'Improve',
activeColor: 'neutral',
activeVariant: 'ghost',
loading: options.aiLoading?.value,
content: {
align: 'start'
},
items: [{
kind: 'aiFix',
label: 'Fix spelling & grammar',
icon: 'i-lucide-spell-check'
}, {
kind: 'aiExtend',
label: 'Extend text',
icon: 'i-lucide-unfold-vertical'
}, {
kind: 'aiReduce',
label: 'Reduce text',
icon: 'i-lucide-fold-vertical'
}, {
kind: 'aiSimplify',
label: 'Simplify text',
icon: 'i-lucide-lightbulb'
}, {
kind: 'aiContinue',
label: 'Continue sentence',
icon: 'i-lucide-text'
}, {
kind: 'aiSummarize',
label: 'Summarize',
icon: 'i-lucide-list'
}, {
label: 'Translate',
icon: 'i-lucide-languages',
children: [{
kind: 'aiTranslate',
language: 'English',
label: 'English'
}, {
kind: 'aiTranslate',
language: 'French',
label: 'French'
}, {
kind: 'aiTranslate',
language: 'Spanish',
label: 'Spanish'
}, {
kind: 'aiTranslate',
language: 'German',
label: 'German'
}]
}]
}], [{
label: 'Turn into',
trailingIcon: 'i-lucide-chevron-down',
activeColor: 'neutral',
activeVariant: 'ghost',
content: {
align: 'start'
},
items: [{
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type'
}, {
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1'
}, {
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2'
}, {
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3'
}, {
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list'
}, {
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered'
}, {
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote'
}, {
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code'
}]
}], [{
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' }
}, {
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' }
}, {
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' }
}, {
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' }
}, {
kind: 'mark',
mark: 'code',
icon: 'i-lucide-code',
tooltip: { text: 'Code' }
}, {
kind: 'link',
icon: 'i-lucide-link',
tooltip: { text: 'Link' }
}]] satisfies EditorToolbarItem<T>[][])
return {
toolbarItems,
bubbleToolbarItems
}
}
And a Continue writing command to the slash commands:
import type { EditorCustomHandlers, EditorSuggestionMenuItem } from '@nuxt/ui'
// The handlers type the custom `kind` of the items, like `imageUpload`
export function useEditorSuggestions<T extends EditorCustomHandlers>(_customHandlers?: T) {
const items: EditorSuggestionMenuItem<T>[][] = [[{
type: 'label',
label: 'AI'
}, {
kind: 'aiContinue',
label: 'Continue writing',
icon: 'i-lucide-sparkles'
}], [{
type: 'label',
label: 'Style'
}, {
kind: 'paragraph',
label: 'Paragraph',
icon: 'i-lucide-type'
}, {
kind: 'heading',
level: 1,
label: 'Heading 1',
icon: 'i-lucide-heading-1'
}, {
kind: 'heading',
level: 2,
label: 'Heading 2',
icon: 'i-lucide-heading-2'
}, {
kind: 'heading',
level: 3,
label: 'Heading 3',
icon: 'i-lucide-heading-3'
}, {
kind: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list'
}, {
kind: 'orderedList',
label: 'Numbered List',
icon: 'i-lucide-list-ordered'
}, {
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote'
}, {
kind: 'codeBlock',
label: 'Code Block',
icon: 'i-lucide-square-code'
}], [{
type: 'label',
label: 'Insert'
}, {
kind: 'mention',
label: 'Mention',
icon: 'i-lucide-at-sign'
}, {
kind: 'emoji',
label: 'Emoji',
icon: 'i-lucide-smile-plus'
}, {
kind: 'imageUpload',
label: 'Image',
icon: 'i-lucide-image'
}, {
kind: 'horizontalRule',
label: 'Horizontal Rule',
icon: 'i-lucide-separator-horizontal'
}]]
return {
items
}
}
Then register the extension and the AI handlers in the page. The composable reads the editor from a template ref:
<script setup lang="ts">
import type { EditorCustomHandlers } from '@nuxt/ui'
import type { Editor } from '@tiptap/vue-3'
import { ImageUpload } from '~/components/editor/ImageUploadExtension'
const editorRef = useTemplateRef('editorRef')
const { extension: Completion, handlers: aiHandlers, isLoading: aiLoading } = useEditorCompletion(editorRef)
const customHandlers = {
imageUpload: {
canExecute: (editor: Editor) => editor.can().insertContent({ type: 'imageUpload' }),
execute: (editor: Editor) => editor.chain().focus().insertContent({ type: 'imageUpload' }),
isActive: (editor: Editor) => editor.isActive('imageUpload'),
isDisabled: undefined
},
...aiHandlers
} satisfies EditorCustomHandlers
const { toolbarItems, bubbleToolbarItems } = useEditorToolbar(customHandlers, { aiLoading })
const { items: mentionItems } = useEditorMentions()
const { items: emojiItems, extension: Emoji } = useEditorEmojis()
const { items: suggestionItems } = useEditorSuggestions(customHandlers)
const { getItems: getDragHandleItems, onNodeChange } = useEditorDragHandle()
const content = ref(`# Welcome to your editor
Start writing here. Everything you type is kept as **Markdown**, from *emphasis* to [links](https://ui.nuxt.com) :sparkles:
- Headings, lists and quotes
- **Bold**, *italic* and \`inline code\``)
</script>
<template>
<UEditor
ref="editorRef"
v-slot="{ editor, handlers }"
v-model="content"
content-type="markdown"
:extensions="[Completion, Emoji, ImageUpload]"
:handlers="customHandlers"
placeholder="Write, type '/' for commands..."
autofocus
class="min-h-screen"
:ui="{ base: 'p-4 sm:p-14', content: 'max-w-4xl mx-auto' }"
>
<div class="sticky top-0 z-10 flex items-center justify-end gap-2 px-4 sm:px-14 py-2 border-b border-default bg-default">
<UEditorToolbar :editor="editor" :items="toolbarItems" />
</div>
<UEditorToolbar
:editor="editor"
:items="bubbleToolbarItems"
layout="bubble"
:should-show="({ view, state }) => view.hasFocus() && !state.selection.empty && !editor.isActive('image') && !editor.isActive('imageUpload')"
/>
<UEditorMentionMenu :editor="editor" :items="mentionItems" />
<UEditorEmojiMenu :editor="editor" :items="emojiItems" />
<UEditorSuggestionMenu :editor="editor" :items="suggestionItems" />
<UEditorDragHandle v-slot="{ ui, onClick }" :editor="editor" @node-change="onNodeChange">
<UButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
size="sm"
aria-label="Add a block"
:class="ui.handle()"
@click="(e: MouseEvent) => {
e.stopPropagation()
const node = onClick()
handlers.suggestion?.execute(editor, { pos: node?.pos }).run()
}"
/>
<UDropdownMenu
v-slot="{ open }"
:modal="false"
:items="getDragHandleItems(editor)"
:content="{ side: 'left' }"
:ui="{ content: 'w-48' }"
@update:open="editor.chain().setMeta('lockDragHandle', $event).run()"
>
<UButton
icon="i-lucide-grip-vertical"
color="neutral"
variant="ghost"
active-variant="soft"
size="sm"
aria-label="Block actions"
:active="open"
:class="ui.handle()"
/>
</UDropdownMenu>
</UEditorDragHandle>
</UEditor>
</template>
Adding real-time collaboration
Collaboration relies on Y.js, a CRDT library that merges the changes of everyone editing the same document. Tiptap's collaboration extensions keep the editor in sync with a Y.js document, and a PartyKit server relays the changes between collaborators.
Installing the dependencies
pnpm add yjs y-partykit @tiptap/extension-collaboration @tiptap/extension-collaboration-caret
yarn add yjs y-partykit @tiptap/extension-collaboration @tiptap/extension-collaboration-caret
npm install yjs y-partykit @tiptap/extension-collaboration @tiptap/extension-collaboration-caret
bun add yjs y-partykit @tiptap/extension-collaboration @tiptap/extension-collaboration-caret
And the PartyKit CLI to run the server:
pnpm add -D partykit
yarn add -D partykit
npm install -D partykit
bun add -d partykit
Creating the PartyKit server
The server hands each WebSocket connection to y-partykit, which syncs the document of the room:
import type * as Party from 'partykit/server'
import { onConnect } from 'y-partykit'
export default class EditorServer implements Party.Server {
constructor(readonly room: Party.Room) {}
onConnect(connection: Party.Connection) {
// Keep the document in the room storage once the last collaborator leaves
return onConnect(connection, this.room, {
persist: { mode: 'snapshot' }
})
}
}
Configure the PartyKit project:
{
"$schema": "https://www.partykit.io/schema.json",
"name": "nuxt-editor",
"main": "party/server.ts",
"compatibilityDate": "2026-10-27"
}
Start the server on localhost:1999 next to your Nuxt dev server:
npx partykit dev
Then add its host to your .env file:
AI_GATEWAY_API_KEY=your-api-key-here
NUXT_PUBLIC_PARTYKIT_HOST=localhost:1999
Creating the collaboration composable
The composable connects to the PartyKit room and returns the Tiptap extensions that sync the editor with it:
import * as Y from 'yjs'
import YPartyKitProvider from 'y-partykit/provider'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCaret from '@tiptap/extension-collaboration-caret'
import type { Extensions } from '@tiptap/core'
export interface CollaborationUser {
name: string
color: string
}
// Tailwind CSS colors, each collaborator gets one as their primary color
export const COLORS: Record<string, string> = {
orange: '#fb923c',
amber: '#fbbf24',
lime: '#a3e635',
emerald: '#34d399',
cyan: '#22d3ee',
sky: '#38bdf8',
violet: '#a78bfa',
fuchsia: '#e879f9',
rose: '#fb7185'
}
const ADJECTIVES = ['Swift', 'Clever', 'Bright', 'Bold', 'Calm', 'Brave', 'Wise', 'Lucky']
const ANIMALS = ['Fox', 'Owl', 'Bear', 'Wolf', 'Eagle', 'Otter', 'Lynx', 'Raven']
function pick<T>(list: T[]) {
return list[Math.floor(Math.random() * list.length)]!
}
export const getRandomName = () => `${pick(ADJECTIVES)} ${pick(ANIMALS)}`
export const getRandomColor = () => pick(Object.keys(COLORS))
export function useEditorCollaboration({ room, host, user }: { room?: string, host?: string, user: CollaborationUser }) {
const extensions = shallowRef<Extensions>([])
const connectedUsers = ref<CollaborationUser[]>([])
const synced = ref(false)
// Without a room and a host, the editor works on its own
if (!room || !host) {
return { enabled: false, ready: ref(true), extensions, connectedUsers, synced }
}
// The Y.js document every collaborator edits
const ydoc = new Y.Doc()
const ready = ref(false)
let provider: YPartyKitProvider | undefined
// The provider opens a WebSocket, so it only connects in the browser
onMounted(() => {
const connection = new YPartyKitProvider(host, room, ydoc)
provider = connection
// Share our name and color with the other collaborators
connection.awareness.setLocalStateField('user', user)
connection.awareness.on('change', () => {
connectedUsers.value = Array.from(connection.awareness.getStates().values())
.map(state => state.user as CollaborationUser | undefined)
.filter((user): user is CollaborationUser => !!user)
})
connection.on('sync', (isSynced: boolean) => {
synced.value = isSynced
})
extensions.value = [
Collaboration.configure({ document: ydoc }),
CollaborationCaret.configure({ provider: connection, user })
]
// The editor waits for the extensions before it renders
ready.value = true
})
onUnmounted(() => {
provider?.destroy()
ydoc.destroy()
})
return { enabled: true, ready, extensions, connectedUsers, synced }
}
Here's what it does:
Without a room
Collaboration only starts with both a room and a host. Otherwise the editor works on its own, like in the previous sections.
The provider
YPartyKitProvider syncs the Y.js document with the room over a WebSocket. It's created in onMounted, so the editor waits for ready before it renders.
Awareness
Each collaborator shares their name and color through the provider's awareness, which also gives the list of people in the room. The CollaborationCaret extension uses it to display their cursors.
Display the people in the room next to the toolbar:
<script setup lang="ts">
import type { CollaborationUser } from '~/composables/useEditorCollaboration'
defineProps<{
users: CollaborationUser[]
}>()
</script>
<template>
<UAvatarGroup v-if="users.length" class="me-auto">
<UTooltip v-for="user in users" :key="user.name" :text="user.name">
<UAvatar :alt="user.name" size="sm" :style="{ color: user.color }" />
</UTooltip>
</UAvatarGroup>
</template>
Updating the configuration
Expose the PartyKit host to the browser through the runtime config:
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@nuxthub/core'],
css: ['~/assets/css/main.css'],
hub: {
blob: true
},
runtimeConfig: {
public: {
// Set with the `NUXT_PUBLIC_PARTYKIT_HOST` environment variable
partykitHost: ''
}
},
vite: {
optimizeDeps: {
// Pre-bundled so the dev server never loads a second copy of Y.js
include: ['yjs', 'y-partykit/provider']
}
}
})
Style the cursors of the other collaborators:
@import "tailwindcss";
@import "@nuxt/ui";
/* The carets and names of the other collaborators */
.collaboration-carets__caret {
border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
.collaboration-carets__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 12px;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
Mention the people in the room instead of the fixed list:
import type { EditorMentionMenuItem } from '@nuxt/ui'
import type { CollaborationUser } from './useEditorCollaboration'
const USERS: EditorMentionMenuItem[] = [{
label: 'benjamincanac',
avatar: { src: 'https://avatars.githubusercontent.com/u/739984?v=4' }
}, {
label: 'atinux',
avatar: { src: 'https://avatars.githubusercontent.com/u/904724?v=4' }
}, {
label: 'danielroe',
avatar: { src: 'https://avatars.githubusercontent.com/u/28706372?v=4' }
}]
// Mention the people in the room, or a fixed list when editing alone
export function useEditorMentions(users?: Ref<CollaborationUser[]>) {
const items = computed<EditorMentionMenuItem[]>(() => {
if (!users?.value.length) {
return USERS
}
return users.value.map(user => ({
label: user.name,
avatar: { alt: user.name, style: { color: user.color } }
}))
})
return {
items
}
}
Joining a room
Connect the page to the room from the ?room= query parameter:
<script setup lang="ts">
import type { EditorCustomHandlers } from '@nuxt/ui'
import type { Editor } from '@tiptap/vue-3'
import { ImageUpload } from '~/components/editor/ImageUploadExtension'
const route = useRoute()
const appConfig = useAppConfig()
const runtimeConfig = useRuntimeConfig()
const editorRef = useTemplateRef('editorRef')
// A random name and color, created on the server so both renders agree
const user = useState('user', () => ({ name: getRandomName(), color: getRandomColor() }))
const { enabled: collaborationEnabled, ready, extensions: collaborationExtensions, connectedUsers, synced } = useEditorCollaboration({
room: typeof route.query.room === 'string' ? route.query.room : undefined,
host: runtimeConfig.public.partykitHost,
user: { name: user.value.name, color: COLORS[user.value.color]! }
})
if (collaborationEnabled) {
appConfig.ui.colors.primary = user.value.color
}
const { extension: Completion, handlers: aiHandlers, isLoading: aiLoading } = useEditorCompletion(editorRef)
const customHandlers = {
imageUpload: {
canExecute: (editor: Editor) => editor.can().insertContent({ type: 'imageUpload' }),
execute: (editor: Editor) => editor.chain().focus().insertContent({ type: 'imageUpload' }),
isActive: (editor: Editor) => editor.isActive('imageUpload'),
isDisabled: undefined
},
...aiHandlers
} satisfies EditorCustomHandlers
const { toolbarItems, bubbleToolbarItems } = useEditorToolbar(customHandlers, { aiLoading })
const { items: mentionItems } = useEditorMentions(connectedUsers)
const { items: emojiItems, extension: Emoji } = useEditorEmojis()
const { items: suggestionItems } = useEditorSuggestions(customHandlers)
const { getItems: getDragHandleItems, onNodeChange } = useEditorDragHandle()
const content = ref(`# Welcome to your editor
Start writing here. Everything you type is kept as **Markdown**, from *emphasis* to [links](https://ui.nuxt.com) :sparkles:
- Headings, lists and quotes
- **Bold**, *italic* and \`inline code\``)
// The extensions are reactive, the collaboration ones load in the browser
const extensions = computed(() => [Completion, Emoji, ImageUpload, ...collaborationExtensions.value])
// With collaboration, the content lives in the Y.js document instead
function onUpdate(value: string) {
if (!collaborationEnabled) {
content.value = value
}
}
// A new room starts empty, it gets the default content once it has synced
watch(() => synced.value && editorRef.value?.editor, (editor) => {
if (editor && editor.isEmpty) {
editor.commands.setContent(content.value, { contentType: 'markdown' })
}
})
</script>
<template>
<UEditor
v-if="ready"
ref="editorRef"
v-slot="{ editor, handlers }"
:model-value="collaborationEnabled ? undefined : content"
content-type="markdown"
:extensions="extensions"
:starter-kit="collaborationEnabled ? { undoRedo: false } : undefined"
:handlers="customHandlers"
placeholder="Write, type '/' for commands..."
autofocus
class="min-h-screen"
:ui="{ base: 'p-4 sm:p-14', content: 'max-w-4xl mx-auto' }"
@update:model-value="onUpdate"
>
<div class="sticky top-0 z-10 flex items-center justify-end gap-2 px-4 sm:px-14 py-2 border-b border-default bg-default">
<EditorCollaborationUsers :users="connectedUsers" />
<UEditorToolbar :editor="editor" :items="toolbarItems" />
</div>
<UEditorToolbar
:editor="editor"
:items="bubbleToolbarItems"
layout="bubble"
:should-show="({ view, state }) => view.hasFocus() && !state.selection.empty && !editor.isActive('image') && !editor.isActive('imageUpload')"
/>
<UEditorMentionMenu :editor="editor" :items="mentionItems" />
<UEditorEmojiMenu :editor="editor" :items="emojiItems" />
<UEditorSuggestionMenu :editor="editor" :items="suggestionItems" />
<UEditorDragHandle v-slot="{ ui, onClick }" :editor="editor" @node-change="onNodeChange">
<UButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
size="sm"
aria-label="Add a block"
:class="ui.handle()"
@click="(e: MouseEvent) => {
e.stopPropagation()
const node = onClick()
handlers.suggestion?.execute(editor, { pos: node?.pos }).run()
}"
/>
<UDropdownMenu
v-slot="{ open }"
:modal="false"
:items="getDragHandleItems(editor)"
:content="{ side: 'left' }"
:ui="{ content: 'w-48' }"
@update:open="editor.chain().setMeta('lockDragHandle', $event).run()"
>
<UButton
icon="i-lucide-grip-vertical"
color="neutral"
variant="ghost"
active-variant="soft"
size="sm"
aria-label="Block actions"
:active="open"
:class="ui.handle()"
/>
</UDropdownMenu>
</UEditorDragHandle>
</UEditor>
</template>
Here's what changes with collaboration:
The content
The document now lives in Y.js, so the editor doesn't receive a model-value. A new room starts empty and gets the default content once it has synced.
Undo and redo
The StarterKit's history would undo the other collaborators' changes too, so it's turned off with the starter-kit prop. The collaboration extension adds undo and redo commands that only undo your own changes.
The colors
Each collaborator gets a random name and color. The color draws their cursor and avatar for the others, and becomes the primary color of their own page.
Open localhost:3000/?room=my-room in two windows and start typing in one of them.
Ignoring collaborators' edits
Remote changes fire the editor's update event too, so every keystroke from a collaborator would ask for a completion and dismiss yours. Update the completion extension to skip them with isChangeOrigin:
import { Extension } from '@tiptap/core'
import { isChangeOrigin } from '@tiptap/extension-collaboration'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/vue-3'
import { useDebounceFn } from '@vueuse/core'
export interface CompletionOptions {
/**
* Debounce delay in ms before triggering completion
* @defaultValue 250
*/
debounce?: number
/**
* Whether to automatically trigger completion while typing
* @defaultValue true
*/
autoTrigger?: boolean
/**
* Characters that prevent completion while typing the word they start (slash commands, emojis, mentions)
* @defaultValue ['/', ':', '@']
*/
triggerCharacters?: string[]
/**
* Called when completion should be triggered, receives the editor instance.
* Return `false` to skip showing a suggestion.
*/
onTrigger?: (editor: Editor) => boolean | undefined
/**
* Called when suggestion is accepted
*/
onAccept?: () => void
/**
* Called when suggestion is dismissed
*/
onDismiss?: () => void
}
export interface CompletionStorage {
suggestion: string
position: number | undefined
visible: boolean
debouncedTrigger: ((editor: Editor) => void) | null
setSuggestion: (text: string) => void
clearSuggestion: () => void
}
export const completionPluginKey = new PluginKey('completion')
function dismiss(editor: Editor, storage: CompletionStorage, options: CompletionOptions) {
storage.clearSuggestion()
// Force decoration update
editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
options.onDismiss?.()
}
export const Completion = Extension.create<CompletionOptions, CompletionStorage>({
name: 'completion',
addOptions() {
return {
debounce: 250,
autoTrigger: true,
triggerCharacters: ['/', ':', '@'],
onTrigger: undefined,
onAccept: undefined,
onDismiss: undefined
}
},
addStorage() {
return {
suggestion: '',
position: undefined as number | undefined,
visible: false,
debouncedTrigger: null as ((editor: Editor) => void) | null,
setSuggestion(text: string) {
this.suggestion = text
},
clearSuggestion() {
this.suggestion = ''
this.position = undefined
this.visible = false
}
}
},
addProseMirrorPlugins() {
const storage = this.storage
const options = this.options
return [
new Plugin({
key: completionPluginKey,
state: {
init: () => null,
apply(tr, _value, oldState, newState) {
// Yjs replaces the whole document on remote changes, so the position can't be mapped. The suggestion
// sits at the cursor, so follow it, and drop the suggestion if a collaborator changed its paragraph.
if (isChangeOrigin(tr) && storage.position !== undefined) {
const { $from } = newState.selection
if ($from.parent.eq(oldState.doc.resolve(storage.position).parent)) {
storage.position = $from.pos
} else {
storage.clearSuggestion()
options.onDismiss?.()
}
}
return null
}
},
props: {
decorations(state) {
if (!storage.visible || !storage.suggestion || storage.position === undefined) {
return DecorationSet.empty
}
const widget = Decoration.widget(storage.position, () => {
const span = document.createElement('span')
span.className = 'completion-suggestion'
span.textContent = storage.suggestion
span.style.cssText = 'color: var(--ui-text-muted); opacity: 0.6; pointer-events: none;'
return span
}, { side: 1 })
return DecorationSet.create(state.doc, [widget])
}
}
})
]
},
addKeyboardShortcuts() {
return {
Tab: ({ editor }) => {
if (!this.storage.visible || !this.storage.suggestion || this.storage.position === undefined) {
return false
}
// Store values before clearing
const suggestion = this.storage.suggestion
const position = this.storage.position
// Clear suggestion first
this.storage.clearSuggestion()
// Force decoration update
editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
// Insert the suggestion text
editor.chain().focus().insertContentAt(position, suggestion).run()
this.options.onAccept?.()
return true
},
Escape: ({ editor }) => {
if (!this.storage.visible) {
return false
}
dismiss(editor as Editor, this.storage, this.options)
return true
}
}
},
onUpdate({ editor, transaction }) {
// Ignore edits from collaborators
if (isChangeOrigin(transaction)) return
// Clear suggestion on any edit
if (this.storage.visible) {
dismiss(editor as Editor, this.storage, this.options)
}
if (this.options.autoTrigger) {
this.storage.debouncedTrigger?.(editor as Editor)
}
},
onSelectionUpdate({ editor, transaction }) {
// Collaborator edits move the selection too
if (isChangeOrigin(transaction)) return
if (this.storage.visible) {
dismiss(editor as Editor, this.storage, this.options)
}
},
onCreate() {
const storage = this.storage
const options = this.options
// Create debounced trigger function for this instance
this.storage.debouncedTrigger = useDebounceFn((editor: Editor) => {
if (!options.onTrigger) return
const { state } = editor
const { selection } = state
const { $from } = selection
// Only suggest at end of block with content
const isAtEndOfBlock = $from.parentOffset === $from.parent.content.size
const hasContent = $from.parent.textContent.trim().length > 0
const textContent = $from.parent.textContent
// Don't trigger if sentence is complete (ends with punctuation)
const endsWithPunctuation = /[.!?]\s*$/.test(textContent)
// Don't trigger while typing a word that starts with a trigger character
const lastWord = textContent.split(/\s/).pop() || ''
const endsWithTrigger = (options.triggerCharacters || []).some(char => lastWord.startsWith(char))
if (!isAtEndOfBlock || !hasContent || endsWithPunctuation || endsWithTrigger) {
return
}
// Pass editor to let the handler extract content (e.g., as markdown)
if (options.onTrigger(editor) === false) {
return
}
// Set position and mark as visible
storage.position = selection.from
storage.visible = true
}, options.debounce || 250)
},
onDestroy() {
this.storage.debouncedTrigger = null
}
})
export default Completion
Y.js replaces the whole document on remote changes, so the position of the suggestion can't be mapped. It sits at the cursor, so it follows the cursor instead, and is dropped if a collaborator changes its paragraph.
Going further
You now have a working Notion-like editor. To take it further, consider adding:
Tables and code highlighting
The Editor template adds tables with @tiptap/extension-table, syntax highlighting in code blocks with Shiki, and dedicated toolbars for images and tables.
Document storage
The PartyKit server keeps each room in its storage. Use the load and callback options of y-partykit to load and save the documents in your own database.
Authentication
The upload and completion routes accept requests from anyone, and anyone who knows a room name can join it. Protect them before you deploy, for example with nuxt-auth-utils, and limit how much each user can upload or generate.
Another collaboration backend
Y.js works with other providers, like Liveblocks or Tiptap Collaboration, if you'd rather not host the server yourself.
Deploying to Vercel
Install the Vercel Blob SDK, which NuxtHub uses to store the images on Vercel:
pnpm add @vercel/blob
yarn add @vercel/blob
npm install @vercel/blob
bun add @vercel/blob
Deploy your editor to Vercel with zero configuration:
npx vercel deploy
Then, in the Vercel dashboard:
- Enable AI Gateway and add credits so requests can be processed.
- Create a Blob store in the Storage tab and connect it to your project.
Deploy the PartyKit server and set its host, like nuxt-editor.username.partykit.dev, as the NUXT_PUBLIC_PARTYKIT_HOST environment variable of your Vercel project:
npx partykit deploy
AI_GATEWAY_API_KEY. Vercel handles the gateway configuration for deployments. Keep using .env locally for development.Conclusion
You've built a Notion-like editor with:
- Rich text editing of Markdown content with Nuxt UI's
UEditor - Toolbars, menus and a drag handle driven by the editor's handlers
- Image uploads with NuxtHub Blob
- AI completions with the AI SDK and AI Gateway
- Real-time collaboration with Y.js and PartyKit
Resources: