# Save and load GrapesJS projects in a database with remote storage

*Source: https://www.editorstack.cc/guides/grapesjs-save-load-database — last updated 2026-09-17. Licensed CC BY 4.0 with attribution to EditorStack.*

*Tested with GrapesJS 0.23.5. The code on this page runs in CI from examples/guides/grapesjs-save-load-database in the site repository.*

## Summary

- 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](/guides/grapesjs-export-html-css) is worth reading next.

## The API and the table

```js title="server.mjs"
/**
 * 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.pages` must be an array, so a bug in `onStore` cannot overwrite a
  project with garbage.
- **The `X-Requested-With` header.** GrapesJS adds `X-Requested-With: XMLHttpRequest` to 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

```ts title="client.ts"
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:error` handler receives the `TypeError`, 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:

1. opening a project that does not exist surfaces as a load error and leaves the editor usable;
2. adding a section autosaves, and the row's `data` holds the component while `html` holds the
   markup;
3. a second browser page with the same project id loads the saved section into the canvas;
4. a non-2xx load rejects with a `TypeError`;
5. the API rejects a POST without `X-Requested-With` with 403 and a malformed body with 400.

> **Sponsored — GJS.Market:** Storage adapters for GrapesJS — If you would rather not maintain your own storage layer, GJS.Market lists GrapesJS storage plugins. GJS.Market funds this site; read each adapter's code and GrapesJS version support before adopting it.

## Limitations

- **Last write wins.** Two tabs editing the same project overwrite each other. Add a `version`
  column, send it in `onStore`, 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](/guides/grapesjs-asset-manager-s3).
- **The public page.** Serving `html` and `css` safely to visitors, per tenant, is not covered.
- **The whole job.** Persistence, versioning and recovery are one line of our
  [build versus buy estimate](/use-cases/build-vs-buy-visual-editor); the commercial
  [GrapesJS Studio SDK](/libraries/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

1. [GrapesJS documentation: Storage Manager](https://grapesjs.com/docs/modules/Storage.html) — accessed 2026-09-17
2. [GrapesJS API reference: StorageManager](https://grapesjs.com/docs/api/storage_manager.html) — accessed 2026-09-17
3. [PGlite documentation](https://pglite.dev/docs/) — accessed 2026-09-17
4. [node-postgres documentation: Queries](https://node-postgres.com/features/queries) — accessed 2026-09-17
5. [PostgreSQL documentation: INSERT ... ON CONFLICT](https://www.postgresql.org/docs/current/sql-insert.html) — accessed 2026-09-17
