ForgeWP

Choose How Block Content Is Edited

Compare block attributes, inline RichText, nested blocks, content slots, and fixed component content.

ForgeWP lets you choose exactly which parts of a block editors can change. Start by registering the block, then connect its editable values:

For a complete walkthrough, follow Build an Editable Hero Block, then try Add an Editable CTA and Image.

For editor-composed sections, follow Build a Section with Nested Blocks and practice controlling child blocks and movement.

  • A block is registered when WordPress can insert it from the block inserter.
  • A value is editable when an editor can change it in WordPress and the change is stored somewhere.
  • A value is inline-editable when the editor changes it directly in the block canvas.

You can register a block with no editable content, or make only one field editable while keeping layout, icons, and links controlled by code.

The four common choices

1. Sidebar attributes

Use ordinary defineBlock() attributes when a value should be edited through generated controls in the block sidebar. This is a good fit for colors, URLs, switches, numbers, icon slugs, and content that does not need canvas editing.

src/blocks/Alert.tsx
1import { defineBlock } from "@forgewp/react"
2
3export default defineBlock({
4  name: "alert",
5  title: "Alert",
6  category: "design",
7  attributes: {
8    tone: {
9      type: "string",
10      default: "info",
11      control: "select",
12      options: ["info", "warning", "success"],
13    },
14    dismissible: { type: "boolean", default: false },
15  },
16  edit: ({ attributes }) => (
17    <aside data-tone={attributes.tone}>
18      The message is fixed in this example; tone and behavior are configurable.
19    </aside>
20  ),
21})

ForgeWP generates editor controls from the attribute definitions. This does not make the literal message editable. Add a string attribute, use WpEditable, or use child blocks if the message belongs to the editor.

How block editability works

ForgeWP separates the block's saved data from the component that renders it. During export, the compiler turns the block definition into WordPress block metadata and an editor implementation. On the visitor-facing side, the same attributes are rendered through the generated PHP renderer. A value is editable only when the block exposes it through one of the supported editing mechanisms below.

Keep these responsibilities separate:

Output
Attribute or schema field = where the value is stored
Generated control         = how it is changed in the editor UI/sidebar
WpEditable                = how text is changed directly on the canvas

An attribute or schema field can power a sidebar control without making the rendered element inline-editable. Inline editing must be explicitly wired with WpEditable (or with a nested block editing surface).

1. Block attributes and controls

Declare a value in defineBlock({ attributes: ... }). ForgeWP stores the value in the block comment in post content and generates the matching editor control, typically in the block's Inspector/sidebar. This is the right choice for settings such as tone, layout mode, alignment, URLs, colors, numbers, and other structured values.

The attribute value is available in both edit({ attributes, setAttributes }) and the visitor-facing renderer. Changing it in Gutenberg updates the block's saved attributes. Bind each editable piece of JSX to the corresponding attribute.

2. Inline editing with WpEditable

Use WpEditable inside edit() when the author should edit text directly in the Gutenberg canvas. In the editor, ForgeWP compiles it to the appropriate native WordPress text control. In local Vite development it behaves like an inline editable element, while exported visitor markup is ordinary escaped HTML.

TSX
1<WpEditable
2  tagName="h2"
3  value={attributes.heading}
4  onChange={(value) => setAttributes({ heading: value })}
5/>

WpEditable is an editing surface, not a persistence layer by itself. The onChange handler must write the new value through setAttributes or another supported data path.

3. Schema-backed block content

For content shared with page templates or other blocks, define an editable schema with ForgeWP's field helpers such as text, richText, image, boolean, color, url, select, number, and repeater. The compiler uses that schema to generate the corresponding WordPress fields, typically exposed through editor/sidebar controls, and to expose the values to the component. A schema defines the fields and their persistence. Pair a schema value with WpEditable to let authors edit it directly on the canvas.

Keep the schema as the source of truth when several components share the same content. buildPageEditable() combines section schemas into a page schema, and pickEditable() selects or renames fields for a block without redefining their types and defaults.

For example:

TSX
1export const editable = pickEditable(pageEditable, {
2  title: "hero_title",
3  subtitle: "hero_subtitle",
4})

The from and pick metadata supported by @forgewp-block provides the annotation equivalent of this schema reuse. These fields are dual-hosted when configured: the block keeps its attributes in post content, while the compiler can synchronize selected values to WordPress post meta for page-template reads.

Shared block canvas rendering

When a block's edit implementation renders the same reusable component used for its visitor output, ForgeWP uses that edit JSX as the default Gutenberg canvas. This lets the block keep one layout/component implementation while WpEditable supplies inline RichText controls and attributes supply persisted values. Components nested inside another component retain their passed children and layout props in the generated canvas.

Gutenberg export and local preview use separate runtimes. Local BlockArea parses mock post.content; supply imported block definitions through previewBlocks to render their saved attributes and nested shells. Saved core HTML is preserved, while unknown/server-only blocks show placeholders. Follow local block previews to wire the map. Verify the actual editor and visitor output on WordPress separately.

editorPreview is an optional fallback for blocks whose editor cannot safely render the normal edit component, such as an interactive form or a live-data view. If a block deliberately needs its separate preview even though edit can render, opt into the override:

TSX
1export default defineBlock({
2  name: "interactive-form",
3  title: "Interactive Form",
4  editorPreviewMode: "override",
5  edit: ({ attributes }) => <InteractiveForm {...attributes} />,
6  editorPreview: ({ attributes }) => (
7    <StaticFormPreview heading={attributes.heading} />
8  ),
9})

Without editorPreviewMode: "override", a renderable edit implementation takes precedence and the explicit preview is used only as a fallback. This changes the previous preview-first behavior for blocks that define both; add the override when a project intentionally depends on the separate preview. Blocks with live hooks or interactions that cannot be expanded safely should keep their explicit inert preview or use WordPress server rendering.

ForgeWP compiles WpImage to an editor-safe image element and resolves theme-relative asset paths to the active theme URL. Image fields selected in the Media Library retain their URL and alt text for the canvas preview.

Links in a generated canvas do not navigate away from wp-admin. For a link whose href is bound to a block attribute, clicking the link opens an inline URL editor; the same URL remains available in the block sidebar. Put the visible label in WpEditable to keep it editable as RichText independently of the destination:

TSX
1<a href={attributes.buyUrl}>
2  <WpEditable
3    tagName="span"
4    value={attributes.buyLabel}
5    onChange={(value) => setAttributes({ buyLabel: value })}
6  />
7</a>

Declare image and URL attributes (or editable schema fields) to provide the Media Library picker and URL sidebar control. These controls persist values; they do not make arbitrary canvas text inline-editable.

Framer Motion motion.* elements render as their underlying static HTML elements in this canvas; animation-only props are removed. Live-data hooks and actual event handlers are not run in the generated preview. Keep an explicit inert editorPreview for those blocks, or use editorPreviewMode: "override" when the visitor component itself cannot safely serve as the canvas.

Visitor rendering: A WpQueryLoop nested in a defineBlock() visitor component compiles to a WordPress WP_Query loop in the generated block render.php. Hooks such as useWpTitle(), useWpExcerpt(), useWpPermalink(), useWpFeaturedImage(), and useWpCustomField() resolve against each queried post, and the parent post context is restored after the loop. The supported query props are postType, postsPerPage, categoryName, orderby, order, postId, metaKey, metaValue, metaCompare, taxTaxonomy, taxTerms, and postIn. postIn names a custom field on the current parent post containing related post IDs. Query inputs are normalized before being passed to WordPress; meta comparison operators are allow-listed.

This visitor behavior does not make mock data live: local and Gutenberg previews can still use mock/editor data, while the exported renderer queries WordPress at request time. Include the data hooks inside the loop's rendered children so each result uses its own post context. For images, visitor PHP resolves attachment IDs/objects, remote URLs, and theme-relative asset paths. Validate query-backed output by inspecting the generated render.php and running php -l on exported PHP before WordPress testing.

TSX
1attributes: {
2  cover: { type: "object", control: "image", label: "Cover image", default: null },
3  targetUrl: { type: "string", control: "url", label: "Destination", default: "/about" },
4},
5edit: ({ attributes }) => (
6  <WpLink href={attributes.targetUrl}>
7    <WpImage src={attributes.cover || "/assets/cover.jpg"} alt="Cover" />
8  </WpLink>
9)

Choose where editable values are stored

Keep fixed text and temporary React state in the component. For values editors save in WordPress, choose block attributes for block-local settings, WpEditable bound to saved values for inline text, child blocks for nested content, or an editable schema for shared/page-level content.

2. Inline text editing with WpEditable

Use WpEditable for text that should be changed directly in the canvas. In local Vite development it behaves like a native contentEditable element. In the Gutenberg editor the compiler converts it to a native RichText control. In the exported visitor markup it becomes ordinary escaped HTML, not a live editor.

Inside a block edit() function
1<WpEditable
2  tagName="h2"
3  value={attributes.heading}
4  onChange={(value) => setAttributes({ heading: value })}
5  className="text-2xl font-bold"
6/>

Inline editing is opt-in per field. Use a normal element for a heading that visitors should see but editors should not change. onChange receives the edited HTML string, so choose the field type and output markup deliberately when rich text is allowed.

3. Nested blocks with InnerBlocks

Use a parent shell when editors should insert, remove, or reorder child blocks. Scaffold one with make:shell, or define innerBlocks on a defineBlock() declaration. The shell owns layout; child blocks own their own attributes and editability.

Terminal
pnpm forgewp make:shell FeatureSection

A shell can specify allowedBlocks, a starting template, templateLock, and an editor orientation. Its shell settings control the parent wrapper's layout classes. These are separate from block attributes, which store editable values, and from innerBlocks, which defines the child-block rules and starter layout.

For a repeatable scaffold, create the child blocks first and then name them explicitly:

Terminal (children followed by a layout shell)
pnpm forgewp make:block FeatureCopy --attributes=title,description
pnpm forgewp make:block FeatureNote --attributes=title,description
pnpm forgewp make:shell FeatureSection --children=feature-copy,feature-note --layout=2-col --yes

This writes three source files. Each child stays registered as an independent block; the shell's allowedBlocks and starter template determine its nested composition. In the shell source, a template row can also provide initial child attributes, for example ["feature-copy", { title: "Our approach" }]. These are starting values for newly inserted children, not a synchronization rule for existing saved instances. Export the theme to generate WordPress assets; verify insertion and rearrangement in WordPress separately.

src/blocks/FeatureSectionShell.tsx
1import { defineBlock } from "@forgewp/react"
2
3export default defineBlock({
4  name: "feature-section-shell",
5  title: "Feature Section",
6  category: "design",
7  icon: "columns",
8  attributes: {},
9  innerBlocks: {
10    allowedBlocks: ["feature-copy", "feature-image"],
11    template: [["feature-copy"], ["feature-image"]],
12    templateLock: false,
13    orientation: "horizontal",
14  },
15  shell: {
16    className: "mx-auto max-w-7xl px-6 py-12",
17    gridClassName: "grid grid-cols-1 gap-8 lg:grid-cols-2",
18  },
19  edit: () => null,
20})

In this example, attributes: {} means the parent shell stores no separate values. allowedBlocks restricts which child blocks can be inserted, and template provides the initial two-child layout. templateLock: false lets editors add, remove, and rearrange children. shell.className and shell.gridClassName control layout; they do not create editable content fields. Put editable text or settings on the child blocks that own that content.

Use this structure when editors should compose sections from reusable blocks instead of editing one large text attribute.

4. A bounded WordPress content slot

Use <BlockArea name="main-content" /> when a fixed React page or template should reserve a location for the post's normal Gutenberg content. It is not a custom block definition. During export ForgeWP replaces it with the_content(), so WordPress renders the post's saved block tree in that slot. Generated custom Page templates enter the WordPress Loop before rendering the slot, preserving the current Page context for dynamic blocks supplied by WordPress or plugins. Pages containing a BlockArea are not wrapped in one page-wide hydration island, so client hydration cannot replace PHP-rendered block output with a React-only preview. For example, an EDD Buy Button block in the slot can select a Download in the editor; EDD's separately assigned Checkout Page remains responsible for checkout.

src/app/page.tsx
1import { BlockArea } from "@forgewp/react"
2
3export default function Page() {
4  return (
5    <main>
6      <header>Layout controlled by the theme</header>
7      <BlockArea name="main-content" />
8    </main>
9  )
10}

Use BlockArea when editors should compose normal WordPress content inside a coded layout. Use a custom block when editors should insert one named, reusable section from the inserter.

Standard architecture for editable sections

For a section that must work both inside a baked page and as an insertable Gutenberg block, use one component and one content contract. Do not create separate ACF and block implementations with duplicated markup.

The recommended project shape is:

Output
cms/editables/sections/hero.ts  → canonical section fields and defaults
cms/editables/front-page.ts     → baked page schema composed from sections
src/components/HeroSection.tsx  → shared renderer with page and block inputs
                                 → @forgewp-block generates the block wrapper

The section component should follow this data flow:

TSX
1export const editable = pickEditable(frontPageEditable, {
2  title: "hero_title",
3  subtitle: "hero_subtitle",
4})
5
6export function HeroSection({ title: titleProp, setAttributes }: Props) {
7  const titleMeta = useWpMeta("hero_title", defaults.hero_title)
8  const title = titleProp ?? titleMeta
9
10  return setAttributes ? (
11    <WpEditable
12      tagName="h1"
13      value={title}
14      onChange={(value) => setAttributes({ title: value })}
15    />
16  ) : (
17    <h1>{title}</h1>
18  )
19}

This gives the section two compatible hosts:

Table · 3 columns
Host
Value source
Output
Baked page
useWpMeta() and the page ACF schema
Theme page/PHP output
Gutenberg block
Block attributes and setAttributes()
Registered block/PHP output

Use pickEditable() when block fields must map to existing page ACF keys. ForgeWP then includes the fields in the generated block and can synchronize changed block attributes back to the matching post-meta fields. When the imported page schema uses buildPageEditable() and pageEditableSections, the compiler resolves the composed section fields into the generated block attributes as well. Use buildPageEditable() or pageEditableSections to compose page-level ACF schemas from reusable section schemas.

ForgeWP provides a standard architecture for sections that need to work in both baked pages and Gutenberg blocks: define one editable schema, compose it into the page schema, reuse the section component, and optionally project its fields into a block with pickEditable(). The framework keeps page ownership, block ownership, and dual hosting explicit because these choices affect persistence and conflict behavior. Use dual hosting only when the block-to-ACF mapping is intentional.

Scaffold the standard section shape

For a new section, use the additive generator:

BASH
pnpm forgewp make:section HeroSection --page=front-page --block

The command creates a canonical schema under cms/editables/sections/, a shared component under src/components/, and a page contract under cms/editables/<page>.ts when that page contract does not already exist. The component uses pickEditable() for the block projection, prefixed useWpMeta() keys for the baked page, and @forgewp-block when --block is supplied.

If the target page contract already exists, the generator does not modify it. Add the new section to its existing buildPageEditable() or pageEditableSections composition intentionally. Existing files are never overwritten.

Which choice should I make?

Table · 2 columns
Editorial requirement
Recommended model
The author changes a color, URL, toggle, or number
Sidebar attribute
The author changes a heading or short paragraph in place
WpEditable plus a string attribute
The author composes a section from paragraphs, buttons, and images
Parent shell plus InnerBlocks
The author edits the normal page body inside a coded template
BlockArea
The author changes a page field that multiple sections also read
Shared editable schema and ACF/page meta
The content is part of the design and should not be changed
Fixed JSX

There is no requirement to expose every prop. Keep layout and behavior in code unless an editor genuinely needs control over them.

The three runtime views

The same source can have three different behaviors:

Flowchart
Rendering diagram...

Do not test editability only in local preview. Confirm the generated block in WordPress, save a post, reload it, and check the visitor frontend.