react-page-builder-free
v2.0.1
Published
Free React page builder — drag-and-drop visual editor built on Gutenberg blocks, with an SSR-safe renderer for Next.js, Remix, and Vite. Open-source, no-code page builder for React apps via onSave/onLoad hooks.
Downloads
266
Maintainers
Readme
React Page Builder Free — drag-and-drop page builder for React
react-page-builder-free is a free, open-source React page builder: a drag-and-drop visual editor built on Gutenberg blocks, plus an SSR-safe renderer for the public site. Drop it into Next.js (App Router), Remix, or Vite — no WordPress install required.
Build landing pages, marketing pages, and CMS-style content in React with a WYSIWYG block editor, then render the saved blocks to HTML on the server.
- 🧱 Gutenberg block editor in any React app — no WordPress, no PHP
- 🖱️ Drag-and-drop blocks, inline rich text, device preview widths
- ⚡ SSR / RSC safe renderer —
react-page-builder-free/renderernever touches the DOM - 🔌 Bring your own persistence —
onSave/onLoadprops, any DB or API - 🎨 Custom blocks & templates from your own app
- 📦 Free and MIT licensed
Live demo · Demo source: https://react-block-builder.vercel.app/
Full documentation (npm publish, Vercel deploy, Next.js/Remix/Vite, AI agent rules):
docs/FULL_GUIDE.md · Quick reference for Cursor/agents: AGENTS.md
Install
npm install react-page-builder-free react react-domPeer dependencies: react and react-dom (^18 or ^19). Your app must provide a single React instance (dedupe in Vite).
Package exports
| Import path | Use |
|-------------|-----|
| react-page-builder-free / react-page-builder-free/editor | BlockEditor (client only) |
| react-page-builder-free/renderer | BlockRenderer (SSR / RSC safe) |
| react-page-builder-free/styles | Editor CSS (required for the editor UI) |
| react-page-builder-free/bootstrap | Optional; editor entry already runs bootstrap |
import { BlockEditor, initBlocks } from 'react-page-builder-free/editor';
import { BlockRenderer, BLOCK_LIBRARY_STYLES } from 'react-page-builder-free/renderer';
import 'react-page-builder-free/styles';Quick start (any React app)
1. Editor (client only)
import 'react-page-builder-free/styles';
import { BlockEditor } from 'react-page-builder-free/editor';
export default function CMSPage() {
return (
<BlockEditor
initialTitle="Home"
initialContent={savedJsonOrHtml}
onSave={async ({ id, title, html, json }) => {
await fetch(`/api/pages/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, html, json }),
});
}}
onLoad={async (id) => {
const res = await fetch(`/api/pages/${id}`);
return res.ok ? res.json() : null;
}}
/>
);
}2. Public page (server or client)
import { BlockRenderer } from 'react-page-builder-free/renderer';
import '@wordpress/block-library/build-style/style.css';
export function PublicPage({ html }) {
return <BlockRenderer html={html} />;
}html— fromserialize(blocks)when saving in the editor.json— store separately to reopen the page in the editor (initialContent).
Required CSS
| Surface | Import |
|---------|--------|
| Editor | import 'react-page-builder-free/styles' |
| Rendered HTML | import '@wordpress/block-library/build-style/style.css' (or BLOCK_LIBRARY_STYLES constant from the renderer entry) |
Do not import editor styles on public-only routes — they are large (~500KB).
Next.js (App Router)
Editor — client component
// app/admin/editor/BlockEditorClient.jsx
'use client';
import 'react-page-builder-free/styles';
import { BlockEditor } from 'react-page-builder-free/editor';
export default function BlockEditorClient(props) {
return <BlockEditor {...props} />;
}// app/admin/editor/page.jsx
import dynamic from 'next/dynamic';
const BlockEditorClient = dynamic(
() => import('./BlockEditorClient'),
{ ssr: false },
);
export default function EditorPage() {
return (
<BlockEditorClient
onSave={async (payload) => { /* ... */ }}
onLoad={async (id) => { /* ... */ }}
/>
);
}Public page — server component
// app/pages/[slug]/page.jsx
import { BlockRenderer } from 'react-page-builder-free/renderer';
import '@wordpress/block-library/build-style/style.css';
export default async function Page({ params }) {
const page = await getPage(params.slug);
return <BlockRenderer html={page.html} className="entry-content" />;
}next.config.js (if you hit ESM/CJS issues with WordPress packages):
const nextConfig = {
transpilePackages: ['react-page-builder-free'],
};
export default nextConfig;Remix
Editor — client route
// app/routes/admin.editor.tsx
import 'react-page-builder-free/styles';
import { BlockEditor } from 'react-page-builder-free/editor';
import { ClientOnly } from 'remix-utils/client-only'; // or your own guard
export default function AdminEditor() {
return (
<ClientOnly fallback={<p>Loading editor…</p>}>
{() => (
<BlockEditor
onSave={savePage}
onLoad={loadPage}
/>
)}
</ClientOnly>
);
}Public route — SSR
// app/routes/pages.$slug.tsx
import { BlockRenderer } from 'react-page-builder-free/renderer';
import blockLibraryStyles from '@wordpress/block-library/build-style/style.css?url';
export const links = () => [{ rel: 'stylesheet', href: blockLibraryStyles }];
export default function Page() {
const { page } = useLoaderData();
return <BlockRenderer html={page.html} />;
}Vite / React Router / Remix
Use the included Vite plugin — do not add @wordpress/* to optimizeDeps.include yourself (those packages live under react-page-builder-free and Vite cannot resolve them at your app root).
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { reactPageBuilderVite } from 'react-page-builder-free/vite';
export default defineConfig({
plugins: [react(), reactPageBuilderVite()],
});Do not add react-page-builder-free/editor to optimizeDeps.include manually — Vite will pre-bundle it into node_modules/.vite/deps/ with a second React copy and you get Cannot read properties of undefined (reading 'cloneElement'). After upgrading, run rm -rf node_modules/.vite and restart dev.
Editor route (SSR) — never top-level import { BlockEditor } from 'react-page-builder-free/editor' in a route file (React Router evaluates all routes on the server → document is not defined). Use ClientBlockEditor instead:
// app/routes/admin.editor.tsx
import { useEffect } from 'react';
import { ClientBlockEditor } from 'react-page-builder-free/editor-client';
export function HydrateFallback() {
return <p>Loading editor…</p>;
}
export async function clientLoader() {
return { pageId: 'home' };
}
clientLoader.hydrate = true;
export default function AdminEditor({ loaderData }) {
useEffect(() => {
import('react-page-builder-free/styles');
}, []);
return (
<ClientBlockEditor
fallback={<p>Loading editor…</p>}
initialTitle="Home"
onSave={async (payload) => { /* … */ }}
onLoad={async (id) => { /* … */ }}
/>
);
}Public route (SSR) — only the renderer:
import { BlockRenderer } from 'react-page-builder-free/renderer';
import '@wordpress/block-library/build-style/style.css';
export default function PublicPage({ loaderData }) {
return <BlockRenderer html={loaderData.page.html} />;
}The plugin resolves @wordpress/block-library/build-style/style.css and other @wordpress/* imports from the kit's dependencies.
Block registry (custom blocks)
Register your blocks without editing the package.
Option A — Host .jsx blocks (recommended for real blocks)
Author blocks like the kit's own src/blocks/*, but import the shared WordPress runtime from the package so registerBlockType hits the editor's registry:
// your-app/blocks/carousel/index.jsx
import { registerBlockType } from 'react-page-builder-free/wp/blocks';
import {
useBlockProps, RichText, InspectorControls, MediaUpload, MediaUploadCheck,
} from 'react-page-builder-free/wp/block-editor';
import { PanelBody, Button } from 'react-page-builder-free/wp/components';
import { useState } from 'react-page-builder-free/wp/element';
import { plus, trash } from 'react-page-builder-free/wp/icons';
import {
ActionBuilder, ActionLink, DEFAULT_BUTTON_ACTION, resolveItemButtonAction,
} from 'react-page-builder-free/actions';
registerBlockType('myapp/carousel', { /* edit, save, attributes… */ });// your-app/blocks/index.js — side-effect registration
import './carousel/index.jsx';// your-app editor route
import 'react-page-builder-free/styles';
import { ClientBlockEditor } from 'react-page-builder-free/editor-client';
import './blocks';
<ClientBlockEditor
disableBundledBlocks
unregisterBlocks={['myapp/cta-block']}
onSave={onSave}
onLoad={onLoad}
/>;Load-order safe alternative — register in a callback instead of a side-effect import:
import { registerBlocks } from 'react-page-builder-free/editor';
registerBlocks(({ blocks, blockEditor, element }) => {
const { registerBlockType } = blocks;
const { useBlockProps } = blockEditor;
// …
});| Prop / export | Purpose |
|---------------|---------|
| react-page-builder-free/wp/* | Same @wordpress/* instance the editor uses |
| react-page-builder-free/actions | ActionBuilder, ActionLink, button-action helpers |
| disableBundledBlocks | Omit all bundled myapp/* demo blocks |
| unregisterBlocks | Remove specific blocks after init |
| registerBlocks(fn) | Register host blocks after core init |
Use editorSettings.allowedBlockTypes to limit which blocks appear in the inserter.
Option B — blockRegistry prop (JSON-shaped blocks)
Same shape as src/data/customBlocksConfig.json:
<BlockEditor
blockRegistry={[
{
name: 'myapp/pricing',
title: 'Pricing',
category: 'myapp-blocks',
icon: 'money-alt',
attributes: {
price: { type: 'string', default: '$9' },
accentColor: { type: 'string', default: '#3858e9' },
},
},
]}
onSave={onSave}
onLoad={onLoad}
/>Option C — customBlocksConfig prop (merged at init)
<BlockEditor customBlocksConfig={blocksFromApi} onSave={onSave} onLoad={onLoad} />Option D — initBlocks() before mount
import { initBlocks, BlockEditor } from 'react-page-builder-free/editor';
await initBlocks(myBlocks, {
customBlocksConfig: moreBlocks,
disableBundledBlocks: true,
unregisterBlocks: ['myapp/carousel'],
});
export default function Editor() {
return <BlockEditor onSave={onSave} onLoad={onLoad} />;
}Bundled defaults: core Gutenberg blocks, package custom blocks (hero-banner, cta-block, etc.), and entries from customBlocksConfig.json. Host blocks are added via blockRegistry / customBlocksConfig; they do not replace bundled blocks.
Hand-crafted blocks in the package use registerBlockType in src/blocks/* — copy that pattern in your app if you need React edit/save components beyond the JSON factory.
BlockEditor props
| Prop | Description |
|------|-------------|
| onSave | async ({ id, title, html, json }) => void |
| onLoad | async (pageId) => page \| null |
| onClear | async (pageId) => void (optional) |
| initialContent | Block JSON string/array or serialized HTML |
| initialTitle | Page title (default "Home") |
| initialPageId | Slug/id (default "home") |
| blockRegistry | Array of JSON block definitions |
| customBlocksConfig | Extra JSON blocks merged at first initBlocks |
| disableBundledBlocks | When true, skip bundled myapp/* demo blocks |
| unregisterBlocks | Block names to unregisterBlockType after init |
| editorSettings | Partial override of Gutenberg BlockEditorProvider settings (merged with defaults) |
| onViewSite | Optional callback (demo uses for preview route) |
| headerButtons | Object — show/hide each header button (see below). Default: all shown |
| confirmClear | boolean — confirm dialog before Clear wipes content. Default true |
| confirmClearMessage | Confirm dialog text. Default "Clear all content? This cannot be undone." |
| devices | Array of device ids to show in the preview toolbar. Default ['desktop','tablet','mobile'] |
| defaultDevice | Initial selected device. Default: first item in devices |
| customButtons | Array of consumer buttons rendered in the header (see below) |
| templates | Array of consumer block templates added to the "Choose a Template" picker |
| disableBundledTemplates | When true, hide the bundled demo templates (show only your templates) |
| actions | Configure button actions: { customActions, removeActions, fetchPages, pickProduct, pickCollection }. See docs/ACTIONS.md |
Button actions
Buttons store a structured action ({ actionName, params }), serialized to data-action
for your native/Shopify app. The package ships only OPEN_URL; add your own (products,
collections, in-app pages, …) via the actions prop. Full guide + Shopify example:
docs/ACTIONS.md.
Header buttons & device toolbar
Hide any header button (consumers embedding the editor in their own chrome):
<BlockEditor
headerButtons={{
deviceSwitcher: true, // device preview toggle
sidebar: true, // sidebar toggle
preview: true, // preview/edit toggle
clear: false, // hide Clear (trash) button
save: true, // Save button
viewSite: false, // hide View Site button
options: true, // options (⋮) menu
}}
onSave={onSave}
onLoad={onLoad}
/>Any key set to false hides that button; omitted keys default to shown.
Clear confirmation — the Clear button asks for confirmation before wiping content:
<BlockEditor confirmClear confirmClearMessage="Delete everything?" onSave={onSave} />
// disable: <BlockEditor confirmClear={false} ... />Device toolbar — restrict which preview widths are offered, and the default:
// Mobile-only editor — only the mobile button, opens in mobile width
<BlockEditor devices={['mobile']} onSave={onSave} onLoad={onLoad} />
// Desktop + mobile, default to mobile
<BlockEditor devices={['desktop', 'mobile']} defaultDevice="mobile" onSave={onSave} />The switcher auto-hides when only one device is allowed. defaultDevice is validated against devices; if invalid it falls back to the first allowed device.
Custom header buttons
Add your own buttons to the header. Each onClick receives an editor API object so the button can act on editor state.
import { FaUpload, FaCog } from 'react-icons/fa';
<BlockEditor
customButtons={[
{
id: 'publish',
label: 'Publish',
icon: <FaUpload />,
title: 'Publish this page',
position: 'end', // 'start' | 'end' (default 'end')
onClick: (api) => {
api.handleSave();
myPublish(api.pageId, api.blocks);
},
},
{
id: 'settings',
icon: <FaCog />, // icon-only button (no label)
onClick: (api) => openSettings(api.pageTitle),
},
]}
onSave={onSave}
onLoad={onLoad}
/>| Field | Type | Notes |
|-------|------|-------|
| id | string | Key + fallback for title |
| label | string | Optional button text |
| icon | ReactNode | Optional icon element |
| title | string | Tooltip (defaults to label) |
| position | 'start' | 'end' | Where in the header actions row. Default 'end' |
| className | string | Extra CSS class |
| disabled | boolean | Disable the button |
| onClick | (api) => void | Receives the editor API |
Editor API passed to onClick: blocks, setBlocks, pageId, pageTitle, setPageTitle, preview, setPreview, deviceType, setDeviceType, sidebarOpen, setSidebarOpen, handleSave, handleClear, onViewSite.
Register / import templates
The "Choose a Template" picker ships demo templates. Add your own with templates, and/or hide the bundled ones with disableBundledTemplates.
const myTemplates = [
{
slug: 'landing',
label: 'Landing Page',
category: 'Marketing',
icon: '🚀',
description: 'Hero + CTA',
blocks: [
{ name: 'core/heading', attributes: { content: 'Welcome', level: 1 } },
{ name: 'core/paragraph', attributes: { content: 'Build faster.' } },
// innerBlocks supported: { name, attributes?, innerBlocks? }
],
},
];
<BlockEditor
templates={myTemplates}
disableBundledTemplates // optional — show ONLY your templates
onSave={onSave}
onLoad={onLoad}
/>| Field | Type | Notes |
|-------|------|-------|
| slug | string | Unique key |
| label | string | Card title |
| category | string | Shown under the label |
| icon | string/ReactNode | Card icon (emoji or element) |
| description | string | Tooltip |
| blocks | array | { name, attributes?, innerBlocks? } — required |
Importing templates is just passing parsed JSON — fetch/JSON.parse your saved layouts and hand them to templates. Every block name used must be a registered block (bundled, or added via blockRegistry / customBlocksConfig); templates with an unknown blocks array are skipped.
Custom media library (images)
Frontend-only apps pass media callbacks — the editor shows a Media Library popup with two tabs: Media library (grid + search + pagination) and Upload files (drag-and-drop or click-to-browse). Multiple files upload at once when the block allows it. Your backend handles storage.
<BlockEditor
media={{
perPage: 20,
listImages: async ({ page, perPage, search }) => {
const res = await fetch(
`/api/media?page=${page}&perPage=${perPage}&q=${encodeURIComponent(search)}`,
);
return res.json();
// { items: [{ id, url, alt?, title?, mimeType? }], total, page, perPage, totalPages }
},
uploadImage: async (file) => {
const body = new FormData();
body.append('file', file);
const res = await fetch('/api/media/upload', { method: 'POST', body });
return res.json(); // { id, url, alt?, title?, mimeType? }
},
}}
onSave={onSave}
onLoad={onLoad}
/>listImages— required for the library button; powers search + pagination.uploadImage— optional; enables the Upload files tab (drag-and-drop + multi-file) in the modal and drag-and-drop file upload in blocks. Called once per file; returns the stored item.- Without
media, image blocks fall back to URL-only (link) input.
See https://react-block-builder.vercel.app/demo/mediaHandlers.js for a localStorage demo.
Override editor settings
import 'react-page-builder-free/styles';
import {
BlockEditor,
EDITOR_SETTINGS,
mergeEditorSettings,
} from 'react-page-builder-free/editor';
const mySettings = mergeEditorSettings(EDITOR_SETTINGS, {
bodyPlaceholder: 'Start writing…',
hasFixedToolbar: true,
colors: [{ name: 'Brand', slug: 'brand', color: '#3858e9' }],
allowedBlockTypes: ['core/paragraph', 'core/heading', 'core/image'],
});
<BlockEditor editorSettings={mySettings} onSave={onSave} onLoad={onLoad} />
// Or inline partial override:
<BlockEditor
editorSettings={{ bodyPlaceholder: 'Add content…', imageEditing: true }}
onSave={onSave}
onLoad={onLoad}
/>BlockRenderer props
| Prop | Default | Description |
|------|---------|-------------|
| html | '' | Serialized block HTML |
| className | entry-content wp-block-post-content | Wrapper classes |
| id | — | Optional wrapper id |
| as | 'div' | Wrapper element |
Data format
{
"id": "home",
"title": "Home",
"html": "<!-- wp:paragraph -->...",
"json": "[{\"name\":\"core/paragraph\", ...}]",
"updatedAt": "2026-03-04T12:00:00.000Z"
}Develop this repo
git clone https://github.com/bhavik-dreamz/react-page-builder-free.git
cd react-page-builder-free
pnpm install
pnpm run dev # examples/demo → http://localhost:5173
pnpm run build:lib # npm package → dist/
pnpm run test:exports && pnpm run test:bundle && pnpm run test:boundaryLayout
src/ # Published library
examples/demo/ # Playground (not on npm)
dist/ # Published build outputScripts
| Command | Description |
|---------|-------------|
| pnpm run dev | Demo app |
| pnpm run build / build:lib | Library → dist/ |
| pnpm run build:demo | Demo → dist-demo/ |
| pnpm run test:exports | Verify export map |
| pnpm run test:bundle | Renderer isolated from editor |
| pnpm run test:boundary | No demo code in dist/ |
License
MIT · Bhavik Patel
