Replace the old PHP form with a REST-backed settings page using register_setting(), @wordpress/components, apiFetch, and native admin notices.

Most WordPress settings page tutorials still stop at settings_fields(), do_settings_sections(), and a PHP form that reloads the page when you click Save. Modern plugins don’t work that way. The better pattern is to register settings for REST, mount a React app on your admin page, and build the UI with native WordPress components; if you want the boilerplate fast, the functions.php builder is a useful starting point for the menu and registration pieces.
If you’ve built plugin settings pages the classic way, you already know the friction points:
Imagine a plugin with five settings: a feature toggle, an API endpoint, a sync mode, a debug switch, and a few dependent fields that only show when the feature is enabled. The old pattern starts simple, but it gets awkward fast. You end up rendering HTML in PHP, juggling option defaults manually, and bolting JavaScript onto markup that was never designed to behave like an app.
That mismatch is the real issue. WordPress core already ships a component library and a REST layer that solve most of this. If your settings live in /wp/v2/settings, your admin page can behave like a modern interface: fetch state on load, update local state instantly, save without a refresh, and show native notices instead of custom banners or alert() calls.
The key move is register_setting() with show_in_rest. That’s what puts your option into /wp-json/wp/v2/settings and gives your JavaScript something native to talk to.
Use the exact pattern below, including the schema. This is the part many tutorials skip, and it’s why their examples never graduate beyond server-rendered forms.
add_action( 'init', function() {
register_setting( 'my_plugin', 'my_plugin_options', [
'type' => 'object',
'default' => [
'enable_feature' => false,
'api_endpoint' => '',
],
'sanitize_callback' => 'my_plugin_sanitize_options',
'show_in_rest' => [
'schema' => [
'type' => 'object',
'properties' => [
'enable_feature' => [ 'type' => 'boolean' ],
'api_endpoint' => [ 'type' => 'string' ],
],
],
],
] );
} );You also need a sanitizer that returns a clean object shape every time:
<?php
function my_plugin_sanitize_options( $input ) {
$input = is_array( $input ) ? $input : [];
return [
'enable_feature' => ! empty( $input['enable_feature'] ),
'api_endpoint' => isset( $input['api_endpoint'] )
? esc_url_raw( $input['api_endpoint'] )
: '',
];
}Once the setting exists, your admin page no longer needs a PHP form. It just needs a mount point for React:
add_action( 'admin_menu', function() {
add_menu_page(
__( 'My Plugin', 'my-plugin' ),
__( 'My Plugin', 'my-plugin' ),
'manage_options',
'my-plugin-settings',
function() {
echo '<div id="my-plugin-settings"></div>';
}
);
} );
add_action( 'admin_enqueue_scripts', function( $hook ) {
if ( $hook !== 'toplevel_page_my-plugin-settings' ) return;
wp_enqueue_script(
'my-plugin-settings',
plugins_url( 'build/index.js', __FILE__ ),
[ 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-notices' ],
filemtime( plugin_dir_path( __FILE__ ) . 'build/index.js' ),
true
);
wp_localize_script( 'my-plugin-settings', 'myPluginSettings', [
'nonce' => wp_create_nonce( 'wp_rest' ),
] );
} );That callback printing <div id="my-plugin-settings"></div> is the whole page shell. No settings_fields(). No rendered table rows. No submit button living in PHP. If you want to scaffold this part quickly, the functions.php builder is handy for generating the menu and hook boilerplate before you adapt it for a plugin file.
@wordpress/componentsThis is where the admin page stops looking like 2014. WordPress already ships the same component package used in the block editor, so you can build the UI with Panel, PanelBody, PanelRow, TextControl, ToggleControl, and Button without adding a third-party UI layer.
Here’s the complete React component using @wordpress/api-fetch to read and write settings, plus useDispatch( noticesStore ) for a native success notice after saving:
import { useState, useEffect } from '@wordpress/element';
import { Panel, PanelBody, PanelRow, TextControl, ToggleControl, Button } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
import { useDispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
apiFetch.use( apiFetch.createNonceMiddleware( window.myPluginSettings.nonce ) );
export default function SettingsPage() {
const [ settings, setSettings ] = useState( {} );
const [ isSaving, setIsSaving ] = useState( false );
const { createSuccessNotice } = useDispatch( noticesStore );
useEffect( () => {
apiFetch( { path: '/wp/v2/settings' } ).then( ( data ) => {
setSettings( data.my_plugin_options ?? {} );
} );
}, [] );
const saveSettings = async () => {
setIsSaving( true );
await apiFetch( {
path: '/wp/v2/settings',
method: 'POST',
data: { my_plugin_options: settings },
} );
setIsSaving( false );
createSuccessNotice( 'Settings saved.', { type: 'snackbar' } );
};
return (
<Panel header="My Plugin Settings">
<PanelBody title="General" initialOpen={ true }>
<PanelRow>
<ToggleControl
label="Enable feature"
checked={ settings.enable_feature ?? false }
onChange={ ( val ) =>
setSettings( { ...settings, enable_feature: val } )
}
/>
</PanelRow>
<PanelRow>
<TextControl
label="API endpoint"
value={ settings.api_endpoint ?? '' }
onChange={ ( val ) =>
setSettings( { ...settings, api_endpoint: val } )
}
/>
</PanelRow>
</PanelBody>
<PanelBody>
<Button
variant="primary"
isBusy={ isSaving }
onClick={ saveSettings }
>
Save Settings
</Button>
</PanelBody>
</Panel>
);
}A few practical notes matter here:
apiFetch( { path: '/wp/v2/settings' } ) returns the entire settings payload, so you pull out your option object from data.my_plugin_options.createSuccessNotice() gives you a native WordPress admin message instead of rolling your own notification system.To mount that component, keep the entry point minimal:
import { createRoot } from '@wordpress/element';
import SettingsPage from './SettingsPage';
const container = document.getElementById( 'my-plugin-settings' );
if ( container ) {
const root = createRoot( container );
root.render( <SettingsPage /> );
}This is also where @wordpress/scripts earns its keep. You don’t need to hand-roll webpack config for this pattern; a standard build step is enough to compile the bundle into build/index.js.
The dependency array is not busywork. It’s how WordPress knows to load the right versions of its own packages before your code runs.
Use this exact enqueue pattern on the correct admin page only:
add_action( 'admin_enqueue_scripts', function( $hook ) {
if ( $hook !== 'toplevel_page_my-plugin-settings' ) return;
wp_enqueue_script(
'my-plugin-settings',
plugins_url( 'build/index.js', __FILE__ ),
[ 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-notices' ],
filemtime( plugin_dir_path( __FILE__ ) . 'build/index.js' ),
true
);
wp_localize_script( 'my-plugin-settings', 'myPluginSettings', [
'nonce' => wp_create_nonce( 'wp_rest' ),
] );
} );Why those dependencies matter:
wp-element gives you the React-compatible element package WordPress shipswp-components loads the admin UI component librarywp-api-fetch provides the authenticated REST clientwp-notices supports native notice behavior through the notices storeIf you skip the dependency list and bundle against your own assumptions, you create avoidable problems: duplicate React runtimes, missing globals, components failing silently, or admin pages that break after a core update.
The page check is equally important:
if ( $hook !== 'toplevel_page_my-plugin-settings' ) return;Without it, your script loads across all of wp-admin. That wastes resources and increases the odds of collisions with other screens. Keep the bundle scoped to your plugin page.
If you also want editor-like styles for components, you can enqueue the styles package as needed, but the core pattern stays the same: mount one app container, load one bundle, let WordPress provide the underlying libraries.
This is the production gotcha that burns the most time: GET requests may look fine, but saving fails with 403 Forbidden. The reason is simple — authenticated REST writes need a nonce.
Pass the nonce from PHP:
wp_localize_script( 'my-plugin-settings', 'myPluginSettings', [
'nonce' => wp_create_nonce( 'wp_rest' ),
] );Then attach it in JavaScript before any apiFetch() call happens:
// In your JS entry point — do this BEFORE any apiFetch calls
apiFetch.use(
apiFetch.createNonceMiddleware( window.myPluginSettings.nonce )
);
// Without this, POST requests to /wp/v2/settings return 403 ForbiddenThat middleware adds the correct X-WP-Nonce header automatically. It means you don’t have to remember headers on every request, and your save logic stays clean.
A practical structure looks like this:
import apiFetch from '@wordpress/api-fetch';
import { createRoot } from '@wordpress/element';
import SettingsPage from './SettingsPage';
apiFetch.use(
apiFetch.createNonceMiddleware( window.myPluginSettings.nonce )
);
const container = document.getElementById( 'my-plugin-settings' );
if ( container ) {
const root = createRoot( container );
root.render( <SettingsPage /> );
}If you forget this step, the symptom is usually misleading. Your UI loads, your fields render, your GET call to /wp/v2/settings works, and only the save action fails. Developers often debug the schema, the capability, or the sanitize callback first, when the missing nonce is the actual issue.
One more thing: attach the middleware once, at the entry point. Don’t scatter it across components. You want a single setup path so every authenticated request behaves consistently.
The old approach gives you a quick path for tiny settings screens, but it locks you into PHP-rendered markup, full page reloads, and a UX that increasingly feels disconnected from the rest of modern WordPress. The REST-backed React pattern fixes that. Your settings page becomes an interface instead of a form: state loads asynchronously, saves happen without a refresh, notices feel native, and your controls come from the same component library the block editor uses.
The trade-off is a build step. You need a JavaScript bundle, and in practice that means using @wordpress/scripts or an equivalent setup to compile your code into build/index.js. That’s extra tooling compared with dropping a few callbacks into a plugin file. It also means your team needs to be comfortable maintaining both PHP and JavaScript in the same plugin.
Even so, the trade is usually worth it once you have more than three fields, any conditional UI, or any setting that benefits from immediate feedback. A plugin with two checkboxes might survive with a legacy form. A plugin that aims to feel current in wp-admin should not. The modern pattern is not about chasing a trend; it’s about using the platform WordPress already provides instead of rebuilding a mini admin system in raw PHP.
Start with the boring part: register_setting(), add_menu_page(), the mount point, and the enqueue function. Once that scaffolding is in place, the rest is just a small React app talking to /wp/v2/settings. If you want a fast starting point for the boilerplate before wiring in your REST-backed UI, the functions.php builder is the right place to begin.