Save and load GrapesJS projects in a database with remote storage
Wire GrapesJS's built-in remote storage to a small HTTP API and a Postgres table: autosave, reload, HTML and CSS stored for the public page, and the 0.23.5 quirk that turns every failed request into a TypeError. Tested against Postgres via PGlite.
TL;DR
- GrapesJS's remote storage sends project JSON with POST and loads it with GET; onStore and onLoad reshape the payload, so the server can store the JSON plus the rendered HTML and CSS in one request.
- Store the project JSON as the source of truth: the GrapesJS documentation warns against reloading a project from its HTML, because information is lost.
- In 0.23.5 any non-2xx response rejects with 'TypeError: PromiseReject called on non-object' instead of your error body, because RemoteStorage calls Promise.reject unbound; our test pins this behaviour.
What gets stored
GrapesJS has two representations of a page. The project data is a JSON document with pages, components, styles and assets; it is what the editor loads. The HTML and CSS are an export of that document. The GrapesJS documentation is blunt about which one to persist: rely only on the project JSON to load a project, because parsing HTML back loses information.
So the table below keeps both, for different readers: data for the editor, html and css for
the public page that should never load an editor. That public page is also why
exporting HTML and CSS is worth reading next.
The API and the table
/**
* Project storage API for GrapesJS's `remote` storage, backed by Postgres.
*
* GET /api/projects/:id → 200 { data } or 404
* POST /api/projects/:id ← { data, html, css } → 200 { ok: true, updatedAt }
*
* `db` is anything with a node-postgres style `query(text, params)` that resolves to
* `{ rows }` — `pg.Pool` in production; the verifier passes PGlite, which is Postgres
* compiled to WebAssembly, so the SQL below is exercised as written.
*/
export const SCHEMA = `
CREATE TABLE IF NOT EXISTS projects (
id text PRIMARY KEY,
data jsonb NOT NULL,
html text NOT NULL DEFAULT '',
css text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now()
)
`;
const MAX_BODY_BYTES = 5 * 1024 * 1024;
const PROJECT_PATH = /^\/api\/projects\/([a-z0-9-]{1,64})$/;
function sendJson(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(body));
}
async function readJson(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw Object.assign(new Error('Project too large'), { status: 413 });
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw Object.assign(new Error('Body is not valid JSON'), { status: 400 });
}
}
/** Returns true when it handled the request, so it can sit in front of other routes. */
export function createProjectRoutes(db) {
return async function handleProjects(req, res) {
const match = PROJECT_PATH.exec(new URL(req.url ?? '/', 'http://local').pathname);
if (!match) return false;
const id = match[1];
// Add your own authentication and per-project authorisation here, before any query.
try {
if (req.method === 'GET') {
const { rows } = await db.query('SELECT data FROM projects WHERE id = $1', [id]);
if (!rows.length) sendJson(res, 404, { error: 'Project not found' });
else sendJson(res, 200, { data: rows[0].data });
return true;
}
if (req.method === 'POST') {
// GrapesJS's remote storage always sends this header; a plain cross-site form cannot.
if (req.headers['x-requested-with'] !== 'XMLHttpRequest') {
sendJson(res, 403, { error: 'Missing X-Requested-With header' });
return true;
}
const body = await readJson(req);
const data = body?.data;
if (typeof data !== 'object' || data === null || !Array.isArray(data.pages)) {
sendJson(res, 400, { error: 'Expected { data: { pages: [...] } }' });
return true;
}
const html = typeof body.html === 'string' ? body.html : '';
const css = typeof body.css === 'string' ? body.css : '';
const { rows } = await db.query(
`INSERT INTO projects (id, data, html, css, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (id) DO UPDATE
SET data = EXCLUDED.data, html = EXCLUDED.html, css = EXCLUDED.css, updated_at = now()
RETURNING updated_at`,
[id, JSON.stringify(data), html, css],
);
sendJson(res, 200, { ok: true, updatedAt: rows[0].updated_at });
return true;
}
res.writeHead(405, { allow: 'GET, POST' }).end();
return true;
} catch (error) {
const status = error.status ?? 500;
if (status === 500) console.error('project storage failed', { id, error });
sendJson(res, status, { error: status === 500 ? 'Storage failed' : error.message });
return true;
}
};
}The handler is framework-free on purpose: createProjectRoutes(db) returns a function that takes
Node's req and res, so it drops into a plain http server, and its body ports to an Express
route or a Next.js Route Handler with little change. What it enforces:
- An id allowlist in the route regex, before any query.
- A 5 MB body limit. Project JSON with many pages grows quickly; pick a limit on purpose.
- A shape check.
data.pagesmust be an array, so a bug inonStorecannot overwrite a project with garbage. - The
X-Requested-Withheader. GrapesJS addsX-Requested-With: XMLHttpRequestto storage requests, and a cross-site HTML form cannot set it. This is a cheap layer against form-based CSRF, not a replacement for authentication and your framework's CSRF protection.
Authentication and per-project authorisation are left as a comment because they depend on your stack. They are not optional.
The editor side
import grapesjs, { type Editor } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';
const projectId = new URLSearchParams(location.search).get('project') ?? 'demo';
const endpoint = `/api/projects/${encodeURIComponent(projectId)}`;
const status = document.getElementById('status');
const editor: Editor = grapesjs.init({
container: '#gjs',
height: 'calc(100vh - 32px)',
storageManager: {
type: 'remote',
autosave: true,
autoload: true,
stepsBeforeSave: 1,
options: {
remote: {
urlLoad: endpoint,
urlStore: endpoint,
// Send the rendered HTML and CSS alongside the project JSON, so the public page
// can be served from the database without running the editor.
onStore: (data, editorInstance) => ({
data,
html: editorInstance.getHtml(),
css: editorInstance.getCss() ?? '',
}),
// The API wraps the project as { data }; hand GrapesJS the project itself.
onLoad: (result) => result.data,
},
},
},
});
editor.on('storage:start:store', () => {
if (status) status.textContent = 'Saving…';
});
editor.on('storage:end:store', () => {
if (status) status.textContent = 'Saved';
});
editor.on('storage:error', (error, type) => {
if (status) status.textContent = `Could not ${type} the project`;
console.warn('storage error', type, error);
});
// Exposed for the test harness and for poking at the editor from devtools.
Object.assign(window, { editor });options.remote.onStore wraps the project into { data, html, css } in the same request, and
options.remote.onLoad unwraps { data } on the way back. Both are documented options of the
remote storage; the documentation's own example uses the same result.data unwrapping.
The 0.23.5 error quirk
Our first test run failed on an unexpected console error:
TypeError: PromiseReject called on non-object. The cause is in GrapesJS's RemoteStorage: on a
non-2xx response it runs result.then(Promise.reject), passing Promise.reject without its
receiver. So instead of rejecting with your response body, the request rejects with a TypeError,
and GrapesJS logs it.
The consequences for your code:
- the
storage:errorhandler receives theTypeError, not your server's{ error }message, so do not try to show server messages from it; - a 404 on first load, a 401 after a session expires and a 500 all look identical to the client;
- if you need the status, replace the remote storage with your own via
editor.Storage.add(), which the documentation describes under "Replace storage".
The verifier asserts the TypeError explicitly. If a future GrapesJS release fixes the call, that
assertion fails and this section gets rewritten.
What our test checks
The server handler runs against PGlite with the schema above; the client is bundled with esbuild and loaded in headless Chromium. The test asserts that:
- opening a project that does not exist surfaces as a load error and leaves the editor usable;
- adding a section autosaves, and the row's
dataholds the component whilehtmlholds the markup; - a second browser page with the same project id loads the saved section into the canvas;
- a non-2xx load rejects with a
TypeError; - the API rejects a POST without
X-Requested-Withwith 403 and a malformed body with 400.
Limitations
- Last write wins. Two tabs editing the same project overwrite each other. Add a
versioncolumn, send it inonStore, and reject stale writes with 409 if concurrent editing is possible. We did not implement or test that. - No history. The upsert keeps only the latest state. Draft and published versions, or an audit trail, need a second table.
- Assets are URLs, not files. Uploaded images are referenced from the project JSON; storing the files is covered in uploading assets to S3.
- The public page. Serving
htmlandcsssafely to visitors, per tenant, is not covered. - The whole job. Persistence, versioning and recovery are one line of our build versus buy estimate; the commercial GrapesJS Studio SDK includes project storage if you would rather buy it.
Frequently asked questions
- What does GrapesJS send to the server when it saves?
- With remote storage and contentTypeJson left at its default of true, a POST to urlStore with a JSON body and an X-Requested-With: XMLHttpRequest header. The body is the project data, or whatever your onStore function returns. Credentials default to 'include', so cookies are sent.
- How often does autosave fire?
- When the number of changes reaches stepsBeforeSave, 1 by default. Every change triggers a request at 1; raise it to batch saves, and call editor.store() yourself before navigation or on an explicit Save button.
- What happens when the project does not exist yet?
- Our API returns 404, GrapesJS emits storage:error with type 'load', the editor stays usable with an empty canvas, and the first change saves a new row. The error object is a TypeError, not the response body, in 0.23.5. Creating the row when the user creates a project, before the editor opens, avoids the error path entirely.
- Why PGlite in the test and not a real Postgres server?
- PGlite is Postgres compiled to WebAssembly, so the SQL in server.mjs runs unmodified without a database service in CI. In production pass a pg Pool; it has the same query(text, params) shape the handler uses.
Sources
- GrapesJS documentation: Storage Manager — accessed 2026-09-17
- GrapesJS API reference: StorageManager — accessed 2026-09-17
- PGlite documentation — accessed 2026-09-17
- node-postgres documentation: Queries — accessed 2026-09-17
- PostgreSQL documentation: INSERT ... ON CONFLICT — accessed 2026-09-17
Written by the EditorStack Research Team. How we test and what we refuse to publish is on the methodology page.