Dynamic block loading for Plone 6 / Volto using Webpack Module Federation. Install, activate, and remove Volto blocks at runtime — no frontend rebuild required.
# Default — auto-detects your Plone project:
curl -sL https://raw.githubusercontent.com/RenteriaMX/MF-Blocks-Manager/main/install.sh | bash
# Or with a specific project path:
curl -sL https://raw.githubusercontent.com/RenteriaMX/MF-Blocks-Manager/main/install.sh | bash -s /opt/plone/mi-proyectoNo sudo required. Run as the Plone system user. The script auto-detects your Plone project, installs backend + frontend + Nginx config, builds, restarts services, and activates the add-on.
Traditional Volto requires a full frontend rebuild to add a new block (5-10 min compile + deploy + restart). With MF-Blocks-Manager:
- Build your block as a standalone webpack package
- Upload the
.tar.gzfrom Site Setup → Module Federation Blocks - The page auto-reloads and the block is immediately available in the editor under the Bricks group
No rebuild. No restart. No deploy pipeline.
┌─────────────────────────────────────────────────┐
│ Plone Backend │
│ collective.mfblocks │
│ ├── Content Type: MFBlock │
│ │ (upload .tar.gz, auto-extract, publish) │
│ ├── GET @blocks-registry (public) │
│ └── GET/PATCH/POST @mfblocks-manage (admin) │
└───────────────────┬─────────────────────────────┘
│ REST API
┌───────────────────▼─────────────────────────────┐
│ Volto Frontend │
│ volto-mfblocks │
│ ├── Block registry (same-origin endpoint + SSR) │
│ ├── MF Loader (shared React/ReactDOM scope) │
│ ├── View/Edit wrappers (host manages sidebar) │
│ └── Control Panel UI (Site Setup) │
└───────────────────┬─────────────────────────────┘
│ HTTP
┌───────────────────▼─────────────────────────────┐
│ Nginx │
│ /mf-blocks/{block_id}/remoteEntry.js │
└─────────────────────────────────────────────────┘
| Component | Description |
|---|---|
Backend (collective.mfblocks) |
Content Type MFBlock, @blocks-registry endpoint (public), @mfblocks-manage endpoint (admin), event subscribers for bundle extraction |
Frontend (volto-mfblocks) |
MF Loader with shared scope, SSR pre-registration, View/Edit wrappers with host-side sidebar, Control Panel in Site Setup |
| Nginx | Static file serving at /mf-blocks/ with CORS headers |
The install.sh script automatically:
- Detects Plone project directory (or lets you choose if multiple found)
- Detects current user (
whoami), systemd--userservices (patternplone-*-backend/plone-*-frontend), pip tool (uv/pip) - Installs backend Python package
- Copies frontend addon and registers it in
volto.config.jsandpackage.json - Runs
pnpm installand builds the frontend - Creates
<project>/var/mf-blocksdirectory for block bundles - Configures Nginx location block (uses
sudoonly fornginx -tandsystemctl reload nginx) - Activates the add-on via
zconsole(fallback:uv run zconsole) - Restarts all services via
systemctl --user
Nginx note: The installer calls
sudo /usr/sbin/nginx -t,sudo /usr/bin/systemctl reload nginxandsudo <setfacl-path>(to grant the Nginx worker user traversal access to$HOMEwithout opening it to everyone else). It resolvessetfacl's absolute path itself (viacommand -v setfacl) so thesudocall matches asudoersrule by exact path — runcommand -v setfaclon your server to find that path (commonly/usr/bin/setfaclon Debian/Ubuntu, sometimes/usr/sbin/setfaclelsewhere) and add a rule like:plone ALL=(ALL) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx, /usr/bin/setfaclWithout
NOPASSWDforsetfacl, the installer will print the exact command to run manually (it won't abort the rest of the install).
After installation, go to Site Setup → Module Federation Blocks:
- View all installed blocks with name, block ID, version, group, status
- + Install Block — upload a
.tar.gzbundle with metadata - Publish / Retract / Activate / Deactivate / Delete blocks
- Block ID and Remote Name auto-generated from title
- Auto-reload — the page reloads automatically after install/publish/retract/delete so the block appears immediately in the editor
- MF blocks appear under a dedicated Bricks group in the block chooser (separate from native Volto blocks)
Blocks ONLY depend on React. NEVER import Volto components (SidebarPortal, BlockDataForm, @plone/volto/helpers, etc.). The host handles the sidebar using the exported schema.
The host provides React and ReactDOM to remote blocks via Module Federation shared scope. Without this, blocks create duplicate React instances and hooks crash.
On the server (Node.js), blocks are pre-registered synchronously at startup via @blocks-registry. Without this, anonymous visitors see "Unknown Block".
my-block/
├── package.json
├── webpack.config.js
└── src/
├── index.js ← export { view, edit, schema }
├── View.jsx ← React component (view mode)
├── Edit.jsx ← React component (edit preview only)
└── Schema.js ← Schema object for BlockDataForm
import View from './View';
import Edit from './Edit';
import schema from './Schema';
export default { view: View, edit: Edit, schema };view— React component rendered in view modeedit— React component rendered as preview in edit mode (NOT the sidebar)schema— Schema object. The HOST rendersSidebarPortal+BlockDataFormusing this schema
On top of the standard Volto block props, the loader passes two extra props to
your block's view and edit components so it can call protected backend
endpoints:
authToken— the logged-in user's JWT (ornullfor anonymous visitors).apiPath— the Plone backend base URL (useful in headless setups where the API is on a different origin than the frontend).
const View = ({ data, authToken, apiPath }) => {
const headers = authToken ? { Authorization: `Bearer ${authToken}` } : {};
// fetch(`${apiPath}/@my-protected-endpoint`, { headers })
// ...
};Both are additive and optional — blocks that don't need them just ignore them.
Note that an MF block is third-party JavaScript running same-origin in the
page, so it could already reach the Redux store, cookies and localStorage on
its own; authToken is a convenience for legitimate blocks, not a new
privilege.
const schema = {
title: 'My Block',
fieldsets: [
{ id: 'default', title: 'Default', fields: ['myField'] },
],
properties: {
myField: {
title: 'My Field',
type: 'string',
},
},
required: [],
};
export default schema;const base = require('../../shared/webpack.base');
module.exports = base({
name: 'voltoMyBlockBlock', // Remote Name
entry: './src/index.js',
exposes: {
'./block': './src/index.js', // Remote Module
},
});cd blocks/my-block
npx webpack --mode production
tar -czf my-block.tar.gz -C dist .The shared webpack.base automatically emits an mf-manifest.json into the
bundle — the explicit contract the backend reads on install to auto-detect the
exposed module and version:
{ "name": "voltoMyBlockBlock", "module": "./block", "version": "1.0.0" }If you build your bundle without webpack.base, include this file in the
tarball root yourself. Bundles without a manifest still work: the backend
falls back to parsing remoteEntry.js, but that parsing depends on the
exact webpack/terser output format and may fail silently on other versions.
Site Setup → Module Federation Blocks → + Install Block → Upload .tar.gz → Done.
cd /opt/plone/<project>/backend
git clone https://github.com/RenteriaMX/MF-Blocks-Manager.git /tmp/MF-Blocks-Manager
cp -r /tmp/MF-Blocks-Manager/backend/collective.mfblocks packages/
pip install -e packages/collective.mfblocks
systemctl --user restart plone-backend-1
# Site Setup → Add-ons → Install collective.mfblockscd /opt/plone/<project>/frontend
cp -r /tmp/MF-Blocks-Manager/frontend/volto-mfblocks packages/
# Add 'volto-mfblocks' to volto.config.js addons array
# Add "volto-mfblocks": "workspace:*" to package.json dependencies
pnpm install
VOLTOCONFIG=$(pwd)/volto.config.js pnpm --filter @plone/volto build
systemctl --user restart plone-voltolocation /mf-blocks/ {
alias /opt/plone/<project>/var/mf-blocks/;
expires 1h;
add_header Access-Control-Allow-Origin *;
}mkdir -p /opt/plone/<project>/var/mf-blocks
sudo /usr/sbin/nginx -t && sudo /usr/bin/systemctl reload nginxMF-Blocks-Manager/
├── install.sh ← Auto-installer
├── README.md ← This file (English)
├── README.es.md ← Spanish version
├── backend/
│ └── collective.mfblocks/ ← Plone add-on
│ ├── pyproject.toml
│ └── src/collective/mfblocks/
│ ├── content/mfblock.py ← Content Type IMFBlock
│ ├── services/blocks_registry.py ← GET @blocks-registry
│ ├── services/mfblocks_manage.py ← GET/PATCH/POST @mfblocks-manage
│ └── subscribers/mfblock.py ← Extract/remove bundle events
└── frontend/
└── volto-mfblocks/ ← Volto addon
├── package.json
└── src/
├── index.ts ← applyConfig (sync registration)
├── mf/loader.ts ← loadRemoteModule + shared scope
├── mf/MFBlocksLoader.tsx ← View/Edit wrappers
├── mf/ssrPreRegister.ts ← SSR pre-registration
└── components/MFBlocksControlPanel/ ← Control Panel UI
- Plone 6.1+ with Volto
- Python 3.10+
- Node.js 18+
- pnpm 9+
- Nginx
- git, curl
- MF block content is not server-rendered. SSR pre-registration only prevents the "Unknown Block" error for anonymous visitors; the actual block content renders client-side after hydration. If SEO or no-JS rendering of MF block content matters to you, use natively built Volto blocks for that content.
- Single-host deployment is assumed. Bundles are extracted to the local
filesystem (
var/mf-blocks/) of the backend instance that handles the upload. Multi-machine ZEO clusters would need a shared filesystem or object storage for that directory. - Trust boundary: anyone with the
cmf.ManagePortalpermission (Site Administrators) can upload bundles, which means they can run arbitrary JavaScript in every visitor's browser. This is the feature working as designed — grant that permission accordingly. - The SSR server needs
curlavailable in its PATH for the one-time synchronous registry fetch at startup (already listed in Requirements).
| Problem | Cause | Solution |
|---|---|---|
| "Unknown Block" in public view | SSR doesn't have blocks registered | Restart plone-volto |
| Block not in editor chooser | MFBlock not published or not active | Check in MF Blocks Manager, reload page |
| Block appears in Common instead of Bricks | Block was installed with old group: "common" |
Delete and reinstall the block |
null is not an object (useState) |
Dual React instances | Verify shared scope includes host React |
| Empty sidebar when editing | Block imports SidebarPortal from Volto | Apply Golden Rule: block only exports schema |
| Build doesn't include addons | Missing VOLTOCONFIG |
Use VOLTOCONFIG=$(pwd)/volto.config.js |
icon: null crash in block chooser |
Icon is null in blocksConfig | Uses codeSVG from @plone/volto/icons/code.svg |
- Juan Renteria — juan.renteria@it4s.mx
- Julia Bernuy S. — bernuy@unam.mx
MIT

