Editor.js
This page shows Editor.js used two ways: as a form field adapter inside a page, and standalone as a plain controlled component outside any schema.
Install:
npm install @vibeflui/editors @editorjs/editorjsWith no tools configured, Editor.js only renders its built-in paragraph blocks plus inline Bold/Italic/Link — that is the "text only" experience. This example wires the rest of Editor.js's block tools (Header, List, Checklist, Quote, Warning, Table, Code, and more) so both panels below show the full editor, not just paragraphs.
npm install @editorjs/header @editorjs/list @editorjs/checklist @editorjs/quote \
@editorjs/warning @editorjs/delimiter @editorjs/table @editorjs/code \
@editorjs/embed @editorjs/raw @editorjs/marker @editorjs/inline-code @editorjs/underlineImage and Link preview are left out of this example: both need a server endpoint (file storage, Open Graph scraping) that this docs site does not run. See "Registering tools" below for their config shape and Editors for the exact request/response contract your backend must implement.
Editor.js as a form field
Why FluiKitForm directly
The page file below renders FluiKitForm directly instead of <FluiKit>. This is deliberate: <FluiKit> only opens a form.mode: "page" surface from a Create trigger button (its stand-in for the table toolbar when table.enabled is false), so the form is hidden behind an extra click. That fits a resource page where creating is one of several actions, but not a dedicated route like app/articles/new/page.tsx, where the form should already be visible the moment the user lands there. Use FluiKitForm directly (see Direct Usage) whenever a route's entire purpose is showing one form.
File structure
schemas/
articles/
article.schema.ts
lib/
vibeflui/
registry.tsx
editorjs-tools.ts
app/
articles/
new/
page.tsxSchema file
schemas/articles/article.schema.ts
export const articleSchema = {
resource: "articles",
identity: { key: "id", param: "id" },
table: { enabled: false },
form: {
mode: "page",
fields: [
{ name: "title", label: "Title", required: true },
{ name: "content", label: "Content", type: "json", adapter: "editorjs" }
]
},
actions: {
create: { enabled: true, label: "Save article", handler: "articles.create" }
},
registry: {
fieldAdapters: ["editorjs"],
actionHandlers: ["articles.create"]
},
feedback: {
mode: "inline"
}
} as const;The field value is Editor.js's own OutputData shape ({ time, blocks, version }), not a string, so it pairs with type: "json" rather than a dedicated field type.
Registry file
lib/vibeflui/registry.tsx
import { createRegistry } from "@vibeflui/core";
export const vibefluiRegistry = createRegistry({
actionHandlers: {
"articles.create": async ({ values }) => ({
status: true,
code: 200,
message: "Article created",
data: values
})
}
});Editor.js tools file
Tool classes cannot live in a JSON-friendly schema, so they are imported and wired here, once, and passed into the plugin.
lib/vibeflui/editorjs-tools.ts
import Header from "@editorjs/header";
import EditorjsList from "@editorjs/list";
import Checklist from "@editorjs/checklist";
import Quote from "@editorjs/quote";
import Warning from "@editorjs/warning";
import Delimiter from "@editorjs/delimiter";
import EditorjsTable from "@editorjs/table";
import CodeTool from "@editorjs/code";
import Embed from "@editorjs/embed";
import RawTool from "@editorjs/raw";
import Marker from "@editorjs/marker";
import InlineCode from "@editorjs/inline-code";
import Underline from "@editorjs/underline";
import { buildEditorJsTools } from "@vibeflui/editors";
export const editorJsTools = buildEditorJsTools({
header: Header,
list: EditorjsList,
checklist: Checklist,
quote: Quote,
warning: Warning,
delimiter: Delimiter,
table: EditorjsTable,
code: CodeTool,
embed: Embed,
raw: RawTool,
marker: Marker,
inlineCode: InlineCode,
underline: Underline
});buildEditorJsTools only registers a tool whose class was actually passed in, and applies sensible per-tool defaults (inline toolbar where it fits, Header's levels, Embed's common service list) automatically.
Page file
app/articles/new/page.tsx
"use client";
import { useState } from "react";
import {
FluiKitFeedback,
FluiKitForm,
FluiKitProvider,
executeOperation,
normalizeSchema,
type FluiKitFeedbackMessage
} from "@vibeflui/core";
import { editorJsPlugin } from "@vibeflui/editors";
import { articleSchema } from "@/schemas/articles/article.schema";
import { vibefluiRegistry } from "@/lib/vibeflui/registry";
import { editorJsTools } from "@/lib/vibeflui/editorjs-tools";
const schema = normalizeSchema(articleSchema);
const editorsPlugin = editorJsPlugin({ tools: editorJsTools });
export default function NewArticlePage() {
const [feedback, setFeedback] = useState<FluiKitFeedbackMessage | null>(null);
const handleSubmitArticle = async (values: Record<string, unknown>) => {
const result = await executeOperation({
schema,
operation: "create",
values,
registry: vibefluiRegistry
});
setFeedback({
status: "success",
title: "Saved",
message: String((result.data as { message?: unknown } | undefined)?.message ?? "Article saved")
});
};
return (
<FluiKitProvider plugins={[editorsPlugin]} registry={vibefluiRegistry}>
<FluiKitFeedback
schema={schema}
mode="inline"
feedback={feedback}
onClose={() => setFeedback(null)}
/>
<FluiKitForm
schema={schema}
mode="create"
onSubmit={handleSubmitArticle}
/>
</FluiKitProvider>
);
}plugins={[editorsPlugin]} registers the editorjs field adapter on the same FluiKitProvider this page renders — there is no separate app-wide providers file to keep in sync with this one.
Registering tools
Editor.js tools (Header, List, and other @editorjs/* packages) cannot live in a JSON-friendly schema, so they are wired at plugin registration time (see the tools file above), not per-field.
A specific field can still narrow which of those registered tools it enables, or supply its own image/link endpoint, through field.adapterProps — this is schema-safe since it's plain strings, not classes:
{
name: "content",
label: "Content",
type: "json",
adapter: "editorjs",
adapterProps: {
tools: ["header", "list", "quote"]
}
}Image and Link preview call a server endpoint that VibeFlui doesn't provide (it only owns the FE/UI layer). Once you have one, pass it either globally when building the tools:
buildEditorJsTools(
{ image: ImageTool, linkTool: LinkTool /* ...other tools */ },
{
image: { endpointByFile: "/api/editorjs/upload-image" },
link: { endpoint: "/api/editorjs/link-preview" }
}
);or per field through adapterProps.image / adapterProps.link. See Editors for the exact request/response contract each endpoint must implement.
Editor.js standalone (outside a form)
EditorJsEditor is a plain controlled component. It does not need <FluiKit>, <FluiKitForm>, or a registry.
components/articles/article-body-editor.tsx
"use client";
import { useState } from "react";
import { EditorJsEditor, type EditorJsOutputData } from "@vibeflui/editors";
import { editorJsTools } from "@/lib/vibeflui/editorjs-tools";
export function ArticleBodyEditor() {
const [value, setValue] = useState<EditorJsOutputData>();
return <EditorJsEditor value={value} onChange={setValue} tools={editorJsTools} />;
}tools is optional here too — pass the same tools file used for the form field adapter (as above), or omit it for paragraph plus inline Bold/Italic/Link only.
Use this pattern for non-schema surfaces such as a comment box, a notes panel, or a custom draft editor that never goes through FluiKit.