New Froala Editor v5.5.0 is here – Learn More
Froala Documentation
- Installation Guides
- Browser Support
- Languages Support
- Shortcuts
- Activation
- Examples
- Customize the Editor
- Use-cases
- Plugins
- APIs
- Development Frameworks
- Server Integrations
- Server SDKs
- Migration Guides
- Changelog
- Tutorials
Add Real-Time Collaboration to Froala Editor
Connect Froala Editor to a small Node.js server and everyone in a document sees edits as they happen. Comments, suggestions and saved versions go to SQLite, so they stay in place after a reload. Everything runs on your own server, with no third-party collaboration cloud.
How real-time collaboration works in Froala
To add real-time collaboration to Froala Editor, turn on the collaboration plugin with collabConfig and connect it to a small Node.js server. The server has two jobs. A WebSocket relay passes live edits between everyone in the same document. REST routes from the Froala Node.js SDK store comments, suggestions, versions and the latest HTML in SQLite.
Try Froala real-time collaboration live
The demo is running on CodeSandbox.
Things to try
- Select some text and press
Ctrl + M(Cmd + Mon Mac) to add a comment. - Switch the mode selector to Suggesting, then delete a word. The deletion becomes a suggestion (like track changes in Word) that another person can accept or reject.
- Save a named version from the version history menu, change a few words, then restore it.
- Reload the page. Your comments and versions are still there because they live in the database.
Shared document
Everyone who opens the demo edits the same document. Keep personal or confidential content out of it. First load
CodeSandbox pauses idle servers. If the editor takes a moment to connect, the server is waking up. Forking the sandbox
Your fork gets its own preview URL, such as https://abc123-3000.csb.app. Open .env in your fork, set ALLOWED_ORIGINS to that URL with no trailing slash, then restart the server. The server accepts live connections from listed origins, so this step turns on real-time sync in your copy. Example code: Node.js, WebSockets and SQLite
The example has four files. The editor page and the API share one origin, which keeps cookies and CORS simple.
public/index.html
The editor page. collabConfig points the collaboration plugin at the server, and a short fetch seeds new sessions with the last saved copy.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Froala collaboration example</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/froala-editor@5.5.0/css/froala_editor.pkgd.min.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/froala-editor@5.5.0/css/froala_style.min.css"
/>
</head>
<body>
<div id="editor"></div>
<script src="https://cdn.jsdelivr.net/npm/froala-editor@5.5.0/js/froala_editor.pkgd.min.js"></script>
<script>
// The API and the WebSocket relay share this page's origin.
const origin = window.location.origin;
const wsOrigin = origin.replace(/^http/, "ws");
// TEMPORARY: one shared room. In your app, read the id from the route.
const docId = "my-doc";
// TEMPORARY: starter text for a brand-new room. In your app, use "".
const STARTER_HTML =
"<h2>Welcome to the collaborative editor</h2>" +
"<p>Everyone who opens this page edits this document at the same time.</p>";
// The relay keeps no history, so seed the editor with the last copy
// the save plugin stored. Keep this fetch in your app.
async function loadSavedHtml() {
try {
const res = await fetch(origin + "/collab/" + docId + "/content");
if (!res.ok) return STARTER_HTML;
const data = await res.json();
return data.content || STARTER_HTML;
} catch (err) {
return STARTER_HTML;
}
}
function startEditor(html) {
document.getElementById("editor").innerHTML = html;
new FroalaEditor("#editor", {
// TEMPORARY: inject your key from the server when you render the page.
key: "YOUR-LICENSE-KEY",
heightMin: 300,
heightMax: 700,
htmlExecuteScripts: false,
collabConfig: {
docId,
// TEMPORARY: a random guest. In your app, pass the signed-in user.
user: {
id: "u-" + Math.random().toString(36).slice(2, 7),
name: "Guest",
},
commentsUrl: origin + "/collab/" + docId + "/comments",
suggestionsUrl: origin + "/collab/" + docId + "/suggestions",
versionControl: { url: origin + "/collab/" + docId + "/versions" },
realTime: { syncUrl: wsOrigin },
},
// Keeps a server copy of the latest HTML through the save plugin.
saveURL: origin + "/collab/" + docId + "/content",
saveParam: "body",
saveInterval: 5000,
events: {
"collab.connectionStatus": function (status) {
console.log("connection:", status);
},
"collab.synced": function () {
// Load stored comments and suggestions into the live session.
this.collaborative.syncCollabData();
},
},
});
}
loadSavedHtml().then(startEditor);
</script>
</body>
</html> index.js
Relays live edits over WebSocket and stores comments, suggestions and versions through the Froala Node.js SDK. Security checks run in front of every route.
/**
* Froala collaboration backend, hardened.
*
* Everything you must fill in yourself is marked TODO and lives in the
* "YOUR APPLICATION" block near the top. Nothing else should need editing.
*
* Install:
* npm install express cors express-rate-limit sanitize-html dotenv \
* ws better-sqlite3 wysiwyg-editor-node-sdk
*
* ws and better-sqlite3 arrive with the SDK, but declare them explicitly
* since this file requires them directly.
*/
require("dotenv").config(); // first line of index.js
const http = require("http");
const path = require("path");
const fs = require("fs");
const express = require("express");
const cors = require("cors");
const rateLimit = require("express-rate-limit");
const sanitizeHtml = require("sanitize-html");
const Database = require("better-sqlite3");
const { WebSocketServer } = require("ws");
const {
Collaborative,
CollabPersistence,
VersionControl,
AsyncSave,
} = require("wysiwyg-editor-node-sdk");
/* ------------------------------------------------------------------ *
* Configuration. Everything comes from the environment.
* ------------------------------------------------------------------ */
const PORT = Number(process.env.PORT || 3000);
// Origins allowed to call this server and to open WebSockets against it.
// Comma separated, e.g. "https://app.example.com,https://admin.example.com"
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const DB_PATH =
process.env.DB_PATH || path.join(__dirname, "data", "collab.db");
// Autosaved versions older than this are pruned. Named versions are kept.
const RETENTION_DAYS = Number(process.env.RETENTION_DAYS || 30);
if (ALLOWED_ORIGINS.length === 0) {
console.error("ALLOWED_ORIGINS is empty. Refusing to start.");
process.exit(1);
}
/* ------------------------------------------------------------------ *
* YOUR APPLICATION
*
* Three functions stand between the internet and your data. Replace all
* three. They fail closed as written, so the server is safe but useless
* until you do.
* ------------------------------------------------------------------ */
function parseCookies(header) {
const out = {};
for (const part of (header || "").split(";")) {
const i = part.indexOf("=");
if (i < 0) continue;
const k = part.slice(0, i).trim();
if (k) out[k] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
/**
* Resolve the signed-in user from the request's cookies.
* Return null for anyone who is not signed in.
*
* @returns {Promise<{ userId: string, name: string } | null>}
*/
async function getSession(cookieHeader) {
// TEMPORARY dev bypass. Delete this block and DEV_OPEN_ACCESS in .env.
if (process.env.DEV_OPEN_ACCESS === "true") {
return { userId: "dev-user", name: "Dev User" };
}
const cookies = parseCookies(cookieHeader);
const sid = cookies.sid;
if (!sid) return null;
// TODO: look sid up in your session store.
// TEMPORARY: remove the next line. Until you do, anyone with a sid
// cookie is treated as signed in.
return { userId: "dev-user", name: "Dev User" };
return null;
}
/**
* May this user open this document at all?
*
* @returns {Promise<boolean>}
*/
async function canOpenDoc(userId, docId) {
// TODO: check your documents/permissions tables.
// return await db.documentIsVisibleTo(docId, userId);
return true; // TEMPORARY: remove this line. Lets the demo open any document.
return false;
}
/**
* What may this user do in this document?
* "editor" writes, "suggester" proposes changes, "viewer" reads only.
*
* This is the authoritative answer. collabConfig.user.role in the browser
* only decides which buttons get drawn.
*
* @returns {Promise<"editor" | "suggester" | "viewer">}
*/
async function roleFor(userId, docId) {
// TODO: derive from your own permissions model.
return "editor"; // TEMPORARY: remove this line. Makes everyone an editor.
return "viewer";
}
/* ------------------------------------------------------------------ *
* Database
* ------------------------------------------------------------------ */
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
// Write-ahead logging so readers do not block writers. Stored in the file,
// so this only has to happen once, but running it every boot is harmless.
new Database(DB_PATH).pragma("journal_mode = WAL");
/* ------------------------------------------------------------------ *
* Express. Order matters: everything below runs before attachRoutes.
* ------------------------------------------------------------------ */
const app = express();
// CodeSandbox runs behind Cloudflare, and production runs behind a proxy
// too. Without this, req.ip is the proxy's address, and express-rate-limit
// raises a validation error when it sees X-Forwarded-For.
app.set("trust proxy", 1);
app.disable("x-powered-by");
// 1. CORS. First, so that rejections further down still carry CORS headers
// and show up in the browser as a real status rather than a CORS error.
app.use(
cors({
origin: (origin, cb) => {
// Same-origin and server-to-server requests send no Origin header.
if (!origin) return cb(null, true);
cb(null, ALLOWED_ORIGINS.includes(origin));
},
credentials: true,
})
);
// 2. Body parsing. Version snapshots are whole HTML documents, so the
// Express default of 100kb rejects them with a 413.
app.use(express.json({ limit: "5mb" }));
app.use(express.urlencoded({ extended: true, limit: "5mb" }));
// Serve the editor page. Public, no auth, same origin as the API.
app.use(express.static("public"));
// 3. No caching of collaboration data. A browser that serves a stale
// comment list from cache shows people a document that has moved on.
app.use("/collab/:docId", (req, res, next) => {
res.set("Cache-Control", "no-store");
next();
});
// 4. Never leak database internals. The SDK's 500 responses carry the raw
// SQLite message, which names your tables and columns.
app.use("/collab/:docId", (req, res, next) => {
const send = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 500) {
console.error("collab %s %s", req.method, req.originalUrl, body);
return send({ error: "server error" });
}
return send(body);
};
next();
});
// 5. docId validation. It flows into URL paths and database keys, so reject
// anything that is not plainly a document name before it travels further.
const DOC_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
app.use("/collab/:docId", (req, res, next) => {
if (!DOC_ID.test(req.params.docId)) {
return res.status(400).json({ error: "invalid docId" });
}
next();
});
// 6. Authentication and authorization. The only thing that actually
// protects the data.
app.use("/collab/:docId", async (req, res, next) => {
try {
const session = await getSession(req.headers.cookie);
if (!session) {
return res.status(401).json({ error: "not signed in" });
}
const { docId } = req.params;
if (!(await canOpenDoc(session.userId, docId))) {
return res.status(403).json({ error: "no access to this document" });
}
const role = await roleFor(session.userId, docId);
// Viewers may read and nothing else.
if (req.method !== "GET" && role === "viewer") {
return res.status(403).json({ error: "read only" });
}
req.user = { id: session.userId, name: session.name, role };
next();
} catch (err) {
console.error("auth failed", err);
res.status(500).json({ error: "server error" });
}
});
// 7. Rate limiting. Reads are cheap. Writes are what a client bug turns
// into a loop against your database.
const writeLimiter = rateLimit({
windowMs: 60_000,
limit: 120,
standardHeaders: true,
legacyHeaders: false,
// Budget per person per document, not per IP, so one office behind one
// address does not throttle itself.
keyGenerator: (req) => `${req.user.id}:${req.params.docId}`,
message: { error: "too many requests" },
});
app.use("/collab/:docId", (req, res, next) =>
req.method === "GET" ? next() : writeLimiter(req, res, next)
);
// 8. Sanitization on the way in. Content is returned verbatim by the SDK,
// so this is the only gate before it reaches storage.
const POLICY = {
// Match this to what your toolbar can actually produce. Stricter than the
// editor is fine. Looser is a hole.
allowedTags: [
"p", "br", "strong", "em", "u", "s", "sub", "sup",
"h1", "h2", "h3", "h4", "blockquote", "pre", "code",
"ul", "ol", "li", "a", "img", "hr", "span", "div",
"table", "thead", "tbody", "tr", "th", "td",
],
allowedAttributes: {
a: ["href", "title", "target", "rel"],
img: ["src", "alt", "width", "height"],
span: ["style"],
div: ["style"],
td: ["colspan", "rowspan"],
th: ["colspan", "rowspan"],
},
allowedStyles: {
"*": {
"text-align": [/^left$|^right$|^center$|^justify$/],
color: [/^#[0-9a-fA-F]{3,8}$/, /^rgba?\(/],
"background-color": [/^#[0-9a-fA-F]{3,8}$/, /^rgba?\(/],
},
},
// Blocks javascript: and data: URLs.
allowedSchemes: ["http", "https", "mailto"],
transformTags: {
a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer" }),
},
};
// Comment bodies, replies and suggestion text are plain text, not rich
// content. Strip every tag rather than allowing a subset.
const TEXT_ONLY = { allowedTags: [], allowedAttributes: {} };
app.use("/collab/:docId", (req, res, next) => {
const body = req.body;
if (!body || typeof body !== "object") return next();
const text = (value) =>
typeof value === "string" ? sanitizeHtml(value, TEXT_ONLY) : value;
// Document content. AsyncSave posts "body", VersionControl posts "snapshot".
if (typeof body.body === "string") {
body.body = sanitizeHtml(body.body, POLICY);
}
if (typeof body.snapshot === "string") {
body.snapshot = sanitizeHtml(body.snapshot, POLICY);
}
// Plain-text fields: version title and description, comment text,
// suggestion before and after text, and author display names.
// Only keys already in the request are touched. The SDK's PATCH handlers
// treat any key that exists, even an undefined one, as a requested change.
for (const key of [
"title",
"description",
"text",
"originalText",
"suggestedText",
"authorName",
]) {
if (key in body) body[key] = text(body[key]);
}
// Version author.
if (body.author && typeof body.author === "object") {
body.author.name = text(body.author.name);
}
// Replies, on both comments and suggestions. PATCH replaces the whole
// array, so every element passes through here on every update.
if (Array.isArray(body.replies)) {
body.replies = body.replies.map((reply) => {
if (!reply || typeof reply !== "object") return reply;
return {
...reply,
text: text(reply.text),
authorName: text(reply.authorName),
};
});
}
next();
});
/* ------------------------------------------------------------------ *
* The SDK's own routes. Everything above is already in front of them.
* ------------------------------------------------------------------ */
CollabPersistence.attachRoutes(app, { dbPath: DB_PATH });
VersionControl.attachRoutes(app, { dbPath: DB_PATH });
AsyncSave.attachRoutes(app, { dbPath: DB_PATH });
// Liveness only. No document data, safe to leave open for your load
// balancer. Move it behind auth if you would rather not publish counts.
app.get("/health", (req, res) => res.json(Collaborative.getStats()));
/* ------------------------------------------------------------------ *
* WebSocket relay, with the same checks on the upgrade.
*
* Note: we manage the ws server ourselves rather than calling
* Collaborative.attachToServer(), because that attaches before there is
* any opportunity to reject a connection.
* ------------------------------------------------------------------ */
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", async (req, socket, head) => {
const reject = (why) => {
console.warn(
"upgrade rejected:", why,
"| url:", req.url,
"| origin:", req.headers.origin
);
socket.destroy();
};
try {
if (!ALLOWED_ORIGINS.includes(req.headers.origin)) {
return reject("origin not allowed");
}
const session = await getSession(req.headers.cookie);
if (!session) return reject("no session");
const docId = decodeURIComponent(req.url.split("?")[0].slice(1));
if (!DOC_ID.test(docId)) return reject(`bad docId: "${docId}"`);
if (!(await canOpenDoc(session.userId, docId))) {
return reject("canOpenDoc said no");
}
console.log("upgrade accepted:", docId);
wss.handleUpgrade(req, socket, head, (conn) => {
Collaborative.setupWSConnection(conn, req);
});
} catch (err) {
console.error("upgrade error", err);
socket.destroy();
}
});
/* ------------------------------------------------------------------ *
* Retention. Autosaves are a full copy of the document every interval,
* so they are where the volume comes from. Named versions are kept.
* ------------------------------------------------------------------ */
function pruneAutosaves() {
try {
const db = new Database(DB_PATH);
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000;
const result = db
.prepare(
`DELETE FROM versions
WHERE is_named = 0 AND source = 'auto' AND created_at < ?`
)
.run(cutoff);
db.close();
if (result.changes) {
console.log("pruned %d autosaved versions", result.changes);
}
} catch (err) {
// Non-fatal. Most likely the table does not exist yet on first boot.
console.error("prune failed", err.message);
}
}
setInterval(pruneAutosaves, 24 * 60 * 60 * 1000).unref();
/* ------------------------------------------------------------------ *
* Start and stop
* ------------------------------------------------------------------ */
server.listen(PORT, "0.0.0.0", () => {
console.log("Collaboration backend listening on %d", PORT);
console.log("Allowed origins: %s", ALLOWED_ORIGINS.join(", "));
});
function shutdown() {
console.log("shutting down");
// 1001 is "going away". Clients reconnect after reconnectDelay.
for (const client of wss.clients) {
client.close(1001, "server restarting");
}
server.close(() => process.exit(0));
// Do not hang forever on a stuck connection.
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown); package.json
Dependencies for the server. Requires Node.js 22.12 or newer.
{
"name": "froala-collaboration-server",
"version": "1.0.0",
"private": true,
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"better-sqlite3": "^12.10.0",
"cors": "^2.8.6",
"dotenv": "^18.0.1",
"express": "^5.2.1",
"express-rate-limit": "^8.7.0",
"sanitize-html": "^2.17.7",
"ws": "^8.21.3",
"wysiwyg-editor-node-sdk": "^5.5.0"
},
"overrides": {
"htmlparser2": "^10.1.0"
}
} .env
Server settings. The server exits at startup until ALLOWED_ORIGINS has a value.
# The origin your editor page is served from, with no trailing slash.
# In a CodeSandbox fork, use your fork's preview URL, e.g. https://abc123-3000.csb.app
ALLOWED_ORIGINS=http://localhost:3000
DB_PATH=./data/collab.db
RETENTION_DAYS=30
# TEMPORARY: open access for the demo. Delete before launch.
DEV_OPEN_ACCESS=true Run it locally
- Install Node.js 22.12 or newer. The
htmlparser2override inpackage.jsonkeepssanitize-htmlloading on earlier Node.js 20 releases. - Put
index.js,package.jsonand.envin one folder, and the editor page inpublic/index.html. - Replace
YOUR-LICENSE-KEYwith the trial key from the Froala download page. The trial unlocks every plugin, collaboration included. - Run the commands below, then open
http://localhost:3000in two browser windows.
npm install
npm start
# In a second terminal
curl http://localhost:3000/health
# {"docs":0,"clients":0} With both windows open, /health reports one document and two clients. Reload a window and check that your comments are still listed. That confirms the database side is connected, since the relay holds live edits in memory only.
Server checks
Every request to /collab/:docId passes through these layers in order before it reaches the SDK routes.
| # | LAYER | WHAT IT DOES |
|---|---|---|
| 1 | CORS | Accepts browser requests from ALLOWED_ORIGINS only. It runs first so later rejections still reach the browser as a readable status. |
| 2 | Body size | Raises the JSON limit to 5 MB, since version snapshots are whole HTML documents. |
| 3 | Caching | Sends Cache-Control: no-store so browsers always fetch the current comment list. |
| 4 | Error masking | Replaces database details in 500 responses with a generic message and logs the full error on the server. |
| 5 | docId check | Accepts letters, numbers, dashes and underscores, up to 64 characters. |
| 6 | Sign-in and roles | Calls getSession, canOpenDoc and roleFor. Viewers get read-only access. |
| 7 | Rate limit | Allows 120 writes per minute for each person in each document. Reads are unlimited. |
| 8 | Sanitization | Cleans document HTML against the POLICY allow list and strips every tag from comments, suggestions and names. |
WebSocket connections get the same origin, session, docId and canOpenDoc checks before the server places them in a room. A daily job deletes autosaved versions older than RETENTION_DAYS and keeps every named version.
Endpoints
| METHOD | PATH | PURPOSE |
|---|---|---|
| GETPOST | /collab/:docId/comments | List and create comments. |
| PATCHDELETE | /collab/:docId/comments/:id | Resolve, reply to or delete a comment. |
| GETPOST | /collab/:docId/suggestions | List and create suggestions. |
| PATCHDELETE | /collab/:docId/suggestions/:id | Accept, reject, reply to or delete a suggestion. |
| GETPOST | /collab/:docId/versions | List and save versions. |
| GETPATCHDELETE | /collab/:docId/versions/:id | Read, rename or delete one version. |
| GETPOST | /collab/:docId/content | Read and save the latest document HTML. |
| WS | /:docId | The live relay. Same checks as above, applied to the upgrade request. |
| GET | /health | Open room and client counts for your load balancer. |
Prepare your collaboration server for production
The demo uses open access so anyone can try it. These steps turn it into a server you can put in front of real users.
Connect your sign-in system
Replace the three functions in the YOUR APPLICATION block of index.js. Everything else in the file is configuration.
// Read your session cookie and return the signed-in user, or null.
async function getSession(cookieHeader) {
const sid = parseCookies(cookieHeader).sid; // your cookie name
if (!sid) return null;
const session = await yourSessionStore.get(sid);
if (!session) return null;
return { userId: session.userId, name: session.name };
}
// May this person open this document at all?
async function canOpenDoc(userId, docId) {
return await db.userCanSeeDocument(userId, docId);
}
// What may they do in it? The server's answer is the one that counts.
async function roleFor(userId, docId) {
const p = await db.permissionFor(userId, docId);
return p.canEdit ? "editor" : p.canSuggest ? "suggester" : "viewer";
} Security
Treat collabConfig.user as display information. Anyone can change it in browser devtools, role included. The three server functions decide who opens a document and what they can change. Remove the demo shortcuts
Search all three files for TEMPORARY. Each shortcut sits above the correct code, so the correct code takes over once the shortcut is gone.
- index.js has the
DEV_OPEN_ACCESSblock and thedev-userreturn ingetSession, thereturn trueincanOpenDoc, and thereturn "editor"inroleFor. - .env has the
DEV_OPEN_ACCESSline. - public/index.html has the random guest user, the shared
docId, the starter text and the license key.
After the shortcuts are gone, every request returns 401 until your session logic is in place. That is the server working as designed.
Render the page for the signed-in user
Render the editor page after your session check so the real id, name and role reach collabConfig. This route uses Express with your template engine of choice.
app.get("/doc/:docId", async (req, res) => {
const { docId } = req.params;
const session = await getSession(req.headers.cookie);
if (!session) return res.redirect("/login");
if (!(await canOpenDoc(session.userId, docId))) return res.sendStatus(403);
res.render("document", {
docId,
user: {
id: session.userId,
name: session.name,
role: await roleFor(session.userId, docId),
},
froalaKey: process.env.FROALA_KEY,
});
}); Good to know
Same origin
The collaboration plugin sends REST requests with the browser's default fetch settings, which include cookies for same-origin requests. Serve the editor page from the same origin as the API, as this example does with express.static, so cookie-based sign-in works. Starting content
The relay keeps live edits in memory and holds no history. When someone opens an empty room, the page loads the last copy from /content. When others are already editing, the live room takes over. Images
The allow list accepts images with http or https sources, so set up image upload to store pasted images as files. Froala adds alignment classes such as fr-dib to images. Add class to the img attributes in POLICY to keep that layout in saved content.