Compound Block Tutorial: Scaffolding Parent/Child `InnerBlocks` Patterns with `wp-typia`
This tutorial walks through the compound built-in template. It is the starting point for blocks that need one user-facing parent block plus hidden implementation child blocks managed through InnerBlocks.
Prerequisites
Section titled “Prerequisites”- Node.js 20+ installed
- WordPress development environment (wp-env or local server)
- Familiarity with the Basic Block Tutorial
What is the Compound Template?
Section titled “What is the Compound Template?”The compound template scaffolds:
- a top-level parent block
- a hidden child block constrained by
parent - a multi-block plugin layout under
src/blocks/* InnerBlockswiring with two default child items
By default it is a pure static compound block. If you also pass --data-storage or --persistence-policy, the parent block gains the same count-like persistence wiring used by the persistence template.
Step 1: Create a Compound Block
Section titled “Step 1: Create a Compound Block”Pure compound scaffold:
npx wp-typia compound-demo --template compound --package-manager npm --yes --no-installcd compound-demonpm installCompound scaffold with parent-only persistence:
npx wp-typia compound-demo \ --template compound \ --persistence-policy authenticated \ --package-manager npm \ --yes \ --no-installThe same opt-in local presets are available here too:
--with-wp-envfor project-localwp-envscripts--with-test-presetfor a test-onlywp-envconfig and minimal Playwright smoke test
Step 2: Understand the Generated Structure
Section titled “Step 2: Understand the Generated Structure”The compound template creates a multi-block plugin layout instead of a single src/ block root:
compound-demo/├── src/│ └── blocks/│ ├── compound-demo/│ │ ├── block.json│ │ ├── children.ts│ │ ├── edit.tsx│ │ ├── hooks.ts│ │ ├── index.tsx│ │ ├── save.tsx│ │ ├── style.scss│ │ ├── types.ts│ │ └── validators.ts│ └── compound-demo-item/│ ├── block.json│ ├── edit.tsx│ ├── hooks.ts│ ├── index.tsx│ ├── save.tsx│ ├── types.ts│ └── validators.ts├── scripts/│ ├── add-compound-child.ts│ ├── block-config.ts│ ├── sync-project.ts│ └── sync-types-to-block-json.ts├── compound-demo.php├── package.json└── webpack.config.jsFresh scaffolds already include starter typia.manifest.json files in the parent and default child block directories so editor/runtime imports resolve before the first sync. After you run npm run sync, npm run dev, or npm run start, each block directory also gains:
typia.manifest.jsontypia.schema.jsontypia.openapi.jsontypia-validator.php
If persistence is enabled for the parent block, it additionally gains:
api.openapi.jsonapi-types.tsapi-validators.tsapi.tsinteractivity.tsrender.phpapi-schemas/*.schema.jsonapi-schemas/*.openapi.json
In persistence-enabled compound scaffolds, src/blocks/<parent>/api.openapi.json is the canonical REST surface document for the parent block. The api-schemas/*.schema.json files remain the runtime contract artifacts, and api-schemas/*.openapi.json files remain per-contract compatibility fragments.
Step 3: Parent and Child Roles
Section titled “Step 3: Parent and Child Roles”The parent block is the only block users insert directly.
- block name:
create-block/compound-demo - editor UI: heading, intro text, and an
InnerBlocksarea - save behavior: stores the composed child markup
The child block is an internal implementation detail.
- block name:
create-block/compound-demo-item supports.inserter: falseparent: ['create-block/compound-demo']- minimal title/body editing UI
Step 4: How the Parent Tracks Child Blocks
Section titled “Step 4: How the Parent Tracks Child Blocks”The parent block now keeps its scaffold-owned child registry in src/blocks/<parent>/children.ts:
export const DEFAULT_CHILD_BLOCK_NAME = 'create-block/compound-demo-item';
export const ALLOWED_CHILD_BLOCKS = [DEFAULT_CHILD_BLOCK_NAME];
export const DEFAULT_CHILD_TEMPLATE = [ [ DEFAULT_CHILD_BLOCK_NAME, { title: 'First Item', body: 'Add supporting details for the first internal item.', }, ], [ DEFAULT_CHILD_BLOCK_NAME, { title: 'Second Item', body: 'Add supporting details for the second internal item.', }, ],];The parent edit component consumes that registry to drive:
allowedBlocksso only the child block can be inserted inside the parent- the default seeded child template
templateLock={ false }so users can reorder and add more internal itemsInnerBlocks.ButtonBlockAppenderso the parent owns the insertion affordance
Step 5: How the Child Stays Internal
Section titled “Step 5: How the Child Stays Internal”The child block.json keeps the block out of the global inserter:
{ "name": "create-block/compound-demo-item", "parent": ["create-block/compound-demo"], "supports": { "html": false, "inserter": false, "reusable": false }}This pattern is useful when the child block exists only to structure the parent implementation, not as a reusable standalone block. Parent and child editors still use validated attribute updaters and surface Typia validation errors inside the editor UI.
Step 6: Add Another Child Block Type
Section titled “Step 6: Add Another Child Block Type”Use the generated extension workflow when the parent needs another hidden child block type:
npm run add-child -- --slug faq-item --title "FAQ Item"That command:
- creates
src/blocks/compound-demo-faq-item/ - updates
scripts/block-config.ts - updates
src/blocks/compound-demo/children.ts - keeps the default seeded template unchanged, so existing content does not churn
After adding the child block type, run:
npm run syncnpm run sync is the common-case entrypoint. npm run sync-types remains available when you want metadata-only control; it stays warn-only by default, npm run sync-types -- --fail-on-lossy fails only on lossy WordPress projection warnings, and npm run sync-types -- --strict --report json emits a CI-friendly JSON report while failing on every warning.
Step 7: Optional Persistence on the Parent
Section titled “Step 7: Optional Persistence on the Parent”When you pass --data-storage or --persistence-policy, only the parent block gets persistence wiring.
That means:
- the parent becomes a dynamic block with
render.php - the parent gets typed REST contracts and an Interactivity API store
- the child block remains a pure static content container
This is a good fit for patterns like:
- tab sets with a persisted counter or status
- step lists with aggregate interaction tracking
- experiment containers where the top-level block owns state and children own content
Step 8: Build and Test
Section titled “Step 8: Build and Test”Run the normal scaffold lifecycle:
npm run syncnpm run buildIf you enabled persistence on the parent block:
npm run sync-restUse npm run sync for the common-case metadata + REST refresh before npm run build, npm run typecheck, or commit. npm run sync-rest remains available when you only want to refresh the parent REST layer, but it now fails fast when the block metadata artifacts are stale, so run npm run sync or npm run sync-types first when needed. The generated dev workflow watches the relevant sync steps for compound scaffolds, npm run start still runs them as one-shot syncs, and both npm run build and npm run typecheck verify that the checked-in artifacts are already current. They do not create migration history.
Then load the plugin in your WordPress environment and verify:
- the parent block appears in the inserter
- the child block does not appear in the global inserter
- inserting the parent seeds two child items
- save and reopen keeps the block valid
- the frontend renders the parent plus child content
What’s Next?
Section titled “What’s Next?”- Add custom inspector controls to the parent block
- Use
npm run add-child -- --slug ... --title ...when you need another hidden child type - Enable persistence on the parent when you need count-like behavior
- Adapt the pattern for tabs, steps, carousels, or experiment containers