ForgeWP

Page Content Slots and Editable Fields

Choose between fixed page layout, normal Gutenberg content, and page-level editable fields.

A custom page template answers where the layout comes from. It does not decide automatically which parts of that layout WordPress editors can change. Choose the content model for each part of the page.

Table · 2 columns
Content ownership
ForgeWP model
The developer owns the markup and text
Fixed JSX in the page component
Editors compose normal paragraphs, images, and blocks
<BlockArea />
Editors fill named page fields such as a hero heading or phone number
defineEditable() schema and useWpMeta()
Editors insert a reusable section from the block inserter
A Gutenberg block; see Gutenberg Blocks

These models can be combined in one page template.

Follow Build a Page with Editable Content for a complete BlockArea walkthrough, then practice connecting the Page title and adding a sidebar.

For named fields, follow Build Editable Room Details, then practice adding an enquiry label and destination.

Decide the editing contract before writing the layout

An editing contract is a short description of what a client is allowed to change. For example: "Staff can update room details and reorder promotional sections; the room-page layout remains developer-owned."

Table · 3 columns
Editing level
What the editor can change
Layout ownership
Named fields
Declared text, image, and other values
Developer-authored layout
Reusable section blocks
Block values and the order of permitted sections
Section internals follow the block's controls
Nested blocks
Child blocks inside a parent shell
Parent shell defines allowed children and structure
Fixed JSX
Values connected to WordPress data
Developer-authored JSX

For nested blocks, allowedBlocks limits child types and templateLock controls structural changes. templateLock: false removes the shell's template lock, but permissions and other WordPress restrictions can still apply. Test with the client's actual role.

ForgeWP uses Gutenberg for this editing workflow. Expose client controls through registered blocks, fields, and content slots, and manage the surrounding PHP-backed templates in source. Page-builder widgets and Site Editor block templates are separate authoring models.

Share one value across listings and detail pages

Choose one saved owner for each value before building both views. For a theme-owned custom post type, use post metadata for structured facts needed in more than one view. Both the listing card and detail template read the same field key. Use block attributes for section-specific copy and presentation, and the normal post body for editor-composed content.

For example, a workshop can use this contract:

Table · 3 columns
Value
Saved owner
Where it is read
Workshop name
WordPress post title
Card and detail heading
Date and location
Post metadata: session_date, session_location
Card and detail summary
Public participant count, if needed
Post metadata: session_participant_count
Any view showing that count
Programme text
Post body, composed with Gutenberg
Detail template through BlockArea
Section appearance and introductory copy
That section's block attributes
That block instance

An actual booking or attendee record belongs to the system managing registrations; a public count is not that record. When a plugin owns your content type, use its supported fields, rendering, and editing workflow rather than copying its values into theme-owned fields.

Read a shared text field with the same literal key in both components:

src/components/SessionDate.tsx
1import { useWpCustomField } from "@forgewp/react"
2
3export function SessionDate() {
4  const date = useWpCustomField("session_date", "Date to be confirmed")
5  return <p>{date}</p>
6}

In a listing, render this component inside WpQueryLoop so it reads the current result. In the single template, it reads the current post. Create the field's editing controls through your metadata schema or field plugin; this read hook alone does not create an editor control. See custom fields for setup.

In local fixtures, put the date once in that post's customFields.session_date. Do not also save a sessionDate attribute with the same fact in its serialized block content. Local fixtures supply preview data; they do not update saved WordPress records unless you explicitly run the seeding workflow.

Blocks that show shared facts

A block may display a metadata-owned fact while owning its own layout and copy. Do not offer a separate editable date attribute for that fact. Metadata access in the Gutenberg canvas needs an explicitly supported editor data path or an inert preview; a hook working in local preview or generated visitor PHP does not establish live editor data access.

resolveDualHost(prop, meta) is useful when a reusable component deliberately accepts either a block-owned value or a metadata-owned value. A supplied non-nullish prop wins, including an empty string, false, or zero. It does not save metadata, update block attributes, resolve conflicting edits, or keep two saved copies synchronized. Choose the owner at each use site.

When existing content has two copies

Inventory the affected posts and compare the saved values before changing readers. Select the authoritative source explicitly, back up the content, and migrate conflicts through a reviewed script or editor workflow. Verify listing and detail output before removing old fields. Re-exporting a theme does not reconcile existing block attributes with metadata automatically.

Complete templates and reusable blocks

A theme can ship both complete, developer-authored pages and reusable Gutenberg blocks. They are complementary products, not competing ways to render the same saved Page body:

Table · 2 columns
Editor choice
What the visitor gets
A generated custom Page template
The complete, ready-made layout authored by the theme developer. The Page body is used only where that template includes a <BlockArea />.
ForgeWP Builder
A block canvas rendered from the saved WordPress Page body. The editor can insert, edit, remove, and reorder the theme's reusable blocks alongside core and plugin blocks.
WordPress Default template
The theme's regular Page wrapper rendering saved WordPress content. When its page.php sends content through the_content(), editors can compose and reorder blocks here too.

Think of reusable blocks as the theme's bricks: a theme can provide finished hero, feature, pricing, event, or call-to-action blocks that an editor assembles into a different composition. Shipping a block makes it available in the inserter. Choose a content-rendering template when editors should compose a page from those blocks. Editors who want the developer-designed page keep its generated template. Editors who want to compose the page themselves select Default or ForgeWP Builder and build the Page body from blocks.

Use Default for the theme's ordinary Page wrapper around the composition. Use ForgeWP Builder for the dedicated ForgeWP block canvas. Both can render Gutenberg blocks; their wrapper and title behavior may differ.

This separation lets a theme author maintain polished, fully functional defaults while still giving clients a reusable block catalog for custom pages. Use <BlockArea /> when a specific part of a fixed template should accept Gutenberg or plugin content; use ForgeWP Builder when the whole Page should be composed in Gutenberg.

Create a page editable schema

ForgeWP includes a command for creating the schema file:

Terminal
pnpm forgewp make:editable AboutPage --fields=hero_title:text,hero_subtitle:richText

This creates cms/editables/about-page.ts. The command accepts text, richText, image, boolean, repeater, color, url, select, number, and icon field types. If you omit --fields, it creates hero_title and hero_subtitle text fields. The file already exports editable and defaults for use by components.

The command scaffolds the contract; it does not add a Gutenberg block and it does not replace the page component. Edit the schema, use its defaults in the page, and export the theme to generate the WordPress field registration.

For the command above, the new file initially looks like this:

cms/editables/about-page.ts (generated)
1import {
2  defineEditable,
3  getEditableDefaults,
4  richText,
5  text,
6} from "@forgewp/react"
7
8export const editable = defineEditable({
9  hero_title: text({
10    label: "Hero Title",
11    default: "Edit me",
12  }),
13  hero_subtitle: richText({
14    label: "Hero Subtitle",
15    default: "Edit me",
16  }),
17})
18
19export const defaults = getEditableDefaults(editable)

The generator uses the field names and types from the command. Replace the placeholder defaults and adjust labels or field options as part of authoring the schema.

Read fields in the page

src/app/pages/AboutPage.tsx
1import { useWpMeta } from "@forgewp/react"
2import { defaults } from "../../../cms/editables/about-page"
3
4export function AboutPage() {
5  const heroTitle = useWpMeta("hero_title", defaults.hero_title)
6  const heroSubtitle = useWpMeta("hero_subtitle", defaults.hero_subtitle)
7
8  return (
9    <main>
10      <h1>{heroTitle}</h1>
11      <p>{heroSubtitle}</p>
12    </main>
13  )
14}

During local development, useWpMeta() reads the mock post context. In the exported theme, ForgeWP resolves the field from WordPress metadata or ACF and falls back to the supplied default when no value exists.

Reserve the normal WordPress content area

Preview saved blocks locally

BlockArea parses the current mock post's content with WordPress's block serialization parser. Plain HTML and saved core-block markup render directly. To preview a ForgeWP block, pass its source definition in the local-only previewBlocks map:

src/blocks/SessionHeading.tsx
1import { defineBlock } from "@forgewp/react"
2
3export default defineBlock({
4  name: "session-heading",
5  title: "Session Heading",
6  attributes: { heading: { type: "string", default: "Session details" } },
7  edit: ({ attributes }) => <h2>{attributes.heading}</h2>,
8})
src/app/pages/SessionPage.tsx
1import { BlockArea } from "@forgewp/react"
2import sessionHeading from "../../blocks/SessionHeading"
3
4export function SessionPage() {
5  return (
6    <main>
7      <BlockArea
8        name="body"
9        previewBlocks={{ "forgewp/session-heading": sessionHeading }}
10      />
11    </main>
12  )
13}

In the current mock post, set content to <!-- wp:forgewp/session-heading {"heading":"Workshop agenda"} /-->. The local preview reads that attribute rather than requiring you to duplicate the heading in page JSX. Map keys can use the complete name or the short ForgeWP name, such as session-heading. Importing a definition does not register it globally for previews; each content area declares the blocks it supports.

The preview applies attribute defaults and uses the definition's save renderer when present, otherwise edit, with a read-only setAttributes callback. Layout shells with innerBlocks use the same shell classes as generated PHP and render their saved children recursively. Core layouts keep their saved HTML wrappers around those children. Preview does not insert missing template children or enforce Gutenberg insertion locks.

Unknown blocks and core blocks without saved markup show a named placeholder. Dynamic WordPress/plugin rendering, shortcodes, editor controls, and server data fetching still run on WordPress; the local map does not provide that runtime. Use local fixtures for data read by a preview component. Previewed HTML is trusted project content, not sanitized user input.

During export, BlockArea still becomes WordPress the_content(). previewBlocks does not change stored content or WordPress registration; keep block definitions in the normal block source directory. Live editor save/reload acceptance is a separate check.

Add a content slot

Use <BlockArea /> when the page should have a coded frame but let editors compose the normal post body inside it:

src/app/pages/ArticlePage.tsx
1import { BlockArea } from "@forgewp/react"
2
3export function ArticlePage() {
4  return (
5    <main className="prose mx-auto max-w-3xl">
6      <h1>Article layout from the theme</h1>
7      <BlockArea name="article-content" />
8    </main>
9  )
10}

In local preview, the component displays the active mock post content when available and otherwise shows a placeholder. During export, ForgeWP replaces the primitive with WordPress's the_content() call. The generated custom Page template enters the WordPress Loop before including the rendered layout, so the saved Gutenberg block tree and dynamic plugin blocks render with the current Page context. For example, an editor can place an EDD Buy Button block in this slot and select that site's Download in the block settings.

BlockArea is not a custom inserter block. Use a custom block when an editor should insert one named, reusable section into any post or page.

The name labels the slot, and BlockArea renders the current post's normal body. For independently stored sections, use a parent block with child blocks or explicit fields.

Keep the ownership boundary clear

  • A page schema field is page/post data. It can be read by fixed template components with useWpMeta().
  • A block attribute is stored with a block instance in post content.
  • WpEditable makes a selected block value editable in the canvas; it is not required for page fields.
  • BlockArea renders the normal post body; it does not expose the surrounding page JSX to the editor.

When a page field should also be available to a block, share the schema with pickEditable() or mergeEditable(). Continue to ACF, Editable Schemas, and Exported Blocks for the block-side bridge.

Check the editing contract in WordPress

  1. Export and install the theme on a test site.
  2. Edit a field or block value, save, and reload both the editor and the visitor page.
  3. Move or remove a section if that should be permitted.
  4. Confirm fixed layout regions stay outside the promised editing controls.
  5. Repeat after installing an updated theme against the same saved content.

Keep saved meta keys, block names, and attributes compatible across releases. Changing source defaults does not migrate existing content. Review seed settings before updates; Install and Verify explains the release checks.