Page builders let non-technical users create pages visually. Here is how to build one with blocks and drag-and-drop.
Block Types
// types.ts
export type BlockType = "heading" | "text" | "image" | "columns" | "button" | "spacer";
export interface Block {
id: string;
type: BlockType;
props: Record<string, unknown>;
children?: Block[];
}
export interface PageData {
title: string;
blocks: Block[];
}
export const blockDefaults: Record<BlockType, () => Omit<Block, "id">> = {
heading: () => ({
type: "heading",
props: { text: "New Heading", level: "h2", align: "left" },
}),
text: () => ({
type: "text",
props: { text: "Enter your text here...", align: "left" },
}),
image: () => ({
type: "image",
props: { src: "", alt: "", width: "full" },
}),
columns: () => ({
type: "columns",
props: { columns: 2 },
children: [],
}),
button: () => ({
type: "button",
props: { text: "Click Me", href: "#", variant: "primary" },
}),
spacer: () => ({
type: "spacer",
props: { height: 40 },
}),
};
Builder State Management
"use client";
import { createContext, useContext, useReducer, useCallback } from "react";
import type { Block, BlockType, PageData } from "./types";
import { blockDefaults } from "./types";
type Action =
| { type: "ADD_BLOCK"; blockType: BlockType; index?: number }
| { type: "REMOVE_BLOCK"; id: string }
| { type: "MOVE_BLOCK"; fromIndex: number; toIndex: number }
| { type: "UPDATE_BLOCK"; id: string; props: Record<string, unknown> }
| { type: "SELECT_BLOCK"; id: string | null }
| { type: "SET_BLOCKS"; blocks: Block[] };
interface BuilderState {
blocks: Block[];
selectedId: string | null;
}
function builderReducer(state: BuilderState, action: Action): BuilderState {
switch (action.type) {
case "ADD_BLOCK": {
const def = blockDefaults[action.blockType]();
const newBlock: Block = { ...def, id: crypto.randomUUID() };
const blocks = [...state.blocks];
const index = action.index ?? blocks.length;
blocks.splice(index, 0, newBlock);
return { ...state, blocks, selectedId: newBlock.id };
}
case "REMOVE_BLOCK":
return {
...state,
blocks: state.blocks.filter((b) => b.id !== action.id),
selectedId: state.selectedId === action.id ? null : state.selectedId,
};
case "MOVE_BLOCK": {
const blocks = [...state.blocks];
const [moved] = blocks.splice(action.fromIndex, 1);
blocks.splice(action.toIndex, 0, moved);
return { ...state, blocks };
}
case "UPDATE_BLOCK":
return {
...state,
blocks: state.blocks.map((b) =>
b.id === action.id ? { ...b, props: { ...b.props, ...action.props } } : b,
),
};
case "SELECT_BLOCK":
return { ...state, selectedId: action.id };
case "SET_BLOCKS":
return { ...state, blocks: action.blocks };
default:
return state;
}
}
interface BuilderContextType {
state: BuilderState;
dispatch: React.Dispatch<Action>;
selectedBlock: Block | null;
}
const BuilderContext = createContext<BuilderContextType | null>(null);
export function useBuilder() {
const ctx = useContext(BuilderContext);
if (!ctx) throw new Error("useBuilder must be used within BuilderProvider");
return ctx;
}
export function BuilderProvider({
initialBlocks = [],
children,
}: {
initialBlocks?: Block[];
children: React.ReactNode;
}) {
const [state, dispatch] = useReducer(builderReducer, {
blocks: initialBlocks,
selectedId: null,
});
const selectedBlock = state.blocks.find((b) => b.id === state.selectedId) ?? null;
return (
<BuilderContext.Provider value={{ state, dispatch, selectedBlock }}>
{children}
</BuilderContext.Provider>
);
}
Block Renderers
"use client";
import { useBuilder } from "./BuilderState";
import type { Block } from "./types";
export function BlockRenderer({ block }: { block: Block }) {
const { state, dispatch } = useBuilder();
const isSelected = state.selectedId === block.id;
return (
<div
className={`relative group cursor-pointer transition-all ${
isSelected
? "ring-2 ring-primary ring-offset-2"
: "hover:ring-2 hover:ring-muted-foreground/20"
}`}
onClick={(e) => {
e.stopPropagation();
dispatch({ type: "SELECT_BLOCK", id: block.id });
}}
>
{/* Block toolbar */}
<div
className={`absolute -top-8 left-0 flex items-center gap-1 bg-background border rounded-md shadow-sm px-1 py-0.5 text-xs z-10 ${
isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100"
} transition-opacity`}
>
<span className="px-1 text-muted-foreground capitalize">
{block.type}
</span>
<button
onClick={(e) => {
e.stopPropagation();
dispatch({ type: "REMOVE_BLOCK", id: block.id });
}}
className="px-1 text-red-500 hover:bg-red-50 rounded"
>
Delete
</button>
</div>
<BlockContent block={block} />
</div>
);
}
function BlockContent({ block }: { block: Block }) {
const { dispatch, state } = useBuilder();
const isEditing = state.selectedId === block.id;
switch (block.type) {
case "heading": {
const Tag = (block.props.level as string) ?? "h2";
return (
<Tag
contentEditable={isEditing}
suppressContentEditableWarning
onBlur={(e: React.FocusEvent<HTMLElement>) =>
dispatch({
type: "UPDATE_BLOCK",
id: block.id,
props: { text: e.currentTarget.textContent },
})
}
className={`text-${block.props.align as string} ${
Tag === "h1" ? "text-4xl" : Tag === "h2" ? "text-3xl" : "text-2xl"
} font-bold outline-none`}
>
{block.props.text as string}
</Tag>
);
}
case "text":
return (
<p
contentEditable={isEditing}
suppressContentEditableWarning
onBlur={(e) =>
dispatch({
type: "UPDATE_BLOCK",
id: block.id,
props: { text: e.currentTarget.textContent },
})
}
className={`text-${block.props.align as string} text-base outline-none`}
>
{block.props.text as string}
</p>
);
case "image":
return (
<div className="relative">
{block.props.src ? (
<img
src={block.props.src as string}
alt={block.props.alt as string}
className="max-w-full rounded-lg"
/>
) : (
<div className="h-48 bg-muted rounded-lg flex items-center justify-center text-muted-foreground">
Click to add an image
</div>
)}
</div>
);
case "button":
return (
<div className={`text-${block.props.align as string ?? "left"}`}>
<span className="inline-block px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium">
{block.props.text as string}
</span>
</div>
);
case "spacer":
return (
<div
style={{ height: `${block.props.height as number}px` }}
className="bg-muted/30 border border-dashed border-muted-foreground/20 rounded"
/>
);
default:
return <div>Unknown block type</div>;
}
}
Property Panel
"use client";
import { useBuilder } from "./BuilderState";
export function PropertyPanel() {
const { selectedBlock, dispatch } = useBuilder();
if (!selectedBlock) {
return (
<div className="p-4 text-sm text-muted-foreground">
Select a block to edit its properties
</div>
);
}
function updateProp(key: string, value: unknown) {
dispatch({
type: "UPDATE_BLOCK",
id: selectedBlock!.id,
props: { [key]: value },
});
}
return (
<div className="p-4 space-y-4">
<h3 className="font-semibold capitalize">{selectedBlock.type}</h3>
{selectedBlock.type === "heading" && (
<>
<label className="block text-sm">
Level
<select
value={selectedBlock.props.level as string}
onChange={(e) => updateProp("level", e.target.value)}
className="mt-1 w-full border rounded-md px-2 py-1.5 text-sm"
>
<option value="h1">H1</option>
<option value="h2">H2</option>
<option value="h3">H3</option>
</select>
</label>
</>
)}
{selectedBlock.type === "image" && (
<label className="block text-sm">
Image URL
<input
value={selectedBlock.props.src as string}
onChange={(e) => updateProp("src", e.target.value)}
className="mt-1 w-full border rounded-md px-2 py-1.5 text-sm"
placeholder="https://..."
/>
</label>
)}
{selectedBlock.type === "button" && (
<label className="block text-sm">
Link URL
<input
value={selectedBlock.props.href as string}
onChange={(e) => updateProp("href", e.target.value)}
className="mt-1 w-full border rounded-md px-2 py-1.5 text-sm"
/>
</label>
)}
{selectedBlock.type === "spacer" && (
<label className="block text-sm">
Height (px)
<input
type="number"
value={selectedBlock.props.height as number}
onChange={(e) => updateProp("height", parseInt(e.target.value))}
className="mt-1 w-full border rounded-md px-2 py-1.5 text-sm"
/>
</label>
)}
{"align" in selectedBlock.props && (
<label className="block text-sm">
Alignment
<select
value={selectedBlock.props.align as string}
onChange={(e) => updateProp("align", e.target.value)}
className="mt-1 w-full border rounded-md px-2 py-1.5 text-sm"
>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</label>
)}
</div>
);
}
JSON Export and Import
function exportPage(blocks: Block[]): string {
return JSON.stringify({ version: 1, blocks }, null, 2);
}
function importPage(json: string): Block[] {
const data = JSON.parse(json);
if (data.version !== 1 || !Array.isArray(data.blocks)) {
throw new Error("Invalid page data");
}
return data.blocks;
}
Need a Custom CMS or Page Builder?
We build visual page builders for content teams. Contact us to get started.