Block-Powered Configuration Without Full Site Editing

WordPress 5.9 brought Full Site Editing (FSE), letting users assemble page layouts from blocks. But FSE stops short of helping configure the invisible settings plugins and themes store in the wp_options table. While theme.json covers visual choices like fonts and colors, there is no block-based path for the non-visual configuration that powers much of a site's behavior.

The gap matters because block-based settings could give users purpose-built inputs — calendars for dates, interactive maps for coordinates, sliders for ranges — instead of generic form fields. A block protocol integration might eventually make this straightforward, but that remains speculative. A more practical route exists today: repurpose the WordPress editor to produce configuration rather than content.

Since the editor already supports custom post types (CPTs), a plugin can define a CPT whose entries hold the configuration data. The plugin reads that data back from the post content when needed. This approach requires limiting which blocks appear when editing that CPT, locking them into a predefined template, and ensuring the CPT's content never surfaces on the public site.

Two CPTs, Two Roles

Consider a plugin that installs a GraphQL server with persisted queries. The implementation, available in the leoloso/PoP repository, defines a public CPT called persisted-query. Each entry stores a query in the editor, and requesting its permalink returns the query's results.

Persisted queries often need shared rules — access control lists, HTTP caching policies — that apply across many queries. Embedding those rules in each persisted query would force updates everywhere whenever a rule changed. Instead, a second, private CPT called schema-config holds the rule sets. Dedicated blocks in its editor let users pick which Access Control Lists and Cache-Control Lists apply.

Linking a persisted query to its schema configuration needs user input. Assuming Advanced Custom Fields would exclude many potential users, a custom block in the persisted-query editor displays all available schema configurations for selection. The persisted-query CPT must be public because users load it to fetch responses; the schema-config CPT stays private, reachable only by the plugin.

Registering the CPTs

Creating either CPT uses register_post_type. The public flag is true for the persisted query, and false for the schema configuration. In both cases show_in_rest stays true, since the REST API feeds the block editor:

function create_persisted_query_cpt(): void
{
  $labels = [
    'name'               => 'Persisted queries',
    'singular_name'      => 'Persisted query',
    'menu_name'          => 'Persisted queries',
    'name_admin_bar'     => 'Persisted query',
    'add_new'            => 'Add New',
    'add_new_item'       => 'Add New Persisted query',
    'new_item'           => 'New Persisted query',
    'edit_item'          => 'Edit Persisted query',
    'view_item'          => 'View Persisted query',
    'all_items'          => 'All Persisted queries',
    'search_items'       => 'Search Persisted queries',
    'parent_item_colon'  => 'Parent Persisted query',
    'not_found'          => 'No Persisted queries found',
    'not_found_in_trash' => 'No Persisted queries found in Trash'
  ];
  $args = [
    'labels'              => $labels,
    'public'              => true,
    'show_in_rest'        => true,
    'rewrite'             => ['slug' => 'persisted-query'],
  ];

  register_post_type('persisted-query', $args);
}
add_action('init', 'create_persisted_query_cpt');
function create_schema_config_cpt(): void
{
  $labels = [
    'name'               => 'Schema configurations',
    'singular_name'      => 'Schema configuration',
    // All the rest...
  ];
  $args = [
    'public'              => false,
    'show_in_rest'        => true,
    // ...
  ];
}
add_action('init', 'create_schema_config_cpt');

Other arguments — exclude_from_search, publicly_queryable, show_ui, show_in_nav_menus, show_in_menu, show_in_admin_bar — offer finer control over visibility and navigation placement.

The show_in_rest and template arguments postdate older CPT guides, but otherwise the API has proven stable. New CPTs automatically use the block editor rather than requiring the Classic editor.

Building the Linking Block

The custom block of type graphql-api/schema-configuration serves as the bridge. The @wordpress/create-block scaffolding tool generates a starter plugin with one block; its PHP and JS files can be copied into an existing plugin and the rest discarded.

The block's behavior has four requirements. First, it stores the selected schema-config entry's ID in an integer attribute called schemaConfiguration. Second, it must appear only on the persisted-query CPT. Setting the inserter attribute to false removes it from the editor's block picker; only a locked template can add it.

Third, the block should render nothing visible. Though blocks persist their data inside HTML comments in the editor, those comments can leak into printed pages. Even if a configuration ID seems innocuous, the same comment mechanism could someday carry API keys. A safe save method that emits no sensitive data protects against accidental exposure.

Fourth, the block fetches the list of schema configurations and lets the user pick one. A data store retrieves entries by executing a GraphQL query against the server. For the UI, the implementation wraps the Select component from react-select in a custom SelectCard component, then wraps that in a top-level SchemaConfigurationSelectCard. This component renders a select input with all schema configuration entries and persists the selection through onChange. The block's edit method embeds this component hierarchy.

import { withSelect } from '@wordpress/data';
import { compose, withState } from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import { SelectCard } from '@graphqlapi/components';

const SchemaConfigurationSelectCard = ( props ) => {
  const {
    schemaConfigurations,
    attributes: {
      schemaConfiguration
    }
  } = props;
  
  const schemaConfigurationOptions = schemaConfigurations.map( schemaConfiguration => (
    {
      label: schemaConfiguration.title,
      value: schemaConfiguration.id,
    }
  ) );
  const metaOptions = [
    {
      label: `🟡 ${ __('Default', 'graphql-api') }`,
      value: 0,
    },
    {
      label: `❌ ${ __('None', 'graphql-api') }`,
      value: -1,
    },
  ];
  const groupedOptions = [
    {
    label: '',
    options: metaOptions,
    },
    {
    label: '',
    options: schemaConfigurationOptions,
    },
  ];
  const selectedOptions = schemaConfigurationOptions.filter( option => option.value == schemaConfiguration );
  const defaultValue = selectedOptions[0];

  return (
    <SelectCard
      { ...props }
      isMulti={ false }
      options={ groupedOptions }
      defaultValue={ defaultValue }
      onChange={ selected => setAttributes( {
        ['schemaConfiguration']: selected.value
      } ) }
    />
  );
}

export default compose( [
  withState( {
    label: __('Schema configuration', 'graphql-api'),
  } ),
  withSelect( ( select ) => {
    const {
      getSchemaConfigurations,
    } = select ( 'graphql-api/schema-configuration' );
    return {
      schemaConfigurations: getSchemaConfigurations(),
    };
  } ),
] )( SchemaConfigurationSelectCard );

Locking the Template

Since a schema configuration is mandatory and singular, letting users insert the block freely would invite omission or duplication. The template is locked when registering the post type by setting template and template_lock properties on the CPT object. For a persisted query, the template holds two blocks in order: the GraphiQL client and the schema configuration block.

function register_persisted_query_template(): void
{
  $post_type_object = get_post_type_object( 'persisted-query' );
  $post_type_object->template = [
    ['graphql-api/graphiql'],
    ['graphql-api/schema-configuration'],
  ];
  $post_type_object->template_lock = 'all';
}
add_action( 'init', 'register_persisted_query_template' );

With the template locked, users editing a persisted query see exactly those blocks, pre-positioned and ready to fill.

Reading Configuration Data From The Persisted Query

Once a user has selected a schema configuration for a persisted query, both pieces of data live in the database as custom post entries. The remaining task is to pull that configuration back out when the persisted query needs to be rendered.

In the template that renders the persisted query (for example persisted-query.php), the schema configuration ID is stored in the query's block data. The flow starts by parsing the content with parse_blocks to get at the individual blocks:

$persistedQueryObject = \get_post($persistedQueryID);
$persistedQueryBlocks = \parse_blocks($persistedQueryObject->post_content);

Next, filter the parsed blocks to isolate the one named schema-configuration:

$schemaConfigurationBlock = null;
foreach ($persistedQueryBlocks as $block) {
  if ($block['blockName'] === 'graphql-api/schema-configuration') {
    // We found the block
    $schemaConfigurationBlock = $block;
    break;
  }
}

The block's configuration is accessible under attrs, using the attribute name that was registered with the block — in this case "schemaConfiguration":

$schemaConfigurationID = $schemaConfigurationBlock['attrs']['schemaConfiguration'];

That returns the selected schema configuration ID. The same pattern repeats for the private custom post entry: read the block data from that CPT. The schema configuration stores its access control selections under the schema-config-access-control-lists block and cache control selections under schema-config-cache-control-lists:

$schemaConfigurationObject = \get_post($schemaConfigurationID);
$schemaConfigurationBlocks = \parse_blocks($schemaConfigurationObject->post_content);
$accessControlBlock = $cacheControlBlock = null;
foreach ($schemaConfigurationBlocks as $block) {
  if ($block['blockName'] === 'graphql-api/schema-config-access-control-lists') {
    $accessControlBlock = $block;
  } elseif ($block['blockName'] === 'graphql-api/schema-config-cache-control-lists') {
    $cacheControlBlock = $block;
  }
}

// Retrieve the stored configuration from the private CPT
$accessControlLists = $accessControlBlock['attrs']['accessControlLists'];;
$cacheControlLists = $cacheControlBlock['attrs']['cacheControlLists'];

With the private configuration data in hand, the plugin can decide how to render the persisted query accordingly. (The specific rendering logic is outside the scope of this article.)

// Do something with the configuration data
// ... 

/**
                                               
    ffffffffffffffff    iiii                   
   f::::::::::::::::f  i::::i                  
  f::::::::::::::::::f  iiii                   
  f::::::fffffff:::::f                         
  f:::::f       ffffffiiiiiiinnnn  nnnnnnnn    
  f:::::f             i:::::in:::nn::::::::nn  
 f:::::::ffffff        i::::in::::::::::::::nn 
 f::::::::::::f        i::::inn:::::::::::::::n
 f::::::::::::f        i::::i  n:::::nnnn:::::n
 f:::::::ffffff        i::::i  n::::n    n::::n
  f:::::f              i::::i  n::::n    n::::n
  f:::::f              i::::i  n::::n    n::::n
 f:::::::f            i::::::i n::::n    n::::n
 f:::::::f            i::::::i n::::n    n::::n
 f:::::::f            i::::::i n::::n    n::::n
 fffffffff            iiiiiiii nnnnnn    nnnnnn

*/

Why This Approach Works

Full Site Editing — even in its recent releases — still cannot produce traditional settings pages. For plugin configuration, a custom solution is required. The approach described here uses a private custom post type as the storage layer for configuration and the WordPress editor as the data-entry interface.

Users benefit from the editor's WYSIWYG behavior and from blocks that offer controls matched to the content type: calendars, sliders, maps, and similar inputs. The public custom post type ties a specific configuration to the entity that uses it, keeping the two cleanly separated while still linked through block attributes.