---
title: "Storage adapters, comment stores, Nitro host"
description: "Pick where documents live with ready-made EditorPorts adapters, comment stores, a Nuxt-free Nitro host and the useEditorDocument composable."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/storage-adapters"
---
# Storage adapters, comment stores, Nitro host

> Pick where documents live with ready-made EditorPorts adapters, comment stores, a Nuxt-free Nitro host and the useEditorDocument composable.

Ready-made `EditorPorts` bundles ([the ports each storage serves](https://skaile-ai.github.io/forge-editor/guides/editor-ports)), comment stores, a Nuxt-free Nitro host and a Vue composable ship inside this package as subpath entries (formerly the separate package `@skaile/forge-editor-adapters`; from 2.1.0 `@skaile/forge-editor` is the only dependency). A host picks where documents live without touching the editor. All adapters satisfy the same contract test (`test/adapters/adapter-contract.test.ts`).

```sh
bun add @skaile/forge-editor   # plus drizzle-orm only for the three drizzle entries (PGlite or a Postgres driver for the Block index)
```

<table>
<thead>
  <tr>
    <th>
      Entry
    </th>
    
    <th>
      Adapter
    </th>
    
    <th>
      Storage
    </th>
    
    <th>
      Needs
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters/json
      </code>
    </td>
    
    <td>
      <code>
        createJsonAdapter({ dir, users? })
      </code>
    </td>
    
    <td>
      files under <code>
        dir
      </code>
    </td>
    
    <td>
      nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters/drizzle
      </code>
    </td>
    
    <td>
      <code>
        createDrizzleAdapter({ db, tables: editorTables() })
      </code>
    </td>
    
    <td>
      SQLite via drizzle (<code>
        bun:sqlite
      </code>
      
      , better-sqlite3, libsql, D1)
    </td>
    
    <td>
      <code>
        drizzle-orm
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters/forge
      </code>
    </td>
    
    <td>
      <code>
        createForgeAdapter({ content, state, schema, authenticate, … })
      </code>
    </td>
    
    <td>
      the host's Markdown files; state, assets and settings go to a <code>
        state
      </code>
      
       adapter
    </td>
    
    <td>
      nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters/block-index
      </code>
    </td>
    
    <td>
      <code>
        createMemoryBlockIndex()
      </code>
    </td>
    
    <td>
      the Block index (<code>
        ports.index
      </code>
      
      ) in the process; rebuilt from the documents on start
    </td>
    
    <td>
      nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters/block-index/drizzle
      </code>
    </td>
    
    <td>
      <code>
        createDrizzleBlockIndex({ db, tables: blockIndexTables() })
      </code>
      
      , <code>
        bootstrapBlockIndexTables(db)
      </code>
    </td>
    
    <td>
      the Block index in Postgres or PGlite (<code>
        editor_block_index
      </code>
      
      , one <code>
        tsvector
      </code>
      
       per title and text, and <code>
        editor_block_edges
      </code>
      
      , one row per Edge; search runs in the database, scope as a SQL filter); same answers as the memory adapter (<code>
        test/adapters/block-index-contract.test.ts
      </code>
      
      )
    </td>
    
    <td>
      <code>
        drizzle-orm
      </code>
      
      , a Postgres driver (<code>
        @electric-sql/pglite
      </code>
      
      , <code>
        pg
      </code>
      
      , …)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/anchors
      </code>
    </td>
    
    <td>
      <code>
        captureAnchor
      </code>
      
      , <code>
        capturePoint
      </code>
      
      , <code>
        resolveAnchor
      </code>
      
      , <code>
        mapAnchor
      </code>
    </td>
    
    <td>
      pure functions over ProseMirror documents
    </td>
    
    <td>
      <code>
        @tiptap/pm
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/comments/json
      </code>
    </td>
    
    <td>
      <code>
        createJsonCommentStore({ file })
      </code>
    </td>
    
    <td>
      one JSON file (forge's <code>
        concept-discussions.json
      </code>
      
       layout)
    </td>
    
    <td>
      nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/comments/drizzle
      </code>
    </td>
    
    <td>
      <code>
        createDrizzleCommentStore({ db, tables: commentTables() })
      </code>
    </td>
    
    <td>
      SQLite via drizzle
    </td>
    
    <td>
      <code>
        drizzle-orm
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/comments/server
      </code>
    </td>
    
    <td>
      <code>
        handleCommentsRequest(store, { method, documentId, body })
      </code>
    </td>
    
    <td>
      the REST handler a host routes its comments endpoint to
    </td>
    
    <td>
      nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/server/nitro
      </code>
    </td>
    
    <td>
      <code>
        installEditorServer(nitroApp, { ports, collab, ai?, rateLimit?, schema? })
      </code>
    </td>
    
    <td>
      mounts the editor's routes, the collaboration websocket and optional AI and comments routes on a Nitro app (comments: <code>
        ports.comments
      </code>
      
      )
    </td>
    
    <td>
      <code>
        h3
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters/vue
      </code>
    </td>
    
    <td>
      <code>
        useEditorDocument(id, { backend?, user?, canComment?, collaboration? })
      </code>
    </td>
    
    <td>
      the document a Vue editor surface binds to, through <code>
        EditorBackend
      </code>
      
       (<code>
        documents
      </code>
      
      , <code>
        collaboration
      </code>
      
      , <code>
        comments
      </code>
      
      ): <code>
        modelValue
      </code>
      
      , <code>
        dirty
      </code>
      
      , <code>
        save()
      </code>
      
      , <code>
        collaboration
      </code>
      
      , <code>
        comments
      </code>
    </td>
    
    <td>
      <code>
        vue
      </code>
      
      , <code>
        yjs
      </code>
      
      , <code>
        @hocuspocus/provider
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters/react
      </code>
    </td>
    
    <td>
      <code>
        useEditorDocument(id, { backend?, user?, canComment?, collaboration? })
      </code>
    </td>
    
    <td>
      the same for a React surface: <code>
        modelValue
      </code>
      
      , <code>
        onChange
      </code>
      
      , <code>
        ready
      </code>
      
      , <code>
        dirty
      </code>
      
      , <code>
        save
      </code>
      
      , <code>
        reload
      </code>
      
      , <code>
        error
      </code>
      
      , <code>
        collaboration
      </code>
      
      , <code>
        comments
      </code>
      
      , <code>
        bind
      </code>
    </td>
    
    <td>
      <code>
        react
      </code>
      
      , <code>
        yjs
      </code>
      
      , <code>
        @hocuspocus/provider
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters/http
      </code>
    </td>
    
    <td>
      <code>
        createHttpBackend({ fetch?, base?, probe? })
      </code>
    </td>
    
    <td>
      the client port <code>
        EditorBackend
      </code>
      
       (<code>
        createEditorContext({ backend })
      </code>
      
      ) over the editor's <code>
        /api/editor
      </code>
      
       and <code>
        /api/ai
      </code>
      
       routes, the collaboration websocket and the comments endpoint; the default backend of every editor context. <code>
        probe: true
      </code>
      
       asks <code>
        GET /api/editor/capabilities
      </code>
      
       once and drops the capabilities the server does not serve (see below)
    </td>
    
    <td>
      <code>
        @hocuspocus/provider
      </code>
      
      , <code>
        yjs
      </code>
      
      , <code>
        ofetch
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters/direct-ai
      </code>
    </td>
    
    <td>
      <code>
        createDirectAi({ presets, roles?, users?, fetch? })
      </code>
    </td>
    
    <td>
      the <code>
        ai
      </code>
      
       capability of <code>
        EditorBackend
      </code>
      
       without a server: <code>
        models
      </code>
      
      , <code>
        complete
      </code>
      
      , <code>
        generate
      </code>
      
       and <code>
        transcriptCleanup
      </code>
      
       call OpenAI-compatible chat-completion endpoints straight from the page (see below); <code>
        chat
      </code>
      
       and <code>
        extract
      </code>
      
       answer 501
    </td>
    
    <td>
      nothing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/adapters
      </code>
    </td>
    
    <td>
      types only
    </td>
    
    <td>
      
    </td>
    
    <td>
      
    </td>
  </tr>
</tbody>
</table>

Peers: `drizzle-orm` (`^0.45.0`, optional) is needed by `./adapters/drizzle`, `./comments/drizzle` and `./adapters/block-index/drizzle` only; `h3` (optional) by `./server/nitro`; `vue` (optional), `yjs`, `@hocuspocus/provider` by `./adapters/vue`. `bun run check:editor-entries` builds the adapter entries through its fixtures (`adapters-node`: `./adapters`, `./adapters/json`, `./adapters/forge`, `./anchors`, `./comments/server`, `./comments/json`; `adapters-drizzle`: the three drizzle entries; `adapters-nitro`: `./server/nitro`; `adapters-vue`: `./adapters/vue`; `adapters-http`: `./adapters/http`, which holds the REST comments model; `adapters-direct-ai`: `./adapters/direct-ai`, which reaches no AI SDK, server or HTTP client) and proves on the bundles that Vue is only reachable from `./adapters/vue`, Nitro only from `./server/nitro`, drizzle only from the two drizzle entries, and that no adapter entry reaches React (except `./adapters/react`). Entries named `./server/nest` and `./adapters/prisma` are reserved for a later platform adoption and are not exported; the check fails if one of them appears in `exports` before it is designed.

## The tier rule

Three tiers, one direction of dependency:

1. **The editor** (`@skaile/forge-editor`, everything except `adapters/`) and the `EditorPorts` contract (`@skaile/forge-editor/server`). It knows no storage and no host.
2. **Adapters** (`src/adapters/`, the entries above): implementations of that contract and the wiring around it. They depend on the editor; they import no application code, and no entry imports another UI or server framework than the one it is named for.
3. **Hosts**: an app (Nuxt, a bare Nitro server, Vite, a Fastify or tRPC server) that supplies identity (`authenticate`), a store and its own chrome. A host calls these entries; it never reaches into `dist/` internals or table names.

## Adapter matrix

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      <code>
        json
      </code>
    </th>
    
    <th>
      <code>
        drizzle
      </code>
    </th>
    
    <th>
      <code>
        forge
      </code>
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Documents
    </td>
    
    <td>
      <code>
        <id>.json
      </code>
    </td>
    
    <td>
      <code>
        editor_documents
      </code>
    </td>
    
    <td>
      the host's Markdown files
    </td>
  </tr>
  
  <tr>
    <td>
      Collaboration state
    </td>
    
    <td>
      <code>
        <id>.ystate
      </code>
    </td>
    
    <td>
      <code>
        editor_states
      </code>
    </td>
    
    <td>
      the <code>
        state
      </code>
      
       adapter you pass in
    </td>
  </tr>
  
  <tr>
    <td>
      Settings, assets
    </td>
    
    <td>
      files under <code>
        settings/
      </code>
      
      , <code>
        assets/
      </code>
    </td>
    
    <td>
      <code>
        editor_settings
      </code>
      
      , <code>
        editor_assets
      </code>
    </td>
    
    <td>
      the <code>
        state
      </code>
      
       adapter
    </td>
  </tr>
  
  <tr>
    <td>
      Creates documents (<code>
        PUT /api/editor/documents/:id
      </code>
      
      )
    </td>
    
    <td>
      yes
    </td>
    
    <td>
      yes
    </td>
    
    <td>
      no (the host owns its files)
    </td>
  </tr>
  
  <tr>
    <td>
      Default identity
    </td>
    
    <td>
      anonymous dev user outside production
    </td>
    
    <td>
      same
    </td>
    
    <td>
      none: the host supplies <code>
        authenticate
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Comment store
    </td>
    
    <td>
      <code>
        comments/json
      </code>
    </td>
    
    <td>
      <code>
        comments/drizzle
      </code>
    </td>
    
    <td>
      <code>
        comments/json
      </code>
      
       or <code>
        comments/drizzle
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Nodes
    </td>
    
    <td>
      one process
    </td>
    
    <td>
      SQLite writers of the driver
    </td>
    
    <td>
      one process
    </td>
  </tr>
</tbody>
</table>

All three pass the adapter contract test; both comment stores pass `test/adapters/comment-store.test.ts`. The JSON and forge adapters and the JSON comment store are single-node: no cross-process locking.

## Browser-direct AI

By default the `ai` capability is a relay: the browser calls `/api/ai/*`, and the server holds the model key, applies the per-user rate limit (`config.ai.rateLimit`, enforced in the server handlers only) and calls the model. A host without a server, or one that points the editor at a local model, drops the relay: `createDirectAi` is an `EditorBackend['ai']` that runs the same protocol modules as the handlers (`runGenerate`, `runCompletion`, `runTranscriptCleanup`, so prompts, limits and results are identical) and calls `<endpoint>/chat/completions` itself, over a plain `fetch` with a hand-written SSE reader (no AI SDK in the bundle). It goes beside the memory backend:

```ts
import { createDirectAi } from '@skaile/forge-editor/adapters/direct-ai'
import { createMemoryBackend } from '@skaile/forge-editor/adapters/memory'

const backend = {
  ...createMemoryBackend({ users }),
  ai: createDirectAi({
    presets: { local: { label: 'Lokales Modell', endpoint: 'http://localhost:11434/v1', model: 'llama3.1' } },
    roles: { complete: 'local' }, // Model roles without an entry use the first preset
    users: () => ['anna', 'ben'], // offered to tasks that write `@` mentions
  }),
}
app.use(createEditor({ backend }))
```

`endpoint` is the OpenAI-compatible base URL, as for the server's `ModelPresetConfig.endpoint`; `models()` lists the Model presets without their keys, and a request may also carry its own `{ endpoint, model, apiKey }` (the Model profiles users add in the settings). A refused model request (HTTP error, unreachable endpoint) rejects with a `BackendError` carrying the upstream status before anything streams, like the server's non-2xx answers; a failure inside the stream is an `error` event.

Limits to know before choosing it:

- **The key is in the page.** A `presets[].apiKey` is shipped to every visitor and readable in the network panel. Use it for local models, endpoints without a key, or keys the user brings; keep a key that must stay secret behind the server routes.
- **No rate limit.** The per-user limit lives in the server handlers, so nothing throttles direct calls except the model provider itself.
- **chat and extract need a server.** The document chat (tool calls) and `extract` (transcription, OCR, Crossref) answer `BackendError` 501. A host that needs them takes `ai` from the HTTP backend instead (`createHttpBackend` of `@skaile/forge-editor/adapters/http`): `{ ...createMemoryBackend(), ai: createHttpBackend().ai }`, which sends every AI request to `/api/ai`.
- **CORS.** The endpoint must allow the page's origin.

Which capabilities work in the page and which need a server is summarised in [the locality table](https://skaile-ai.github.io/forge-editor/guides/editor-ports#capability-locality).

## Your own store

A host that keeps comments in its own database implements `CommentStore` (`@skaile/forge-editor/comments/server`) and does not have to rewrite the rules. `addComment`, `createThread`, `editComment`, `removeComment`, `setThreadStatus`, `updateAnchor` and `removeThread` are the pure operations the JSON store is built from, and `emptyDiscussion()` is a document without discussion. Each takes the document's `Discussion` (`{ annotations, threads }`), changes it in place and throws a `CommentStoreError` (unknown or duplicate ids, inconsistent input) before it changes anything. A store loads the record, applies the operation and writes the record back:

```ts
import { addComment, createThread, emptyDiscussion, type CommentStore, type Discussion } from '@skaile/forge-editor/comments/server'

// `db`: the host's own storage, one `Discussion` per document id
const load = async (documentId: string): Promise<Discussion> => (await db.get(documentId)) ?? emptyDiscussion()

export const store: Pick<CommentStore, 'getDiscussion' | 'createThread' | 'addComment'> = {
  getDiscussion: load,
  async createThread(documentId, input) {
    const record = await load(documentId)
    const created = createThread(record, input)
    await db.put(documentId, record)
    return created
  },
  async addComment(documentId, threadId, comment) {
    const record = await load(documentId)
    const thread = addComment(record, threadId, comment)
    await db.put(documentId, record)
    return thread
  },
}
```

The other `CommentStore` methods follow the same three steps; `getThread`, `listThreads` and `removeDocuments` need no operation. Serialise the read-modify-write per document (a revision column, a transaction) when several writers share the database.

## Nitro host and composable

```ts
// server/plugins/editor.ts (bare Nitro, no Nuxt)
import { createJsonAdapter } from '@skaile/forge-editor/adapters/json'
import { createJsonCommentStore } from '@skaile/forge-editor/comments/json'
import { installEditorServer } from '@skaile/forge-editor/server/nitro'
import { defineNitroPlugin } from 'nitropack/runtime'

export default defineNitroPlugin((nitroApp) => {
  installEditorServer(nitroApp, {
    ports: {
      ...createJsonAdapter({ dir: '.data/documents' }),
      comments: createJsonCommentStore({ file: '.data/comments.json' }), // optional: mounts /api/editor/comments/:id
    },
    collab: {},                                                   // `false`: no websocket, documents still load and save over HTTP; `{ authenticateUpgrade: true }`: 401 at the upgrade
    // ...serverConfig(defineEditorConfig({ ai: { apiKey: process.env.AI_KEY } })),   // optional: mounts /api/ai/* with the defaults of the AI configuration guide, replaces `collab`/`ai`/`rateLimit`
  })
})
```

`installEditorServer` is the one installer of both hosts: the [Nuxt module](https://skaile-ai.github.io/forge-editor/guides/nuxt-module) calls it from its server plugin, a host without Nuxt calls it from its own; set `experimental: { websocket: true }` in the Nitro config for the collaboration websocket. It mounts `serverRoutesOf(ports, { ai })` (`src/server/routes.ts`), all behind `ports.identity.authenticate`: the editor's client endpoints (`/api/editor/{settings,users,tags,assets/…,themes/…}`, each served only when its port is present), `GET`/`PUT /api/editor/documents/:id` (a document's TipTap JSON; `PUT` needs a `documents` port that also manages documents: `putDocument`), `GET`/`POST /api/editor/comments/:id` (`handleCommentsRequest` over `ports.comments`, mounted only when that port is present), `/api/ai/*` with `ai`, and `/collab/:id` (the Hocuspocus websocket) unless `collab: false`. It returns the `EditorServer` handle: `forgetDocument(id)`, `liveContent(id)`, `enforceRateLimit(request)` and `resolveModel(ref, user)` act on the collaboration server, AI models and rate limiter the routes use (a Nuxt host reaches it with `useEditorServer()`). The returned object (`ports`, `collab`, `models`, `close()`) is for tests and shutdown; Nitro's `close` hook already calls it.

```vue
<script setup lang="ts">
import { useEditorDocument } from '@skaile/forge-editor/adapters/vue'
import { commentsFeature } from '@skaile/forge-editor/comments/vue'
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { EditorSurface } from '@skaile/forge-editor/vue'
import { ref } from 'vue'

const id = ref('welcome')
const doc = useEditorDocument(id, { user: { id: 'u1', name: 'Anna', color: '#e11d48' } }) // backend: the one `createEditor({ backend: createHttpBackend() })` installed
const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), commentsFeature({ model: () => doc.comments.value! })] })
</script>

<template>
  <button :disabled="!doc.dirty.value" @click="doc.save()">Save</button>
  <EditorSurface v-if="doc.ready.value" :key="id" :preset="preset" v-bind="doc.bind.value" />
</template>
```

`useEditorDocument(id, options)` loads and saves through `backend.documents` (the HTTP backend: `GET`/`PUT /api/editor/documents/:id`; a missing document, `null`, starts empty and is created by the first `save()`) and returns `modelValue`, `ready`, `dirty`, `save()`, `reload()`, `error`, `collaboration`, `comments` (made by `backend.comments`: the REST comments model of `/api/editor/comments/:id` over HTTP), `editor` and `bind` (the surface's props and events). The backend is `options.backend`, else the one of the editor context (`createEditor({ backend })`, see the `/adapters/http` row above); the default context backend has neither collaboration nor comments, so a host that needs them installs `createHttpBackend()`. `dirty` ignores the block ids the editor assigns when it mounts content; they are saved with the next edit. With `collaboration: true` (or `{ token? }`, the token the collaboration server's `AccessControl.authenticate` receives) the Y.Doc is the document: `backend.collaboration.connect(id, token)` opens it, the server loads and stores the content, nothing is fetched or saved by the composable, and `modelValue` stays `undefined`; a backend without the `collaboration` capability makes the call throw `@skaile/forge-editor: useEditorDocument needs backend capability "collaboration"…`. Mount the surface with `:key="id"` and `v-if="doc.ready.value"`. The React twin, `useEditorDocument(id, options)` from `@skaile/forge-editor/adapters/react`, takes the same options and returns plain values (`modelValue`, `onChange`, `ready`, `dirty`, `save`, `reload`, `error`, `collaboration`, `comments`, `editor`, `bind`) and reads its backend from `EditorContextProvider`; `bind` spreads onto the surface as `value`, `onChange`, `onReady` and `collaboration`, and a `null` or `undefined` id keeps it idle (no document open yet).

Every adapter returns an `EditorPorts` bundle with only the ports it serves (`json` and `drizzle`: identity, documents with `putDocument`/`deleteDocument`/`listDocumentIds`, collabState, directory, assets, settings; `forge`: identity, documents and, when its `state` has them, collabState, assets, settings, themes). All take the same hooks: `authenticate({ headers, token })` (the user behind a request or collaboration socket, `null` refuses; default: an anonymous development user outside `NODE_ENV=production`, nobody in production; the forge adapter has no default), `authorize(user, documentId)` (per-document access for collaboration, `{ readOnly } | null`; omitted = every authenticated user edits every document) and `users()` (who can be `@`-mentioned). Host nodes for the collaboration schema are the server's `schema` option, not an adapter hook. Comment stores are the `comments` port: `{ ...createJsonAdapter({ dir }), comments: createJsonCommentStore({ file }) }`.

The runnable app is [`examples/vue-nitro-json`](https://github.com/skaile-ai/forge-editor/tree/main/examples/vue-nitro-json): `bun run proto:vue-nitro-json` serves it on port 3046, documents land in `examples/vue-nitro-json/.data/documents/<id>.json`, comments in `examples/vue-nitro-json/.data/comments.json`.


## Sitemap

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