Quick start: React
The React layer (@skaile/forge-editor/react, shadcn/ui on Radix and Tailwind 4) renders the same framework-free core as the Vue layer: the simple editor, EditorToolbar, the settings panel and modal, and the @, # and / suggestion popups. It uses no Vue, Nuxt UI or Nuxt module. The runnable reference is examples/react-simple; start it with bun run --cwd examples/react-simple dev (port 3041). It has the same sample picker, read-only toggle, host /datum command and JSON and Markdown panes as the Vue example, plus @ mentions.
More than the simple editor: every advanced feature has a React binding as well (./<x>/react: media, transcript, Typed blocks, authors, comments, AI, collaboration, block UI, themes, the Block outline and the Document tree), and composeEditor(options) / advancedEditor() / AdvancedEditor from @skaile/forge-editor/react/advanced compose them all; see Advanced editor in React. coreContentFeature(), coreUiFeature(), mentionsFeature() and the three integrations have no components and work in both layers; a React preset is defineEditor({ features }) over features from ./<x>/react and such component-free features.
Setup
package.jsondependencies:@skaile/forge-editor(workspace:*inside this repository),react,react-dom,@tiptap/core,@tiptap/pm,@tiptap/react,@iconify/react,@iconify-json/lucide,tailwindcss,tw-animate-css,yjs,@hocuspocus/provider(the last two as in the README: source-shipped shared types reference them); dev dependenciesvite,@tailwindcss/vite,@vitejs/plugin-react,typescript,@types/react,@types/react-dom. Use the exact version ranges of the example'spackage.json.index.htmlwith<div id="app"></div>and<script type="module" src="/src/main.tsx"></script>.tsconfig.jsonwithjsx: "react-jsx",moduleResolution: "Bundler",strictandinclude: ["src"](see the example'stsconfig.json).
vite.config.ts:
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({ plugins: [react(), tailwindcss()] })
index.css — Tailwind, the animation utilities the shadcn dialog, dropdown and tooltip use, the library sheets, and a @source so Tailwind generates the utility classes the library's React code uses (Tailwind skips node_modules unless told otherwise):
@import "tailwindcss";
@import "tw-animate-css";
/* ui-vars.css maps the shadcn tokens (--primary, --background, …) to the --ui-* variables the feature sheets read. */
@import "@skaile/forge-editor/react/styles/ui-vars.css";
@import "@skaile/forge-editor/styles/core.css";
@import "@skaile/forge-editor/styles/mentions.css"; /* the sheet of each feature you use */
@source "../node_modules/@skaile/forge-editor/dist"; /* relative path from this CSS file to the installed package's dist */
/* The shadcn theme block follows: @custom-variant dark, @theme inline, :root and .dark tokens, @layer base.
`bunx shadcn@latest init` generates it; examples/react-simple/src/index.css has a complete copy. */
The @source covers the whole dist, not just dist/react: the Features' own React components (features/*/react, e.g. the image studio dialog), the surfaces (document, markdown, code), the outline and the tree sit beside it, and a host that scans only dist/react renders them without their layout classes. In this repository the examples point at the sibling's src (../../../packages/editor/src) instead.
main.tsx — icons and the editor context. The toolbar and menus render i-lucide-* names through @iconify/react; preloading the collection means the page makes no request to the Iconify API. The host owns the context from createEditorContext() (@skaile/forge-editor/core) and every React component of the library reads it from EditorContextProvider; the provider never disposes it, call context.dispose() yourself when the context outlives your app. createEditorContext takes the same options as the Vue createEditor (the default backend syncs the settings with GET/PUT /api/editor/settings; { backend: createMemoryBackend() } keeps them local, { backend: createHttpBackend({ fetch }) } brings your own HTTP client):
import lucide from '@iconify-json/lucide/icons.json'
import { addCollection } from '@iconify/react'
import { createEditorContext } from '@skaile/forge-editor/core'
import { EditorContextProvider } from '@skaile/forge-editor/react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './index.css'
addCollection(lucide)
const context = createEditorContext()
createRoot(document.getElementById('app')!).render(
<StrictMode>
<EditorContextProvider context={context}>
<App />
</EditorContextProvider>
</StrictMode>,
)
Notices of app-level models (for example the AI model profiles) go through the context's notifier. The simple editor raises none, so createEditorContext() is enough here; hosts of features pass createEditorContext({ notify: reactNotifier }) and mount sonner's Toaster once (both from @skaile/forge-editor/react, see Advanced editor in React).
Mount the editor
EditorSurface takes a preset; EditorToolbar is a sibling that works on the editor you pass it (from onReady), never on a global active editor. The toolbar renders the groups of the preset registered for that editor; without an editor it renders none.
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { mentionsFeature } from '@skaile/forge-editor/mentions'
import { EditorSurface, EditorToolbar } from '@skaile/forge-editor/react'
import type { Editor, JSONContent } from '@tiptap/core'
import { useState } from 'react'
// Module constants: the preset, `metadata`, `abilities`, `extensions` and `commands` are read once at mount.
const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), mentionsFeature()] })
const metadata = { mentions: [{ id: 'u1', label: 'Ada Lovelace' }, { id: 'u2', label: 'Alan Turing' }] }
export function App() {
const [editor, setEditor] = useState<Editor | null>(null)
const [content, setContent] = useState<JSONContent>({ type: 'doc', content: [{ type: 'paragraph' }] })
return (
<>
<EditorToolbar editor={editor ?? undefined} />
<EditorSurface
preset={preset}
metadata={metadata}
value={content}
onChange={setContent}
onReady={setEditor}
onContentError={error => console.warn(error.message)}
/>
</>
)
}
Typing @Ad opens the mention popup with the mentions list. A list the host does not supply (here hashtags) comes from /api/editor/tags through the context's HTTP client, and only once the # popup is used. The simple editor's / menu lists the core blocks; pass commands={defaults => [...defaults, ...]} (a module function) to add own entries.
The one-liner: SimpleEditor
Without own features, SimpleEditor from @skaile/forge-editor/react/simple builds the preset itself (core-content + core-ui, plus the features in its add prop) on an EditorSurface. The toolbar takes its groups from the editor it is given; the ref exposes { editor, openSettings, preset }:
import { EditorToolbar } from '@skaile/forge-editor/react'
import { SimpleEditor } from '@skaile/forge-editor/react/simple'
import type { Editor } from '@tiptap/core'
import { useState } from 'react'
export function App() {
const [editor, setEditor] = useState<Editor | null>(null)
return (
<>
<EditorToolbar editor={editor ?? undefined} />
<SimpleEditor onReady={setEditor} />
</>
)
}
Try the React example here:
Next steps
The tutorial shows the same editor on the React surface. The advanced editor on React is covered in Advanced editor in React; every entry is listed in the exports reference.