A responsive, headless page builder with drag-and-drop functionality for React applications.
- 🎨 Drag and drop page builder
- 📱 Responsive breakpoints (desktop, tablet, mobile)
- 🎯 Grid-based snapping
- 💾 Pluggable storage adapters (localStorage, API, etc.)
- 🔧 Any React component works - Just register it and it becomes resizable!
- 📐 Grid-based responsive layouts
- 🎛️ Fully configurable
- 🧩 Headless hooks - Build your own UI with exposed hooks
npm install @vantage/page-builder react-rndnpm install github:dwrth/vantage react-rnd
# or
npm install git+https://github.com/dwrth/vantage.git react-rnd
# or with specific branch/tag
npm install github:dwrth/vantage#main react-rndNote: The package will automatically build on install via the prepare
script.
The editor is driven by a vantageEditor instance (like React Hook Form's
useForm). Create the instance with useVantageEditor, then pass it to
PageEditor:
import { PageEditor, useVantageEditor } from "@vantage/page-builder";
function App() {
const editor = useVantageEditor({ pageId: "home" });
return <PageEditor editor={editor} />;
}The same editor instance exposes all state and actions so you can build your
own toolbar, sidebar, or headless canvas:
import {
useVantageEditor,
PageEditor,
LiveView,
type VantageEditorInstance,
} from "@vantage/page-builder";
function CustomEditor() {
const editor = useVantageEditor({
pageId: "page-1",
components: myComponents,
storage: myStorage,
});
// Use the built-in PageEditor (default UI)
return <PageEditor editor={editor} />;
}
// Or build your own UI with the same instance:
function MyCustomUI() {
const editor = useVantageEditor({
pageId: "page-1",
components: myComponents,
});
return (
<div>
<button onClick={editor.undo} disabled={!editor.canUndo}>
Undo
</button>
<button onClick={editor.redo} disabled={!editor.canRedo}>
Redo
</button>
<button onClick={() => editor.addSection(false)}>Add section</button>
<button onClick={() => editor.addElement("text")}>Add text</button>
<select
value={editor.breakpoint}
onChange={e => editor.setBreakpoint(e.target.value as any)}
>
<option value="desktop">Desktop</option>
<option value="tablet">Tablet</option>
<option value="mobile">Mobile</option>
</select>
{/* Read-only preview using editor data */}
<LiveView
pageData={editor.pageData}
components={editor.components}
breakpoints={editor.breakpoints}
currentBreakpoint={editor.breakpoint}
/>
</div>
);
}Instance API (VantageEditorInstance):
- State:
pageData,breakpoint,selectedIds,showGrid,isDirty,hasUnsavedChanges,canUndo,canRedo,historyLoading,gridSize,breakpoints,canvasHeight,defaultSectionHeight - Setters:
setBreakpoint,setSelectedIds,setShowGrid,setPageData,selectElements - Element actions:
addElement(type, defaultContent?, sectionId?, externalId?),updateElement,updateElementContent(id, content),updateLayout,updateLayoutBulk,deleteElement,updateZIndex,ensureBreakpointLayout - Section actions:
addSection,deleteSection,updateSectionHeight,updateSectionFullWidth - History:
undo,redo,save - Rendering:
components(for use withLiveViewor a custom canvas)
The package never makes network requests. You provide your own storage implementation using any HTTP client you prefer (axios, fetch, server actions, etc.).
Initial data: If you already have page data (e.g. from a parent loader or
Redux), pass it as initialData in usePageData(pageId, { initialData }) or
have your adapter's load() return that on the first call. The editor will use
it immediately so it doesn't flash or overwrite optimistically added elements
before your async fetch completes.
Save return value: save() may optionally return Promise<PageData | null>
(or sync PageData | null). If your adapter returns updated page data (e.g.
with server-assigned externalIds or merged content), the editor will call
setPageData(returned) after a successful save so the next save uses server ids
without an extra round-trip.
import {
PageEditor,
useVantageEditor,
LocalStorageAdapter,
} from "@vantage/page-builder";
function App() {
const storage = new LocalStorageAdapter();
const editor = useVantageEditor({ pageId: "home", storage });
return <PageEditor editor={editor} />;
}import {
PageEditor,
StorageAdapter,
HistorySnapshot,
} from "@vantage/page-builder";
import axios from "axios";
class AxiosStorageAdapter implements StorageAdapter {
async save(pageId: string, data: PageData): Promise<void> {
await axios.put(`/api/pages/${pageId}`, data, {
headers: { Authorization: `Bearer ${token}` },
});
}
async load(pageId: string): Promise<PageData | null> {
const res = await axios.get(`/api/pages/${pageId}`);
return res.data;
}
async saveHistory(pageId: string, history: HistorySnapshot[]): Promise<void> {
await axios.put(`/api/pages/${pageId}/history`, { history });
}
async loadHistory(pageId: string): Promise<HistorySnapshot[] | null> {
const res = await axios.get(`/api/pages/${pageId}/history`);
return res.data.history;
}
}
const storage = new AxiosStorageAdapter();
const editor = useVantageEditor({ pageId: "home", storage });
<PageEditor editor={editor} />;import { PageEditor, StorageAdapter } from "@vantage/page-builder";
import {
savePage,
loadPage,
savePageHistory,
loadPageHistory,
} from "./actions";
class ServerActionStorageAdapter implements StorageAdapter {
async save(pageId: string, data: PageData): Promise<void> {
await savePage(pageId, data);
}
async load(pageId: string): Promise<PageData | null> {
return await loadPage(pageId);
}
async saveHistory(pageId: string, history: HistorySnapshot[]): Promise<void> {
await savePageHistory(pageId, history);
}
async loadHistory(pageId: string): Promise<HistorySnapshot[] | null> {
return await loadPageHistory(pageId);
}
}
const storage = new ServerActionStorageAdapter();
const editor = useVantageEditor({ pageId: "home", storage });
<PageEditor editor={editor} />;import { PageEditor, StorageAdapter } from "@vantage/page-builder";
class FetchStorageAdapter implements StorageAdapter {
async save(pageId: string, data: PageData): Promise<void> {
await fetch(`/api/pages/${pageId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
async load(pageId: string): Promise<PageData | null> {
const res = await fetch(`/api/pages/${pageId}`);
if (!res.ok) return null;
return res.json();
}
// History methods are optional
async saveHistory(pageId: string, history: HistorySnapshot[]): Promise<void> {
await fetch(`/api/pages/${pageId}/history`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ history }),
});
}
async loadHistory(pageId: string): Promise<HistorySnapshot[] | null> {
const res = await fetch(`/api/pages/${pageId}/history`);
if (!res.ok) return null;
const json = await res.json();
return json.history;
}
}
const storage = new FetchStorageAdapter();
const editor = useVantageEditor({ pageId: "home", storage });
<PageEditor editor={editor} />;Any React component works! Just register it and it instantly becomes
draggable and resizable. The registered component receives the element's
content object as props, so you can pass it through to existing components
that expect a specific shape (e.g. CMS or backend model).
Filling the container: Each component is rendered inside a fixed-size resize
cell. For the selection box to match what the user sees and for resizing to feel
correct, the visible component should fill its container (e.g. root node
with width: 100%; height: 100%). The cell wrapper has data-vantage-cell; you
can add global CSS such as
[data-vantage-cell] > * { width: 100%; height: 100%; } so all registered
components fill by default.
import {
PageEditor,
useVantageEditor,
ComponentRegistry,
} from "@vantage/page-builder";
const components: ComponentRegistry<"button" | "card"> = {
button: ({ label, onClick }) => <button onClick={onClick}>{label}</button>,
card: ({ title, children }) => (
<div className="card">
<h3>{title}</h3>
{children}
</div>
),
};
const editor = useVantageEditor({ pageId: "home", components });
<PageEditor editor={editor} />;const editor = useVantageEditor({
pageId: "home",
gridSize: 50,
breakpoints: {
desktop: 1440,
tablet: 1024,
mobile: 375,
},
onSave: data => {
console.log("Page saved:", data);
},
});
<PageEditor editor={editor} />;Each element has an internal id used by the page builder. You can also set an
optional externalId (user-defined id) to link that component to your own
backend or CMS. This keeps layout in the page builder and component-related data
in your model.
- On add:
editor.addElement("text", undefined, sectionId, "my-cms-block-123") - Pre-created components: When your backend pre-creates a component and
returns an id, pass it as the 4th argument:
addElement(type, content, sectionId, backendId)so the element is created withexternalIdfrom the start. - Later:
editor.updateElement(elementId, { externalId: "my-cms-block-123" }) - In page data: Every
PageElementmay haveexternalId?: string. It is persisted with the page and included inonSave/ storage. Use it to look up the corresponding record in your database or CMS when rendering or syncing.
Element id format: New elements get a client-generated id (e.g.
el-${Date.now()}). Use externalId for the canonical server id; treat client
ids like el-* as "new" for create vs update.
When your backend creates the component and returns a persistent id (e.g.
MongoDB _id):
- Persist that id as
externalIdin the saved layout, or return it in thePageDatafrom your adapter'ssave()(see Save return value above). - After save, the editor will update (via returned data or your own
setPageData) so the next save uses that id for updates instead of sending the temp id to an update API. - Do not send client-generated ids (e.g.
el-*) to backend update endpoints that expect server ids; useexternalIdfor the canonical server id.
Layout vs. order: Placement is defined by layout (position and size per breakpoint). Element order in the array is derived when syncing to a backend that uses layout as the source of truth, do not rely on array order for “dirty” or save logic.
The editor exposes unsaved changes so you can drive an external Save/Cancel bar:
- State:
editor.isDirtyandeditor.hasUnsavedChanges(aliases) aretruewhen the currentpageDatadiffers from the last saved or loaded baseline. - Callback: Pass
onDirtyChange={(dirty) => { ... }}inuseVantageEditor({ ... })to be notified when dirty state changes.
If you use an external Save button, you can rely on isDirty (or
onDirtyChange) instead of comparing pageData to a snapshot in your own
logic. For hosts that need a stable “vantage editor is active” signal (e.g. to
avoid clearing dirty state from other logic): set your ref or flag inside the
editor component when it mounts, and clear it only in the parent when the
user explicitly leaves the vantage editor (e.g. switches tabs). Do not clear
that ref in the editor’s effect cleanup, so React effect order and Strict Mode
don’t clear it before your hook runs.
When users edit component content in an external form or sidebar (title, URL,
styling, etc.), sync that into the editor so the next save persists it. Use
editor.updateElement(elementId, { content: updatedContent }) with the
element’s vantage id (look up by externalId if your UI is keyed by backend
id). For content-only updates you can use
editor.updateElementContent(elementId, content) so intent is clear and you
don’t accidentally overwrite layout.
Manages page data and saving:
const { pageData, setPageData, save } = usePageData(pageId, {
storage?: StorageAdapter,
autoSaveDelay?: number,
onSave?: (data: PageData) => void,
onSaved?: (data: PageData) => void,
initialData?: PageData,
});Provides actions for manipulating elements:
const {
addElement,
updateLayout,
updateElement,
deleteElement,
updateZIndex,
ensureBreakpointLayout,
} = usePageActions(pageData, setPageData, {
gridSize?: number,
breakpoints?: Record<Breakpoint, number>,
canvasHeight?: number,
});import {
usePageData,
usePageActions,
StorageAdapter,
} from "@vantage/page-builder";
import axios from "axios";
// Implement your own storage adapter
class MyStorageAdapter implements StorageAdapter {
async save(pageId: string, data: PageData): Promise<void> {
await axios.put(`/api/pages/${pageId}`, data);
}
async load(pageId: string): Promise<PageData | null> {
const res = await axios.get(`/api/pages/${pageId}`);
return res.data;
}
}
function MyEditor() {
const storage = new MyStorageAdapter();
const { pageData, setPageData, save } = usePageData("page-1", {
storage,
autoSaveDelay: 2000, // Auto-save every 2 seconds (optimistic updates)
});
const { addElement, updateLayout } = usePageActions(pageData, setPageData);
// Your custom UI here
}Enable persistent history that survives page refreshes:
import {
PageEditor,
StorageAdapter,
HistorySnapshot,
} from "@vantage/page-builder";
import axios from "axios";
class MyStorageAdapter implements StorageAdapter {
async save(pageId: string, data: PageData): Promise<void> {
await axios.put(`/api/pages/${pageId}`, data);
}
async load(pageId: string): Promise<PageData | null> {
const res = await axios.get(`/api/pages/${pageId}`);
return res.data;
}
// History methods (required for persistHistory: true)
async saveHistory(pageId: string, history: HistorySnapshot[]): Promise<void> {
await axios.put(`/api/pages/${pageId}/history`, { history });
}
async loadHistory(pageId: string): Promise<HistorySnapshot[] | null> {
const res = await axios.get(`/api/pages/${pageId}/history`);
return res.data?.history || null;
}
async clearHistory(pageId: string): Promise<void> {
await axios.delete(`/api/pages/${pageId}/history`);
}
}
const storage = new MyStorageAdapter();
const editor = useVantageEditor({
pageId: "home",
storage,
persistHistory: true, // Enable server-side history
maxHistorySize: 100, // Store up to 100 undo steps
});
<PageEditor editor={editor} />;How it works:
- Optimistic Updates: UI updates immediately, server syncs in background
- History Persistence: Undo/redo history is saved via your storage adapter
- Auto-Load: History loads automatically when page opens
- Debounced: History saves are debounced (1 second) to reduce server calls
Note: The package never makes network requests. You implement saveHistory,
loadHistory, and clearHistory methods using your preferred HTTP client.
import { LiveView } from "@vantage/page-builder";
<LiveView pageData={pageData} components={components} />;npm run buildNote: The prepare script will automatically build the package on install.
However, committing dist/ is recommended for:
- Faster installs - No build step needed
- More reliable - No risk of build failures or missing TypeScript
- Better CI/CD - Consuming projects don't need TypeScript installed
npm run example:dev- Build the package:
npm run build - Commit the
dist/folder (recommended for faster installs) - Push to GitHub
- Install via:
npm install github:dwrth/vantage
.
├── src/ # Package source code
│ ├── core/ # Core types and config
│ ├── hooks/ # React hooks
│ │ ├── usePageEditor.ts # Full editor hook
│ │ ├── usePageData.ts # Headless data management
│ │ └── usePageActions.ts # Headless element actions
│ ├── components/ # React components
│ ├── utils/ # Utility functions
│ └── adapters/ # Storage and component adapters
├── example/ # Example Next.js app
│ └── app/ # Example app pages
└── dist/ # Built package (generated)
PageEditor- Main editor componentLiveView- Preview/published view componentBreakpointSwitcher- Breakpoint selectorGridOverlay- Visual grid overlay
useVantageEditor- Creates the editor instance (recommended). Pass options{ pageId, ...config }; returnsVantageEditorInstance.usePageEditor- Lower-level editor hook (same return shape as useVantageEditor; useVantageEditor is the public API).usePageData- Headless data management hookusePageActions- Headless element manipulation hook
VantageEditorInstance<T>- The editor instance type (state, setters, actions likeupdateElement,updateElementContent,pageData,save). Use it to type props or custom UI that receives the instance.UseVantageEditorOptions<T>- Options passed touseVantageEditor(includesstorage,initialData,onDirtyChange, etc.).UsePageDataOptions<T>- Options forusePageData(storage,onSave,initialData,onSaved). Use when typing custom adapters or wrappers.StorageAdapter- Storage interface (save can returnPageData | null).PageData,PageElement,Section,GridPlacement, etc. - Core data types.
StorageAdapter- Storage interfaceLocalStorageAdapter- Browser localStorage implementationComponentRegistry- Component registry interface
MIT