Skip to content
EditorStackPlayground

GrapesJS in Next.js App Router: load projects on the server, edit on the client

A Next.js 15 App Router setup for GrapesJS: a server component reads the project, a client component mounts the editor, next/dynamic keeps the editor out of the server HTML. Built with next build and loaded in Chromium, including a control case that changes the usual advice.

Updated · Tested with GrapesJS 0.23.5

TL;DR

  • Split the route in two: a server component loads the project JSON and passes it as props, and a client component mounts GrapesJS in an effect.
  • next/dynamic with ssr: false must live in a client component in the App Router; our build confirms the server HTML then contains only the loading fallback, not editor markup.
  • In our 0.23.5 build, importing the editor component without next/dynamic also built and mounted, so ssr: false is a bundle and HTML decision rather than a fix for a crash.

The shape of the route

GrapesJS needs a browser. Next.js App Router renders on the server by default. The pattern that satisfies both has three files:

  1. page.tsx, a Server Component, reads the project from your database and passes plain JSON down;
  2. EditorLoader.tsx, a Client Component, lazy-loads the editor with ssr: false;
  3. GrapesEditor.tsx, a Client Component, mounts GrapesJS in an effect.

The public side of your site, the pages people actually visit, should not load GrapesJS at all. The Next.js use case explains why the editor belongs on its own authenticated route, and our bundle size benchmark puts the GrapesJS core at 294.5 kB gzip before plugins.

The editor component

components/GrapesEditor.tsx
'use client';

import { useEffect, useRef } from 'react';
import grapesjs, { type ProjectData } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';

export type GrapesEditorProps = {
  initialProject: ProjectData;
};

export default function GrapesEditor({ initialProject }: GrapesEditorProps) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const editor = grapesjs.init({
      container,
      // Written onto the container's inline style, replacing whatever height it had.
      height: '100vh',
      storageManager: false,
      projectData: initialProject,
    });

    return () => editor.destroy();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return <div ref={containerRef} />;
}

This is the same mount-once, destroy-on-unmount pattern as the React guide, trimmed to what the route needs. One detail cost us a failed test run: height is written onto the container element's inline style. Our first version passed height: '100%' into a div styled height: 100vh; GrapesJS replaced the style, the parent had no height, and the editor rendered at zero height. Pass the height you want to GrapesJS, not to the div.

The client-side loader

app/editor/[id]/EditorLoader.tsx
'use client';

import dynamic from 'next/dynamic';
import type { GrapesEditorProps } from '@/components/GrapesEditor';

// `ssr: false` is only allowed inside a client component in the App Router.
const GrapesEditor = dynamic(() => import('@/components/GrapesEditor'), {
  ssr: false,
  loading: () => <p>Loading editor…</p>,
});

export default function EditorLoader(props: GrapesEditorProps) {
  return <GrapesEditor {...props} />;
}

The Next.js documentation is explicit that ssr: false is not supported in Server Components, so the dynamic() call lives here, in a file marked 'use client', and the page renders this file.

The page

app/editor/[id]/page.tsx
import { getProject } from '@/lib/projects';
import EditorLoader from './EditorLoader';

// Static export needs the ids up front; a server-rendered app would drop this.
export function generateStaticParams() {
  return [{ id: 'demo' }];
}

export default async function EditorPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  // Runs on the server: read the project from your database here, pass plain JSON down.
  const project = await getProject(id);
  return <EditorLoader initialProject={project} />;
}
lib/projects.ts
import type { ProjectData } from 'grapesjs';

/** Stand-in for a database read. See the save/load guide for a real table. */
export async function getProject(id: string): Promise<ProjectData> {
  return {
    pages: [{ component: `<section><h1>Project ${id}</h1><p>Loaded on the server.</p></section>` }],
  };
}

generateStaticParams is only there because the example builds with output: 'export' so the test can serve static files. In a server-deployed app you would remove it, check the session, and read the project from your database in getProject.

What we ran

The test copies the example into a temporary directory, runs next build with Next.js 15.5.4, serves the static export and loads it in headless Chromium. It asserts that:

  1. the build succeeds with type checking on;
  2. the server HTML for /editor/demo contains the "Loading editor" fallback and no gjs-editor markup;
  3. in the browser, exactly one editor mounts and shows the project the server component loaded;
  4. no errors reach the console.

It also builds a control page, app/direct-import/page.tsx, that renders GrapesEditor straight from a Server Component with no next/dynamic:

app/direct-import/page.tsx
import GrapesEditor from '@/components/GrapesEditor';

/**
 * Control case for the guide's claim about SSR: the client component imported directly from a
 * server component, with no next/dynamic. The verifier checks whether this builds and mounts.
 */
export default function DirectImportPage() {
  return <GrapesEditor initialProject={{ pages: [{ component: '<h1>Direct import</h1>' }] }} />;
}

That page built and mounted too. It is common advice, including on earlier versions of this site, that GrapesJS touches window when imported and therefore crashes a server render. With 0.23.5 on Next.js 15.5.4 we could not reproduce that: the client component is pre-rendered on the server without error, and grapesjs.init() only runs in the effect. So the reason to use ssr: false is what the first test shows, not a crash. If you are on an older GrapesJS version, test before dropping it.

Saving from the editor route

The example passes the project in and stops there. To save, either configure GrapesJS's remote storage to call a Route Handler, or collect editor.getProjectData() and send it with a Server Action. Saving and loading from a database has the storage side, including the HTML and CSS columns a server-rendered public page would read.

Limitations

  • The public page is not covered. Rendering saved HTML safely on a public route (sanitising, caching, per-tenant CSS) is its own design problem.
  • Static export. The test builds with output: 'export'. We did not run the pattern on a Node.js server deployment or on the Edge runtime; the client-side behaviour should not differ, but the data loading in page.tsx will.
  • One Next.js version. Everything here ran on Next.js 15.5.4 with React 19.1.1.
  • Alternatives that server-render. If server rendering of the editable content itself matters, GrapesJS is the wrong engine; GrapesJS vs Puck covers the React-native alternative.

Frequently asked questions

Do I need ssr: false for GrapesJS in Next.js?
Not to avoid a build error, in our test. A client component that imports grapesjs at module level and initialises it in useEffect built with next build on Next.js 15.5.4 and mounted in the browser. We still recommend next/dynamic with ssr: false, because it keeps the editor out of the server-rendered HTML and out of the route's initial JavaScript until it loads.
Why can't I put ssr: false in page.tsx?
page.tsx is a Server Component by default, and the Next.js documentation states that ssr: false is not supported there. Put the dynamic() call in a file marked 'use client', as EditorLoader.tsx does, and render that from the page.
Can the public page be server-rendered from the same project?
Yes, but not with GrapesJS. The editor only runs in the browser. Store the HTML and CSS GrapesJS exports alongside the project JSON and render those on the public route. The save-and-load and export guides show both halves.

Sources

  1. Next.js documentation: Lazy Loading (next/dynamic, ssr: false) — accessed 2026-09-17
  2. GrapesJS documentation: Storage Manager (projectData, skip initial load) — accessed 2026-09-17
  3. GrapesJS API reference: Editor — accessed 2026-09-17
  4. npm registry metadata for grapesjs — accessed 2026-09-17

Written by the EditorStack Research Team. How we test and what we refuse to publish is on the methodology page.