Contents
Overview
This tutorial shows how to build a single-page application (SPA) for plugin or theme settings inside the WordPress admin using React (JavaScript). It covers the complete flow: plugin scaffolding, registering an admin page, enqueuing a bundled React app, secure REST API endpoints to read/update settings, development/build tooling, state management, validation, security (capabilities, nonces, sanitization), internationalization, accessibility, testing, and deployment considerations.
What you will build
- A WordPress plugin that adds a top-level or submenu admin page.
- A bundled React SPA (production build) served in the admin area.
- Secure REST API endpoints to get and update persistent options.
- Client-side features: form editing, validation, optimistic save, error handling, unsaved-changes warning, and localization support.
Prerequisites
- WordPress development environment (local) with WP 5.0 (REST API present). Using WP-CLI is helpful.
- Node.js (14 recommended) and npm or yarn.
- Familiarity with React, REST endpoints, and WordPress plugin basics (hooks, capabilities).
Recommended project structure
Keep the plugin in its own folder under wp-content/plugins/my-settings-spa. A typical layout:
my-settings-spa/ readme.txt my-settings-spa.php src/ admin/ index.js App.jsx components/ FieldText.jsx FieldToggle.jsx styles/ admin.css build/ js/ app.js css/ admin.css package.json webpack.config.js
Step 1 — Plugin bootstrap (PHP)
Create the plugin main file that registers the admin page, enqueues the built assets, localizes data (nonce, REST root, settings), and registers REST endpoints and option handling.
Minimal plugin header and hooks
lt?php / Plugin Name: My Settings SPA Description: Example settings SPA in WP Admin using React. Version: 1.0.0 Author: Your Name Text Domain: my-settings-spa / defined( ABSPATH ) exit class My_Settings_SPA { public function __construct() { add_action( admin_menu, array( this, register_admin_page ) ) add_action( admin_enqueue_scripts, array( this, enqueue_assets ) ) add_action( rest_api_init, array( this, register_rest_routes ) ) } public function register_admin_page() { // Capability: manage_options for site-wide settings change as needed. add_menu_page( __( My Settings, my-settings-spa ), __( My Settings, my-settings-spa ), manage_options, my-settings-spa, array( this, render_app_container ), dashicons-admin-generic, 100 ) } public function render_app_container() { // Echo a simple container where the React app mounts. echo ltdiv id=my-settings-spa-appgtlt/divgt } public function enqueue_assets( hook ) { // Only load on our plugin page. if ( hook !== toplevel_page_my-settings-spa ) { return } asset_dir = plugin_dir_url( __FILE__ ) . build/ // Example: register enqueue CSS wp_enqueue_style( my-settings-spa-admin, asset_dir . css/admin.css, array(), 1.0.0 ) // Enqueue the bundled JS app (contains React) wp_enqueue_script( my-settings-spa-app, asset_dir . js/app.js, array(), // dependencies are already bundled if using webpack/Vite 1.0.0, true ) // Localize and pass bootstrap data: REST root, nonce, initial settings, translations. current_settings = get_option( my_settings_spa_options, array() ) wp_localize_script( my-settings-spa-app, MySettingsSpaData, array( restRoot => esc_url_raw( rest_url() ), nonce => wp_create_nonce( wp_rest ), settings => current_settings, strings => array( saveSuccess => __( Settings saved., my-settings-spa ), saveError => __( There was an error saving settings., my-settings-spa ), ), ) ) // Optional: script translations if using @wordpress/i18n in a WP-packed app // wp_set_script_translations( my-settings-spa-app, my-settings-spa, plugin_dir_path( __FILE__ ) . languages ) } public function register_rest_routes() { register_rest_route( my-settings-spa/v1, /settings, array( array( methods => GET, callback => array( this, rest_get_settings ), permission_callback => function() { return current_user_can( manage_options ) }, ), array( methods => POST, callback => array( this, rest_update_settings ), permission_callback => function() { return current_user_can( manage_options ) }, ), ) ) } public function rest_get_settings( request ) { options = get_option( my_settings_spa_options, array() ) return rest_ensure_response( options ) } public function rest_update_settings( request ) { // Check nonce if client sends it WP REST already checks _wpnonce via header X-WP-Nonce when present // Sanitize and validate incoming data params = request->get_json_params() if ( ! is_array( params ) ) { return new WP_Error( invalid_data, __( Invalid payload, my-settings-spa ), array( status => 400 ) ) } sanitized = this->sanitize_settings( params ) // Update option update_option( my_settings_spa_options, sanitized ) return rest_ensure_response( sanitized ) } private function sanitize_settings( input ) { output = array() // Example fields: text_field (string), feature_enabled (bool) if ( isset( input[text_field] ) ) { output[text_field] = sanitize_text_field( input[text_field] ) } else { output[text_field] = } if ( isset( input[feature_enabled] ) ) { output[feature_enabled] = (bool) input[feature_enabled] } else { output[feature_enabled] = false } // Add additional fields and validations here. return output } } new My_Settings_SPA()
Notes about the PHP
- We create a top-level page with add_menu_page and render only a mount container for the SPA.
- We enqueue built assets from build/js and build/css. The JS bundle contains the React code and will mount into the container.
- wp_localize_script is used to pass bootstrap data (nonce, REST root, initial settings). For complex data, consider wp_add_inline_script or wp_enqueue_script wp_localize_script.
- REST routes are registered under a custom namespace my-settings-spa/v1 adjust as needed. All routes check capability manage_options.
- All inputs are sanitized before saving. You should extend sanitize_settings with stricter validation for complex types.
Step 2 — Client: React app basics
The React app will fetch settings over the REST API, allow editing, validate input, and send updates. It should read MySettingsSpaData passed from PHP for initial state and REST root/nonce. Bundle the React app (with webpack, Rollup, or Vite) into build/js/app.js and CSS into build/css/admin.css.
Important client-side patterns
- Use a single source of truth for form state (useState / useReducer / context).
- Protect API calls with X-WP-Nonce header (wp_create_nonce(wp_rest) in PHP) and check permissions server-side.
- Implement validation both client-side and server-side.
- Show inline errors, success messages, and loading states. Debounce autosaves if needed.
- Warn user of unsaved changes via beforeunload.
- Make forms keyboard-accessible and include proper labels for accessibility.
Entrypoint index.js
// src/admin/index.js import React from react import { createRoot } from react-dom/client import App from ./App import ./styles/admin.css // MySettingsSpaData is injected by wp_localize_script const mount = document.getElementById( my-settings-spa-app ) if ( mount ) { const root = createRoot( mount ) root.render( React.createElement( App, { bootstrap: window.MySettingsSpaData {} } ) ) }
Top-level App.jsx
// src/admin/App.jsx import React, { useEffect, useReducer, useCallback } from react import FieldText from ./components/FieldText import FieldToggle from ./components/FieldToggle // Basic reducer for form state, errors, status function reducer( state, action ) { switch ( action.type ) { case init: return { ...state, settings: action.payload, savedSettings: action.payload } case update: return { ...state, settings: { ...state.settings, ...action.payload }, dirty: true } case saving: return { ...state, saving: true, error: null } case saved: return { ...state, saving: false, savedSettings: action.payload, dirty: false } case error: return { ...state, saving: false, error: action.payload } default: return state } } export default function App( { bootstrap } ) { const initial = { settings: bootstrap.settings {}, savedSettings: bootstrap.settings {}, saving: false, error: null, dirty: false, } const [ state, dispatch ] = useReducer( reducer, initial ) // optional: fetch latest settings on mount to avoid stale bootstrap useEffect( () => { async function fetchSettings() { try { const res = await fetch( bootstrap.restRoot my-settings-spa/v1/settings, { credentials: same-origin, headers: { X-WP-Nonce: bootstrap.nonce }, } ) if ( ! res.ok ) throw new Error( Failed to fetch ) const data = await res.json() dispatch( { type: init, payload: data } ) } catch ( err ) { // Keep bootstrap values if fetch fails console.error( err ) } } fetchSettings() }, [ bootstrap.restRoot, bootstrap.nonce ] ) const updateField = useCallback( ( payload ) => { dispatch( { type: update, payload } ) }, [] ) const save = useCallback( async () => { dispatch( { type: saving } ) try { const res = await fetch( bootstrap.restRoot my-settings-spa/v1/settings, { method: POST, credentials: same-origin, headers: { Content-Type: application/json, X-WP-Nonce: bootstrap.nonce, }, body: JSON.stringify( state.settings ), } ) if ( ! res.ok ) { const body = await res.json().catch( () => null ) throw new Error( body body.message ? body.message : Save failed ) } const data = await res.json() dispatch( { type: saved, payload: data } ) } catch ( err ) { dispatch( { type: error, payload: err.message } ) } }, [ state.settings, bootstrap.restRoot, bootstrap.nonce ] ) // Warn about unsaved changes useEffect( () => { const handler = ( e ) => { if ( state.dirty ) { e.preventDefault() e.returnValue = } } window.addEventListener( beforeunload, handler ) return () => window.removeEventListener( beforeunload, handler ) }, [ state.dirty ] ) return ( / Note: bundler will transform JSX / React.createElement( div, { className: my-settings-spa }, React.createElement( h2, null, My Settings ), state.error React.createElement( div, { role: alert, className: error }, state.error ), React.createElement( FieldText, { label: Text Field, value: state.settings.text_field , onChange: ( v ) => updateField( { text_field: v } ), } ), React.createElement( FieldToggle, { label: Enable Feature, checked: !! state.settings.feature_enabled, onChange: ( v ) => updateField( { feature_enabled: v } ), } ), React.createElement( div, { className: actions }, React.createElement( button, { onClick: save, disabled: state.saving }, state.saving ? Saving... : Save Settings ), state.dirty React.createElement( span, { className: dirty-indicator }, Unsaved changes ) ) ) ) }
Simple Field components
// src/admin/components/FieldText.jsx import React from react export default function FieldText( { label, value, onChange } ) { return ( React.createElement( div, { className: field field-text }, React.createElement( label, null, label ), React.createElement( input, { type: text, value: value , onChange: ( e ) => onChange( e.target.value ), aria-label: label } ) ) ) }
// src/admin/components/FieldToggle.jsx import React from react export default function FieldToggle( { label, checked, onChange } ) { return ( React.createElement( div, { className: field field-toggle }, React.createElement( label, null, React.createElement( input, { type: checkbox, checked: !! checked, onChange: ( e ) => onChange( e.target.checked ) } ), , label ) ) ) }
Step 3 — Development toolchain (example with webpack)
Bundle your React app using webpack (or Vite, Rollup). The bundle should produce a single JS file and CSS file for easy enqueueing in WordPress. Below is an example webpack setup for a simple React app using Babel.
package.json
{ name: my-settings-spa, version: 1.0.0, private: true, scripts: { dev: webpack --mode development --watch, build: webpack --mode production }, dependencies: { react: ^18.2.0, react-dom: ^18.2.0 }, devDependencies: { @babel/core: ^7.22.0, @babel/preset-env: ^7.22.0, @babel/preset-react: ^7.22.0, babel-loader: ^9.1.2, css-loader: ^6.8.1, mini-css-extract-plugin: ^2.7.6, style-loader: ^3.3.3, webpack: ^5.85.0, webpack-cli: ^5.1.1 } }
webpack.config.js
const path = require(path) const MiniCssExtractPlugin = require(mini-css-extract-plugin) module.exports = { entry: ./src/admin/index.js, output: { filename: js/app.js, path: path.resolve(__dirname, build), publicPath: /wp-content/plugins/my-settings-spa/build/ }, module: { rules: [ { test: /.jsx?/, exclude: /node_modules/, use: { loader: babel-loader } }, { test: /.css/i, use: [ MiniCssExtractPlugin.loader, css-loader ] } ] }, plugins: [ new MiniCssExtractPlugin({ filename: css/admin.css }) ], resolve: { extensions: [.js, .jsx] } }
.babelrc
{ presets: [@babel/preset-env, @babel/preset-react] }
Step 4 — Security permissions
Security is critical. Follow these practices:
- Capabilities: Check current_user_can(manage_options) or a capability appropriate for your plugin for all REST endpoints and admin entry points.
- Nonces: Provide wp_create_nonce(wp_rest) to the JS app and send as X-WP-Nonce header. WordPress REST API will validate it automatically for logged-in users.
- Sanitize on server: Never trust client input. Sanitize each field using sanitize_text_field, sanitize_email, wp_kses_post, absint, etc.
- Escape when rendering: Escape values and attributes if you render them in PHP templates (esc_html, esc_attr).
- CSRF and credentials: Use credentials:same-origin on fetch and X-WP-Nonce header.
Step 5 — Validation and data model
The SPA can validate client-side for better UX, but always perform server-side validation. Example types and sanity checks:
- text_field: required or max length
- email_field: valid email via is_email()
- url_field: esc_url_raw() and validate with filter_var
- numeric: cast to int or float and clamp ranges
- arrays: ensure array structure, keys and values validated
Server-side example additions
private function sanitize_settings( input ) { output = array() // text_field: max length 191 if ( isset( input[text_field] ) ) { val = sanitize_text_field( input[text_field] ) output[text_field] = mb_substr( val, 0, 191 ) } else { output[text_field] = } // email_field if ( isset( input[email_field] ) is_email( input[email_field] ) ) { output[email_field] = sanitize_email( input[email_field] ) } else { output[email_field] = } // number field: must be integer between 0 and 100 if ( isset( input[max_items] ) ) { num = intval( input[max_items] ) output[max_items] = max( 0, min( 100, num ) ) } else { output[max_items] = 10 } return output }
Step 6 — Internationalization (i18n)
Make strings translatable in PHP using __() and _e(). For JS, if you want to use WordPress JavaScript i18n utilities, you can use wp_set_script_translations and @wordpress/i18n. For simpler cases, pass localized strings via wp_localize_script as shown earlier. Create .pot and translations if targeting multiple languages.
Step 7 — Accessibility (a11y)
- Use semantic HTML: labels, fieldsets, legends where appropriate.
- Provide aria-labels and role attributes for dynamic messages and alerts.
- Ensure color contrast and focus outlines on inputs and buttons.
- Support keyboard navigation and screen readers (announce saves/errors).
Step 8 — UX patterns
Consider these UX improvements:
- Autosave with debounce and a visible Saving… indicator.
- Optimistic UI updates or clear saving/progress states.
- Versioning or Reset to defaults action with confirmation.
- Import/Export JSON settings for migrations.
- Granular permission or multi-site awareness (network options on multisite).
Step 9 — Handling multisite
For multisite networks, use get_site_option / update_site_option for network-wide settings if the plugin is network-activated and you want a single settings store. Ensure permission checks use manage_network_options for network admin screens.
Step 10 — Testing and debugging
- Use browser devtools to inspect fetch calls, headers (X-WP-Nonce), and responses.
- Enable WP_DEBUG and WP_DEBUG_LOG to capture PHP errors.
- Log REST callbacks temporarily to debug sanitization and permissions.
- Write unit tests for PHP using WP_UnitTestCase and integration tests for REST endpoints.
- Write component tests for React (Jest React Testing Library) for state and UI behavior.
Step 11 — Deployment
Before shipping:
- Run a production build (minify, tree-shake). Example: npm run build.
- Verify correct asset paths and cache-busting (versioning query args or file hash naming).
- Provide sane defaults and migration code for option structure changes.
- Strip debug logging and development-only code.
- Test capability checks and multisite behavior if relevant.
Advanced topics and patterns
- Using WordPress packages: If you want to use WordPress React implementation, you can rely on @wordpress/element and other packages, and declare them as external dependencies so WordPress bundled React is reused. That requires enqueueing scripts that depend on wp-element and using wp_enqueue_script dependencies instead of bundling React.
- Multiple admin pages: Share a single bundle or create separate entrypoints. Use conditional bootstrapping based on data passed from PHP.
- Feature flags: Use server-side flags or REST-provided metadata to show/hide UI sections.
- Chunking and performance: Code-split heavy components and lazy-load them on demand to reduce initial load.
- Autosave undo: Persist a history stack in localStorage or server-side revisioning to allow rollbacks.
Full working example summary
Key files you need to implement and customize:
- my-settings-spa.php — plugin bootstrap, admin page registration, enqueue, REST endpoints, sanitization.
- src/admin/index.js — React bootstrap and mount.
- src/admin/App.jsx — top-level SPA behavior, state, save handlers.
- src/admin/components/… — input components.
- webpack.config.js, package.json — build toolchain.
- build/js/app.js and build/css/admin.css — produced assets enqueued by plugin.
Helpful links
- WordPress REST API Handbook
- JavaScript for WordPress Plugins
- Plugin Developer Handbook
- Internationalization
Troubleshooting common issues
- Blank admin page: Check console for JS errors and ensure the mount element id matches the React mount code. Verify assets are enqueued on the correct admin hook and page slug.
- 403 on REST calls: Verify X-WP-Nonce header being set and that the current user has the required capability. Check rest permission_callback functions.
- Stale initial data: Use fetch on mount to ensure server-side settings are authoritative.
- Mixed React versions: If WordPress already includes React and you bundle a different version, avoid conflicts by using WordPress wp-element or configure webpack externals.
Conclusion
Building a settings SPA in WordPress Admin with React gives a modern UX and allows you to build rich interactions. The architecture involves: a small PHP bootstrap to register admin pages and secure REST endpoints, a bundled React frontend that mounts into a container, careful server-side sanitization and permissions, and a robust build pipeline. The examples provided give a solid starting point that you can extend with more fields, better validation, chunking, and WordPress-specific integrations (i18n, capability granularity, multisite support).
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |