How to build a settings SPA in admin with React (JS) in WordPress

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:

  1. my-settings-spa.php — plugin bootstrap, admin page registration, enqueue, REST endpoints, sanitization.
  2. src/admin/index.js — React bootstrap and mount.
  3. src/admin/App.jsx — top-level SPA behavior, state, save handlers.
  4. src/admin/components/… — input components.
  5. webpack.config.js, package.json — build toolchain.
  6. build/js/app.js and build/css/admin.css — produced assets enqueued by plugin.

Helpful links

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 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *