---
title: "Chapter 2: A simple editor"
description: "Mount SimpleEditor with a JSON v-model, add the toolbar and the outline beside it, switch it read-only and handle rejected content."
canonical_url: "https://skaile-ai.github.io/forge-editor/tutorial/simple-editor"
---
# Chapter 2: A simple editor

> Mount SimpleEditor with a JSON v-model, add the toolbar and the outline beside it, switch it read-only and handle rejected content.

`SimpleEditor` is the two core features alone (`core-content` and `core-ui`): bold, italic, underline, strike, code and link, headings, lists, quotes, code blocks, a horizontal rule, undo and redo. It makes no backend request, has no Yjs and no feature code. This is the right start for meeting notes.

Try it first. The toolbar and the outline sit beside the editor, the JSON pane shows the document the editor holds, and the switch makes it read-only:

<demo-simple-editor>



</demo-simple-editor>

## The editor context

Every editor component reads shared state (settings, language, backend) from an editor context that the app installs once. `createEditor()` from `@skaile/forge-editor/vue` is that context as a Vue plugin; Nuxt UI's `ui` plugin provides the components. The example's `main.ts` in full:

```ts [examples/vue-simple/src/main.ts]
import ui from '@nuxt/ui/vue-plugin'
import { createMemoryBackend } from '@skaile/forge-editor/adapters/memory'
import { createEditor } from '@skaile/forge-editor/vue'
import { nuxtUiKit } from '@skaile/forge-editor/vue/kit/nuxt-ui'
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'

// No data layer: documents, dropped files (as blob: URLs), mention users and tags, and settings live in the page.
const backend = createMemoryBackend({
  users: [
    { id: 'u1', username: 'Ada Lovelace', color: '#7c3aed' },
    { id: 'u2', username: 'Alan Turing', color: '#0ea5e9' },
    { id: 'u3', username: 'Grace Hopper', color: '#16a34a' },
  ],
  tags: ['idee', 'entwurf', 'wichtig'],
})

createApp(App)
  .use(ui)
  .use(createEditor({ kit: nuxtUiKit(), backend }))
  .mount('#app')
```

- `nuxtUiKit()` tells the library's Vue layer to render its toolbar, menus and dialogs with Nuxt UI components.
- `createMemoryBackend()` (`@skaile/forge-editor/adapters/memory`) keeps everything in the page: settings in `localStorage`, dropped files as `blob:` URLs, and the lists behind `@` and `#`. The simple editor does not use the lists; chapter 3 does. Without a `backend` option the context talks to the HTTP routes of the Nuxt module instead ([Nuxt module](https://skaile-ai.github.io/forge-editor/guides/nuxt-module)).
- `createEditor({ locale: 'en' })` switches the library's own strings to English.

Then the stylesheet. Tailwind, Nuxt UI, then the core CSS, and a `@source` line so Tailwind generates the utility classes the library's Vue files use:

```css
@import "tailwindcss";
@import "@nuxt/ui";
@import "@skaile/forge-editor/vue/kit/nuxt-ui.css";
@import "@skaile/forge-editor/styles/core.css";
@source "../node_modules/@skaile/forge-editor/dist";
```

The `@source` path is relative to the CSS file and points to the installed library's `dist` directory: the whole of it, because the Features' components (`features/*/components`) sit beside `vue`. In this repository the examples point at the sibling's `src` (`../../../packages/editor/src`) instead. The order matters: [CSS and UI-provider requirements](https://skaile-ai.github.io/forge-editor/reference/css-and-ui) lists every sheet and why it comes where it does.

## Mount the editor

`v-model` carries the document as TipTap JSON. `EditorToolbar` and `EditorOutline` are siblings of the editor, not children: they work on exactly the editor you hand them, taken from the `ready` event. Keep that instance in a `shallowRef`; a deep `ref` would wrap the TipTap instance in a Vue proxy.

```vue
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import type { Editor, JSONContent } from '@tiptap/core'
import { SimpleEditor } from '@skaile/forge-editor/vue/simple'
import { EditorToolbar } from '@skaile/forge-editor/vue'
import { EditorOutline } from '@skaile/forge-editor/outline/vue'

const editor = shallowRef<Editor>()
const content = ref<JSONContent>({ type: 'doc', content: [{ type: 'paragraph' }] })
</script>

<template>
  <UApp>
    <EditorToolbar :editor="editor" />
    <SimpleEditor v-model="content" @ready="editor = $event" />
    <EditorOutline :editor="editor" />
  </UApp>
</template>
```

`<UApp>` provides the overlays, tooltips and toasts the toolbar uses. The example's own template adds a sample picker, the read-only switch and two output panes. The part that matters here is how the editor, its toolbar and its outline are wired:

```vue [examples/vue-simple/src/App.vue]
<template>
  <UApp>
    <main class="mx-auto flex max-w-7xl flex-col gap-4 p-6">
      <h1 class="text-xl font-semibold">@skaile/forge-editor – SimpleEditor</h1>

      <div class="flex flex-wrap items-center gap-3">
        <USelect
          v-model="sample"
          :items="SAMPLES"
          value-key="id"
          label-key="label"
          class="w-64"
          aria-label="Beispiel"
          data-testid="sample"
        />
        <USwitch v-model="editable" label="Bearbeitbar" data-testid="editable" />
        <span class="text-sm text-muted" data-testid="edits">Änderungen: {{ edits }}</span>
      </div>

      <UAlert v-if="contentError" color="error" variant="subtle" title="Inhalt abgelehnt" :description="contentError" data-testid="content-error" />

      <div class="grid grid-cols-[14rem_minmax(0,1fr)_20rem] gap-6">
        <aside aria-label="Gliederung">
          <EditorOutline :editor="editor" />
        </aside>
        <div class="flex min-w-0 flex-col gap-3">
          <EditorToolbar :editor="editable ? editor : undefined" />
          <div class="rounded-lg border border-default p-4" data-testid="surface">
            <SimpleEditor
              :model-value="content"
              :editable="editable"
              :extensions="extensions"
              :commands="hostCommands"
              @ready="onReady"
              @update:model-value="onUpdate"
              @content-error="onContentError"
            />
          </div>
        </div>
        <div class="flex min-w-0 flex-col gap-3">
          <section>
            <h3 class="mb-1 text-sm font-semibold">JSON</h3>
            <pre class="max-h-72 overflow-auto rounded-lg bg-elevated p-3 text-xs" data-testid="json">{{ json }}</pre>
          </section>
          <section>
            <h3 class="mb-1 text-sm font-semibold">Markdown</h3>
            <pre class="max-h-72 overflow-auto rounded-lg bg-elevated p-3 text-xs" data-testid="markdown">{{ markdown }}</pre>
          </section>
        </div>
      </div>
```

Three details:

- **Read-only.** `editable` is reactive. The example hands the toolbar `undefined` while the editor is read-only; without an editor the toolbar renders no groups.
- **The editor owns the document.** `v-model` mirrors it as JSON. Assigning a new object to the model replaces the document without an update event (the editor calls `setContent(json, { emitUpdate: false })`), so loading a sample does not count as an edit. JSON the editor emitted itself and you hand back unchanged is recognised and ignored, so the usual `v-model` round trip keeps the caret and the undo history.
- **Props that are read once.** `extensions`, `commands` and `abilities` are read when the component is set up. Pass constants, or remount with a new `key`.

## Rejected content: `contentError`

The simple schema is not a lossy view of richer documents. Initial and external JSON is checked against the installed schema. A document with unknown node or mark types, for example `{ type: 'doc', content: [{ type: 'typedBlock' }] }` (the node of a Typed block), is rejected: `contentError` fires with the reason and the **last valid document stays** in the editor. Nothing is flattened or silently dropped. The `v-model` value is your own state, so move it back when you want it to follow the editor. This is how the example handles its „Ungültig (Block-JSON)“ sample: the alert shows the reason, and the model and the picker return to the previous sample.

```vue [examples/vue-simple/src/App.vue]
// `shown` is the sample the editor currently shows. The editor keeps its last valid document when it rejects a
// load; the model and the picker follow it back.
let shown = firstSample.id
let rejectedLoad: { id: string, content: JSONContent } | undefined
watch(sample, (id) => {
  const next = SAMPLES.find(s => s.id === id)
  if (!next || id === shown) return
  rejectedLoad = { id: shown, content: content.value }
  shown = id
  contentError.value = ''
  content.value = next.content
})
function onContentError(error: Error) {
  contentError.value = error.message
  if (!rejectedLoad) return
  content.value = rejectedLoad.content
  shown = rejectedLoad.id
  sample.value = rejectedLoad.id
  rejectedLoad = undefined
}
```

The full list of props and events of `SimpleEditor` is in [Quick start: plain Vue](https://skaile-ai.github.io/forge-editor/getting-started/quick-start-vue). The editor is isolated on purpose: `bun run check:editor-entries` proves that the simple bundle contains no feature, AI SDK, Yjs, Hocuspocus or server module.

## What you have now

A Vue app with a working rich-text editor: JSON `v-model`, a toolbar and an outline that follow the editor you give them, a read-only mode, and a defined behaviour for documents the schema rejects. The editor has no features beyond the core ones yet.

## Next

[Chapter 3: Compose features](https://skaile-ai.github.io/forge-editor/tutorial/compose-features) replaces `SimpleEditor` with an editor you assemble from features.


## Sitemap

See the full [sitemap](https://skaile-ai.github.io/forge-editor/sitemap.md) for all pages.
