Why GrapesJS fits Remix
GrapesJS needs the DOM, so in a Remix app you initialise it inside
useEffect (client-only) while Remix's loaders and actions handle data
on the server. This guide mounts the editor, saves through a Remix action, and
exports HTML/CSS.
1. Mount the editor client-side
Create app/routes/editor.tsx. Import GrapesJS inside the effect so it
never runs during SSR.
import { useEffect, useRef } from 'react';
import 'grapesjs/dist/css/grapes.min.css';
export default function Editor() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let editor: any;
(async () => {
const grapesjs = (await import('grapesjs')).default;
editor = grapesjs.init({
container: ref.current!,
height: '100vh',
fromElement: false,
storageManager: false,
components: '<h1>Hello from GrapesJS</h1>',
});
// Persist via a Remix action.
document.getElementById('save')!.onclick = async () => {
await fetch('/editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
html: editor.getHtml(),
css: editor.getCss(),
project: editor.getProjectData(),
}),
});
};
})();
return () => editor?.destroy();
}, []);
return (
<>
<button id="save">Save</button>
<div ref={ref} />
</>
);
}
2. Handle the save in a Remix action
// same app/routes/editor.tsx
import type { ActionFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { savePage } from '~/models/page.server';
export async function action({ request }: ActionFunctionArgs) {
const data = await request.json();
await savePage('home', data); // your DB write
return json({ status: 'ok' });
}
The action runs on the server, so your database client stays out of the browser bundle.
3. Load saved content back
export async function loader() {
return json(await getPage('home'));
}
// in the effect, after init:
// const saved = await fetch('/editor?_data=...'); editor.loadProjectData(saved.project);
Common pitfalls in Remix
Remix's server/client split is where things go wrong. Importing GrapesJS at module top level runs it during SSR and crashes the route — import it dynamically inside useEffect instead. Keep all persistence in the route's action (server) and never import your database client into the component, or it leaks into the browser bundle. If you load a saved project, read it in the loader and call editor.loadProjectData() after init — don't try to render editor state during SSR. Finally, return editor.destroy() from the effect so client-side transitions don't stack editor instances.
Prerequisites
You'll need Node.js 18+ and a Remix 2 app. No Remix-specific GrapesJS package is
required — the editor is browser-only and Remix's loaders/actions handle data on
the server. Familiarity with routes, useEffect, and Remix actions is
enough.
Add custom blocks to the editor
Register draggable blocks with the Block Manager after init (inside the effect):
editor.BlockManager.add('hero', {
label: 'Hero section',
category: 'Sections',
content: '<section class="hero"><h1>Headline</h1><p>Copy</p></section>',
});
Pull ready-made block libraries and presets from GJS.Market for a richer set.
Storage deep-dive: action + loader
Keep persistence on the server. POST the project to the route's action
and read it back from the loader, so your database client never leaks
into the browser bundle:
export async function action({ request }) {
const data = await request.json();
await savePage('home', data); // server-only DB write
return json({ status: 'ok' });
}
export async function loader() {
return json(await getPage('home')); // returns the saved project
}
After init, call editor.loadProjectData(saved.project) to reopen a page.
Performance tips
Import GrapesJS dynamically inside useEffect so it stays out of the
main bundle and the server render path, and return editor.destroy()
so client-side transitions don't stack instances. Code-split heavy plugins behind
the feature that uses them.
Security considerations
Authenticate and authorise the action before writing — never accept an unauthenticated POST that overwrites a page. Sanitise stored markup on output if non-admins can edit. Validate the payload size so a large project can't exhaust memory.
Troubleshooting common errors
“window/document is not defined” means GrapesJS ran during SSR —
import and init it only inside useEffect. An unstyled
canvas means the stylesheet import is missing. A blank
editor means the container ref wasn't ready at init. Hydration warnings
usually mean you tried to render editor state during SSR.
When to use GrapesJS with Remix
GrapesJS fits when your Remix app embeds a real visual page or email builder your users control, with your own storage and HTML output. For inline rich text, a lighter editor is enough; for full-page composition with layout, styling, and clean export, GrapesJS is the stronger, MIT-licensed, self-hosted choice.
Next steps
See the related GrapesJS + React and GrapesJS + Next.js guides, browse the GrapesJS marketplace, or start from the GJS.Market home page.
FAQ
Does GrapesJS work with Remix server rendering?
Yes — initialise it only in useEffect so it runs in the browser. The
route is server-rendered; only the editor instance is client-only.
How do I save GrapesJS data in Remix?
POST the project data to a Remix action and persist it in your database. Actions run on the server.
How do I load saved content back?
Fetch the saved project from a loader and call
editor.loadProjectData(saved) after grapesjs.init.