Skip to main content
Back to Blog
Tutorials
4 min read
January 17, 2025

How to Build a WYSIWYG Page Builder in React

Create a drag-and-drop page builder with block components, inline editing, layout grid, and JSON serialization.

Ryel Banfield

Founder & Lead Developer

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.

page builderWYSIWYGdrag and dropReactCMStutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles