A page builder does not need to compile every block into one permanent application bundle. The core can stay stable while independently shipped plugins register new block types, provide editor controls, and define how those blocks appear on the public site.
The important boundary is not React itself. It is the contract between the builder core and each block. Once that contract is explicit, the editor can remain a single React application while blocks, CSS, and optional frontend JavaScript are loaded only when required.
The builder should store page structure, not source code
A page is easier to extend when the database stores block data instead of generated JSX or framework-specific source.
{
"blocks": [
{
"id": "b1",
"type": "core/heading",
"props": { "text": "New collection" }
},
{
"id": "b2",
"type": "acme/slider",
"props": { "slides": [1, 2, 3] }
}
]
}The type field is the lookup key. The builder does not need to know the implementation of acme/slider when the page is saved. It only needs a registry capable of resolving that type later.
That keeps stored content independent from the current bundle layout and makes plugin installation a runtime concern rather than a core rebuild.
Blocks register themselves with the core
The core exposes a small registration API:
const blocks = new Map();
export function registerBlock(definition) {
blocks.set(definition.name, definition);
}
export function getBlock(name) {
return blocks.get(name);
}A plugin can then register a block:
registerBlock({
name: 'acme/slider',
editor: SliderEditor,
frontend: SliderFrontend,
dependencies: ['swiper'],
style: '/plugins/acme-slider/style.css'
});The React editor renders editor. The public renderer uses frontend, or an equivalent server renderer, depending on the deployment model. The core owns orchestration; the plugin owns the block-specific behavior.
Installing a block should not rebuild the builder
A plugin can ship a manifest that points to its compiled assets:
{
"name": "acme/slider",
"editor": "/plugins/acme-slider/editor.js",
"frontend": "/plugins/acme-slider/frontend.js",
"style": "/plugins/acme-slider/style.css",
"dependencies": ["swiper"]
}When the plugin is enabled, the backend adds that manifest to the set of active blocks. The admin application can load the editor module with dynamic import() and let the module call registerBlock().
Only the plugin itself needs to have been built from TypeScript, JSX, or another source format. Installing it should not require rebuilding the page-builder core.
Schema-only blocks can go further. A simple heading, spacer, or card can be described by metadata and rendered through a generic component, avoiding a custom JavaScript module entirely.
Shared libraries need a dependency manager
A block that uses Swiper illustrates a common failure mode. If five slider plugins each bundle their own copy of Swiper, the page can download duplicate code and duplicate CSS. Different versions can also produce incompatible behavior.
The block should declare the dependency while the core decides how it is loaded:
registerBlock({
name: 'acme/slider',
dependencies: ['swiper'],
frontend: SliderFrontend
});A dependency manager can cache the loaded module:
const loaded = new Map();
async function loadDependency(name) {
if (loaded.has(name)) return loaded.get(name);
const module = await dependencyLoaders[name]();
loaded.set(name, module);
return module;
}The library is shared, but component instances are not. Three slider blocks should create three Swiper instances, each bound to its own DOM element and destroyed when that block unmounts.
This same rule applies to React. The editor should normally use one React runtime, while each block remains an ordinary component inside that application rather than creating a separate React root.
Tailwind is a build dependency, not a shared browser runtime
Tailwind needs different treatment from Swiper. Swiper is JavaScript that runs in the browser; Tailwind usually produces CSS during a build.
A plugin can use Tailwind during development and ship only the resulting CSS:
plugin source
-> Tailwind build
-> style.css
-> browserPlugins should avoid shipping their own global reset. They should also namespace block selectors or use a unique utility prefix when generated utilities could collide with the site theme or another plugin.
For example:
.acme-slider__slide {
border-radius: 0.75rem;
}is safer than a global selector such as:
.slide {
border-radius: 0.75rem;
}CSS Modules, generated class names, or Shadow DOM can provide stronger isolation, but Shadow DOM also changes how theme inheritance and global typography reach a block. A page builder usually benefits from namespaced CSS before reaching for complete style isolation.
Live editing and public rendering are separate concerns
The admin interface can stay fully React even when the public site does not ship the builder runtime.
A practical editor architecture is:
React admin
-> settings and block state
-> iframe canvas
-> live block rendererThe iframe keeps admin CSS away from page CSS and gives the preview a document environment closer to the public site. The admin can send block updates to the canvas, while the canvas rerenders only the affected block.
This gives immediate feedback for padding, text, colors, images, and other settings without forcing the public frontend to become a large client-side React application.
The frontend only needs renderers for blocks on the page
Suppose a page contains a heading, slider, and button. There is no reason to load gallery, chart, map, or form plugins for that request.
The renderer can scan the saved page data, resolve the three used block types, and collect their assets:
page JSON
-> heading
-> slider
-> button
-> collect renderers
-> collect CSS
-> collect interactive JSStatic blocks can become HTML and CSS only. Interactive blocks can add JavaScript selectively.
This is where an HTML-first frontend such as Astro can fit well: the page can be rendered on the server, while an interactive block becomes an island that hydrates only when required. Next.js is also viable when the public product is intentionally a React application. The plugin contract matters more than choosing one framework for every layer.
Frontend output must be part of the plugin contract
The editor preview cannot be the only implementation of a block. Every installable block needs a defined public rendering path.
That path can be one of several forms:
- a server renderer that returns HTML;
- a framework component rendered by the frontend;
- static HTML plus CSS;
- static HTML plus a small client module for interaction.
The contract should also specify asset ownership and lifecycle. If a block mounts a third-party library, it should also clean that instance up. If it declares CSS, the renderer should load that CSS once. If a dependency is shared, the core should control the version that satisfies the plugin API.
Without these rules, an extensible builder gradually becomes a collection of unrelated scripts competing for globals, selectors, and dependency versions.
A stable plugin boundary matters more than the framework
React, Vue, and Svelte can all implement a capable visual builder. React is attractive for a large plugin ecosystem because its component model and surrounding library ecosystem make it practical to expose a long-lived SDK, but the framework does not solve plugin isolation by itself.
The durable part of the design is the boundary:
page data
-> block type
-> registry
-> dependency resolution
-> editor renderer
-> frontend renderer
-> scoped assetsWhen that boundary is stable, a new block can be installed without recompiling the core, the admin can preview it immediately, and the public page can load only the code required by the blocks it actually contains.