How to Embed Structured Content Records in PageBuilder
Once you’ve defined a schema and editors have started adding records, the next question is always the same. How do you actually get those records on a page? Arc Themes ships content source blocks that handle the data-fetching side, so you don’t need to write your own. This guide walks through adding those blocks and building the front-end feature components that render what they return. Arc Themes doesn’t ship a display block for structured content, so that part is still on you.
What you’ll build
Two content sources (provided by Arc Themes) and two PageBuilder feature blocks you’ll write yourself:
- A list view of structured content records (event listing, recipe index, staff directory)
- A single record in full detail (event detail page, recipe card, profile page)
Events are used as the running example, but nothing here is event-specific. Swap the field names and the same pattern works for any schema you’ve defined in Arc XP.
Prerequisites
- You have at least one structured content type schema defined in Arc XP Data Manager or via the Draft API.
- Records exist in that schema.
- You have a PageBuilder bundle set up locally with the Arc Themes starter. See How to Run PageBuilder Engine Locally for initial setup.
- Your bundle has a
.envfile with:
CONTENT_BASE=https://api.sandbox.{org}.arcpublishing.comARC_ACCESS_TOKEN=<your-access-token>Step 1: Add the content source blocks
Add both blocks as dependencies in your bundle’s package.json and list them in blocks.json:
// package.json"dependencies": { "@wpmedia/schema-feed-content-source-block": "*", "@wpmedia/schema-item-content-source-block": "*"}// blocks.json"blocks": [ "@wpmedia/schema-feed-content-source-block", "@wpmedia/schema-item-content-source-block"]Run npm install, then restart npx fusion start. Each block registers a content source under a name derived from its source file: schema-feed for the list block, schema-item for the single-record block. Those are the names you’ll select in PageBuilder and reference in your feature blocks.
schema-feed: list of records
| Parameter | Required | Description |
|---|---|---|
| Schema Name | Yes | Exact schema name from Data Manager (e.g., events) |
| Sort By | No | Field to sort by. Sort order is always descending. There’s no ascending option. |
| Query | No | An ArcSL filter expression against schema_content fields (e.g., schema_content.location:"City Park"). Not a free-text keyword search. |
| Size | No | Records per page, default 25 |
| Page | No | Page number, default 1 |
Calls GET /content/v5/search/schemas/ and returns an array of record objects:
[ { "document_id": "abc123", "schema_name": "events", "schema_content": { "title": "Summer Block Party", "dt_start": "2026-07-04T18:00:00Z", "location": "City Park", ... } }]Field values live under item.schema_content, not at the top level.
schema-item: single record
| Parameter | Required | Description |
|---|---|---|
| Document ID | Yes | The document_id of the record to fetch |
| Schema Name | Yes | Exact schema name from Data Manager |
Calls GET /content/v5/search/schemas/by-id/ and returns a single object, not an array:
{ "document_id": "abc123", "schema_name": "events", "schema_content": { "title": "Summer Block Party", "dt_start": "2026-07-04T18:00:00Z", "location": "City Park", ... }}Same nesting as the feed source: fields live under data.schema_content.
Step 2: Build a feature block to render a list
Content sources are just the plumbing. Now for the part that actually shows up on the page. Feature components live in components/features/, and each one needs a file named default.jsx inside a folder named after your block.
Create components/features/event-list/default.jsx:
import React from "react";import PropTypes from "@arc-fusion/prop-types";import { useContent } from "fusion:content";import { useFusionContext } from "fusion:context";
const EventList = ({ customFields }) => { const { contentConfig, displayAmount } = customFields; const { arcSite } = useFusionContext();
// useContent fetches data from the content source the editor selected. // Pass the source name and the query params (contentConfigValues) from // the contentConfig custom field. const content = useContent( contentConfig ? { source: contentConfig.contentService, query: { ...contentConfig.contentConfigValues, // Pass the schema name your records belong to. schemaName: "events", }, } : {}, );
if (!contentConfig || !content?.data) return null;
// The feed source returns an array. Slice it to the requested display amount. const records = content.data.slice(0, displayAmount || content.data.length);
return ( <ul className="event-list"> {records.map((item) => { // Each item in the array has schema_content with your field values. const { title, dt_start, location, description } = item.schema_content || {}; const documentId = item.document_id;
return ( <li key={documentId}> {title && <h2>{title}</h2>} {dt_start && <p>{new Date(dt_start).toLocaleDateString()}</p>} {location && <p>{location}</p>} {description && <p>{description}</p>} </li> ); })} </ul> );};
EventList.propTypes = { customFields: PropTypes.shape({ // contentConfig renders the content source picker in PageBuilder. contentConfig: PropTypes.contentConfig().tag({ group: "Configure Content", label: "Content Source", }), displayAmount: PropTypes.number.tag({ label: "Number of records to display", defaultValue: 10, group: "Configure Content", }), }),};
EventList.label = "Event List – My Block";
export default EventList;Key patterns
useContent({ source, query }) fetches data from the named content source at render time. The source value has to match the registered content source name, schema-feed here.
contentConfig is doing the heavy lifting for editors. It’s a special prop type that renders the content source picker in PageBuilder’s settings panel, so editors pick a source and fill in its params without ever seeing code. contentConfig.contentService gives you the selected source name, and contentConfig.contentConfigValues gives you whatever the editor typed into the params (schemaName, size, query, and so on).
Step 3: Build a feature block to render a single record
A detail block has two ways to get its data. It can inherit globalContent if the template’s page-level content source is already set to schema-item, or it can configure its own content source independently. The component below supports both, controlled by a toggle.
Create components/features/event-details/default.jsx:
import React from "react";import PropTypes from "@arc-fusion/prop-types";import { useContent } from "fusion:content";import { useFusionContext } from "fusion:context";
const EventDetails = ({ customFields }) => { const { inheritGlobalContent, contentConfig } = customFields; const { globalContent } = useFusionContext();
// If inheritGlobalContent is true, use the page's global content // (set at the template level). Otherwise fetch via block-level contentConfig. const customContent = useContent( contentConfig ? { source: contentConfig.contentService, query: contentConfig.contentConfigValues, } : {}, );
const showGlobalContent = inheritGlobalContent ?? !contentConfig; const content = showGlobalContent ? globalContent : customContent;
// The item source returns data.schema_content directly. if (!content?.data?.schema_content) return null;
const { title, dt_start, dt_end, location, description, organizer, url } = content.data.schema_content;
return ( <article className="event-details"> {title && <h1>{title}</h1>} {dt_start && <p>Start: {new Date(dt_start).toLocaleString()}</p>} {dt_end && <p>End: {new Date(dt_end).toLocaleString()}</p>} {location && <p>Location: {location}</p>} {organizer && <p>Organizer: {organizer}</p>} {description && <p>{description}</p>} {url && <a href={url}>Event Link</a>} </article> );};
EventDetails.propTypes = { customFields: PropTypes.shape({ inheritGlobalContent: PropTypes.bool.tag({ name: "Inherit from global content", defaultValue: false, description: "Use the page's global content instead of configuring a content source on this block.", group: "Configure Content", }), contentConfig: PropTypes.contentConfig().tag({ group: "Configure Content", label: "Event Content Source", }), }),};
EventDetails.label = "Event Details – My Block";
export default EventDetails;globalContent vs. useContent
Use globalContent when the page-level content source is already set to schema-item. The block just inherits whatever record the page already fetched, so no extra network call. Use useContent via contentConfig when the block needs its own record independent of the page, or when you want editors to be able to configure the record per block instead of per page.
Step 4: Registering the pieces
Your feature blocks need no separate registration step. Fusion picks up every components/features/*/default.jsx file automatically when you run npx fusion start.
The content source blocks are different. They’re external dependencies, not files you wrote, so they need the package.json and blocks.json entries from Step 1. If a content source doesn’t show up in PageBuilder’s picker after adding it, confirm both entries are in place and restart npx fusion start. The engine caches source definitions on startup.
Step 5: Configure the block in PageBuilder
- Start the local Fusion engine:
npx fusion start- Navigate to
http://localhost/pagebuilder/pages. - Open or create a page template.
- Drag your block (e.g., “Event List”) from the block picker into the layout.
- In the block’s settings panel, find Configure Content → Content Source. Select
schema-feedfrom the dropdown. - Fill in the content source parameters: Schema Name (the exact name from Data Manager, e.g.,
events), Size (records to fetch), Sort By (field to sort by, always descending), and Query (an ArcSL filter expression, not a keyword search; leave blank if you don’t need one). - Save and preview the page.
For a detail block using schema-item, set Document ID to an existing record’s ID from Data Manager instead.
End-to-end pipeline
It’s easy to lose track of where data actually comes from once you’re three files deep in a block. Here’s the whole path, schema to published page:
Data Manager / Draft API │ ▼Schema definition + Records stored in Arc XP │ ▼Content Source (@wpmedia/schema-feed-content-source-block / schema-item-content-source-block) └─ Calls /content/v5/search/schemas/ └─ Returns records with schema_content fields │ ▼Feature Block (event-list / event-details, you build these) └─ useContent() fetches from content source └─ Reads item.schema_content field values └─ Renders HTML │ ▼PageBuilder Template └─ Editor picks content source and sets params └─ Block renders on published pageTroubleshooting
Sort or query filter not working
Only indexed fields can be used in sort and query parameters. Check your schema’s field definitions in Data Manager to confirm the field has search_settings configured. See the Developer Guide for API search vs. editorial search.
Custom block renders nothing
Nine times out of ten this is a schema name mismatch, so check schemaName against Data Manager first, case and all. After that, verify records actually exist for that schema and site, and confirm CONTENT_BASE and ARC_ACCESS_TOKEN are set in .env. If those all check out, open dev tools on the preview page and look at the network tab for the content source fetch. A 401 means your token expired or was never valid. A 404 means the schema name or record ID doesn’t exist where you think it does.
Content source doesn’t appear in PageBuilder’s picker
Confirm both blocks are listed in package.json dependencies and blocks.json, then run npm install and restart npx fusion start. The engine caches source definitions on startup, so a dependency you just added won’t show up until you restart.
content.data is undefined in the block
Usually means the fetch function returned an empty string, which happens when a required param like schemaName never made it through. Check the params are actually filled in on the PageBuilder settings panel. Also double check you’re reading the right shape: the feed endpoint hands back an array, so content.data is an array, while the by-ID endpoint hands back an object, so it’s content.data.schema_content you want, not content.data directly.