14+ years building on WordPress / Replies in under 5 hours
WordPress 8 min read · Updated July 2026

Build a Custom WordPress Plugin with React JS: Step-by-Step Guide

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
TL;DR

Build a custom WordPress plugin with React JS using the official `@wordpress/scripts` toolchain (not the deprecated `create-react-app`). **File structure:** `my-plugin.php` (PHP entry), `package.json` with `@wordpress/scripts` as the only dev dep, `src/index.js` (React entry), `build/` (generated — gitignore). **PHP side:** `add_menu_page()` registers the admin page; `mrp_render_page()` outputs `<div id="my-react-plugin-root"></div>`; `admin_enqueue_scripts` loads `build/index.js` with dependencies from `build/index.asset.php` (auto-generated — no manual dep arrays); `wp_localize_script()` passes nonce and `rest_url()` as `window.myPluginData`. **JS side:** `import { createRoot } from '@wordpress/element'` (WP's React wrapper — avoids double-bundling); `import apiFetch from '@wordpress/api-fetch'`; `apiFetch.use(apiFetch.createNonceMiddleware(window.myPluginData.nonce))` — configures the REST nonce header once; `apiFetch({ path: '/wp/v2/posts?_fields=id,title,link' })` — no axios, no hardcoded URLs. **Custom post type:** `show_in_rest: true` is required to expose it at `/wp-json/wp/v2/your_cpt`. **Build:** `npm start` for watch mode; `npm run build` for production. **Gotchas:** missing `show_in_rest` → 404; wrong `$hook` check → script doesn't load; `wp_localize_script` must be called after `wp_enqueue_script` with the same handle.

Building a custom WordPress plugin with React JS gives you a modern, component-driven admin interface while keeping the plugin’s data layer in PHP. WordPress’s own admin UI is built on React — the Gutenberg editor, the site editor, and most newer wp-admin screens all use it — which means React is already loaded in the admin context. Your plugin can tap into that without adding bundle size.

This guide uses the official WordPress build toolchain (@wordpress/scripts) and the official data-fetching library (@wordpress/api-fetch). The old approach with create-react-app is no longer viable — Meta deprecated it in March 2023 and it’s no longer maintained. If you’re also working on building a WordPress theme with React, the same toolchain applies there.

What you’ll build

A WordPress plugin that adds an admin menu page. The page renders a React app that fetches posts from the WordPress REST API and displays them in a list. It covers:

  • Plugin file structure with @wordpress/scripts
  • PHP side: admin menu page, script enqueuing with auto-generated dependencies, nonce passing via wp_localize_script
  • JS side: @wordpress/element for React, @wordpress/api-fetch for authenticated REST calls, component structure
  • Build setup: npm start (watch) and npm run build (production)

Prerequisites

Plugin file structure

my-react-plugin/
├── my-react-plugin.php     ← main plugin file (PHP)
├── package.json            ← @wordpress/scripts config
├── src/
│   ├── index.js            ← React entry point (auto-detected by wp-scripts)
│   └── components/
│       └── PostList.js     ← example component
└── build/                  ← generated by npm run build (gitignore this)
    ├── index.js
    ├── index.asset.php     ← auto-generated dependency list + version hash
    └── index.css           ← compiled styles (if imported in JS)

The build/index.asset.php file is the key difference from a plain webpack setup. @wordpress/scripts generates it automatically on every build. It contains the list of WordPress script handles your bundle depends on (like wp-element, wp-api-fetch) and a content hash for cache busting. You feed this directly into wp_enqueue_script — no manual version strings or dependency arrays.

Step 1: Set up package.json

In your plugin directory, create package.json:

{
  "name": "my-react-plugin",
  "version": "1.0.0",
  "scripts": {
    "start": "wp-scripts start",
    "build": "wp-scripts build"
  },
  "devDependencies": {
    "@wordpress/scripts": "^30.0.0"
  }
}

Run npm install. That’s the entire build configuration — @wordpress/scripts pre-configures webpack for WordPress development. Entry point defaults to src/index.js; output goes to build/.

The @wordpress/dependency-extraction-webpack-plugin (included in @wordpress/scripts) automatically excludes WordPress packages from the compiled bundle. If your JS imports @wordpress/element, the bundler replaces it with a reference to the global window.wp.element — which WordPress already loads — and adds wp-element to the dependencies array in index.asset.php. This keeps your bundle small and prevents React from being double-loaded.

Step 2: Write the main plugin PHP file

<?php
/**
 * Plugin Name: My React Plugin
 * Plugin URI:  https://example.com
 * Description: A custom WordPress plugin with a React JS admin interface.
 * Version:     1.0.0
 * Requires at least: 6.0
 * Requires PHP: 8.1
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Register an admin menu page that will host the React app.
 */
add_action( 'admin_menu', function () {
    add_menu_page(
        'My React Plugin',   // page title
        'My Plugin',         // menu title
        'manage_options',    // capability
        'my-react-plugin',   // menu slug
        'mrp_render_page',   // callback
        'dashicons-list-view',
        80
    );
} );

/**
 * Render the root div the React app will mount into.
 */
function mrp_render_page() {
    echo '<div class="wrap"><div id="my-react-plugin-root"></div></div>';
}

/**
 * Enqueue the compiled React bundle on our admin page only.
 */
add_action( 'admin_enqueue_scripts', function ( $hook ) {
    // Only load on our plugin's admin page.
    if ( $hook !== 'toplevel_page_my-react-plugin' ) {
        return;
    }

    // The asset file contains the dependency array and a content-hash version.
    $asset_file = plugin_dir_path( __FILE__ ) . 'build/index.asset.php';
    if ( ! file_exists( $asset_file ) ) {
        return; // Build hasn't been run yet.
    }
    $asset = require $asset_file;

    wp_enqueue_script(
        'my-react-plugin',
        plugin_dir_url( __FILE__ ) . 'build/index.js',
        $asset['dependencies'],  // auto-includes wp-element, wp-api-fetch, etc.
        $asset['version'],       // content hash — bust cache on every build
        true                     // load in footer
    );

    // Pass the REST nonce and API root URL to the JS global window.myPluginData.
    wp_localize_script(
        'my-react-plugin',
        'myPluginData',
        array(
            'nonce'  => wp_create_nonce( 'wp_rest' ),
            'apiUrl' => rest_url(),
        )
    );

    // Enqueue compiled CSS if present.
    if ( file_exists( plugin_dir_path( __FILE__ ) . 'build/index.css' ) ) {
        wp_enqueue_style(
            'my-react-plugin',
            plugin_dir_url( __FILE__ ) . 'build/index.css',
            array(),
            $asset['version']
        );
    }
} );

Two things worth noting here: the $hook check limits script loading to your plugin’s admin page only — enqueuing a React bundle on every wp-admin page would slow down the entire admin. And wp_localize_script serialises the nonce into a JS global (window.myPluginData) so the React app can configure authenticated REST API requests without hardcoding credentials.

Step 3: Write the React entry point

Create src/index.js:

import { createRoot } from '@wordpress/element';
import PostList from './components/PostList';

// Find the root div rendered by mrp_render_page().
const rootElement = document.getElementById( 'my-react-plugin-root' );

if ( rootElement ) {
    createRoot( rootElement ).render( <PostList /> );
}

@wordpress/element is a thin wrapper around React that WordPress ships with the block editor. It re-exports React’s API — including createRoot (available from WordPress 6.2+, which ships React 18) and the classic render for older installs. Using it instead of importing React directly means you’re using the same React instance as the block editor, avoiding version conflicts and keeping the bundle smaller.

Step 4: Build the PostList component

Create src/components/PostList.js:

import { useState, useEffect } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';

// Configure @wordpress/api-fetch to send the REST nonce on every request.
// This is set once at module load — the nonce comes from the PHP global.
apiFetch.use(
    apiFetch.createNonceMiddleware( window.myPluginData?.nonce )
);

const PostList = () => {
    const [ posts, setPosts ]     = useState( [] );
    const [ isLoading, setLoading ] = useState( true );
    const [ error, setError ]     = useState( null );

    useEffect( () => {
        apiFetch( { path: '/wp/v2/posts?per_page=10&_fields=id,title,link,date' } )
            .then( ( data ) => {
                setPosts( data );
                setLoading( false );
            } )
            .catch( ( err ) => {
                setError( err.message );
                setLoading( false );
            } );
    }, [] );

    if ( isLoading ) return <p>Loading posts…</p>;
    if ( error )     return <p>Error: { error }</p>;
    if ( ! posts.length ) return <p>No posts found.</p>;

    return (
        <div className="mrp-post-list">
            <h2>Recent Posts</h2>
            <ul>
                { posts.map( ( post ) => (
                    <li key={ post.id }>
                        <a href={ post.link } target="_blank" rel="noreferrer">
                            { post.title.rendered }
                        </a>
                    </li>
                ) ) }
            </ul>
        </div>
    );
};

export default PostList;

Key differences from a generic React/axios setup:

  • @wordpress/api-fetch prefixes relative path strings with rest_url() automatically — no hardcoded site URLs
  • The nonce middleware adds X-WP-Nonce: [nonce] to every request, which WordPress validates server-side to allow access to non-public REST endpoints (like draft posts, user data)
  • _fields=id,title,link,date in the query string limits the response payload to only what the component needs — important for performance on sites with many posts
  • useState and useEffect are imported from @wordpress/element rather than react directly for the same bundling reason as above

Step 5: Register a custom post type (optional extension)

If your plugin needs its own content type rather than fetching standard posts, register it in PHP and expose it via the REST API:

add_action( 'init', function () {
    register_post_type( 'mrp_item', array(
        'label'        => 'Items',
        'public'       => true,
        'show_in_rest' => true,   // required to expose via REST API
        'supports'     => array( 'title', 'editor', 'thumbnail' ),
    ) );
} );

show_in_rest => true is the line that makes the post type available at /wp-json/wp/v2/mrp_item. Without it, the REST API returns a 404 for that endpoint regardless of authentication. The React component then fetches from path: '/wp/v2/mrp_item' instead of /wp/v2/posts.

For managing the custom fields on this post type — especially if you want an ACF-backed field group or a Pods-managed schema — see the ACF vs Meta Box vs Pods comparison for guidance on which tool fits which use case.

Step 6: Build and test

Development watch mode (recompiles on every file save):

npm start

Production build (minified, with content-hash versioning):

npm run build

After running either command, activate the plugin in wp-admin → Plugins. Navigate to My Plugin in the left sidebar. You should see the React-rendered post list. If nothing appears, open the browser console — the most common causes are:

  • “Cannot read properties of undefined (reading ‘nonce’)”window.myPluginData wasn’t set. Check that the admin_enqueue_scripts hook fires on your page (verify the $hook check matches)
  • 404 on the REST path — the post type may not have show_in_rest => true, or pretty permalinks aren’t set (the REST API requires them)
  • 401 Unauthorized — the nonce wasn’t passed or has expired; check wp_localize_script is called after wp_enqueue_script with the same handle

Going further: @wordpress/components

Once the basic setup works, @wordpress/components gives you WP-native UI components — buttons, panels, form inputs, notices — that match the rest of wp-admin’s look and feel without writing custom CSS:

import { Button, Notice } from '@wordpress/components';

// Use exactly like any React component.
<Button variant="primary" onClick={ handleSave }>Save</Button>
<Notice status="success" isDismissible>Saved!</Notice>

The Gutenberg block editor uses the same component library. If you’re building blocks alongside your plugin admin UI, the knowledge transfers directly — see the custom Gutenberg block guide for how block.json, useBlockProps, and @wordpress/scripts work in that context.

Frontend React vs admin React

This guide builds a React app that runs in the wp-admin context. Building React on the public-facing frontend is a different problem: WordPress doesn’t load wp-element for visitors, so you either bundle React yourself (adding ~45 KB gzipped to page load) or render server-side and hydrate. For a frontend-React setup with WordPress as the data source, see the guide to building a WordPress theme with React JS.

For the Node.js alternative — running the plugin’s server-side logic through a Node.js process rather than PHP — creating a WordPress plugin using Node.js covers that architecture.

Frequently asked questions

Use @wordpress/scripts. create-react-app (CRA) was deprecated by Meta in March 2023 and is no longer maintained — it has unresolved security vulnerabilities in its dependencies and produces a build output format that doesn't align with WordPress's script enqueuing system (CRA outputs `main.chunk.js`; @wordpress/scripts outputs `index.js` plus an `index.asset.php` dependency file). @wordpress/scripts is the official WordPress build toolchain, used by Gutenberg and all core WordPress JavaScript. It pre-configures webpack for WordPress development, automatically extracts WordPress package dependencies into the asset file so you don't maintain dependency arrays manually, and integrates with the block editor's component library out of the box.

@wordpress/api-fetch is WordPress's official HTTP library for REST API calls. It has three advantages over axios in a WordPress context: (1) it sends the `X-WP-Nonce` header automatically once configured with `apiFetch.use(apiFetch.createNonceMiddleware(nonce))`, which is required for authenticated REST requests; (2) it resolves relative path strings like `/wp/v2/posts` against the site's REST root URL automatically, so your code doesn't hardcode site URLs; (3) it's already loaded in the WordPress admin context (it's part of the block editor's dependencies), so using it doesn't add to your bundle size. Axios requires manual nonce header setup, hardcoded base URLs or axios instance configuration, and adds ~14 KB to your bundle.

Use wp_localize_script() — call it after wp_enqueue_script() with the same handle. It serialises a PHP array into a JavaScript global object. For example: `wp_localize_script('my-plugin', 'myPluginData', ['nonce' => wp_create_nonce('wp_rest'), 'apiUrl' => rest_url()])` creates `window.myPluginData` in the browser with those values. In your React code, access them as `window.myPluginData.nonce` and `window.myPluginData.apiUrl`. The nonce must be created with `wp_create_nonce('wp_rest')` specifically — that's the nonce action the REST API validates. It expires after 24 hours, so it's safe to embed in a page load but shouldn't be cached long-term.

No, not for an admin-side plugin. WordPress loads React (via @wordpress/element) as part of the block editor dependencies — it's already available as window.wp.element in the admin. The @wordpress/dependency-extraction-webpack-plugin (included in @wordpress/scripts) detects that your code imports from @wordpress/* packages and replaces those imports with references to the corresponding window.wp.* globals at build time. It also writes the wp-element dependency to index.asset.php so WordPress loads it before your script. The result: your bundle contains only your application code, not React itself. For a public frontend React setup (where wp-element isn't loaded for visitors), you would bundle React — but that's a different deployment pattern.

Yes. @wordpress/components is just a React component library — it works in any React context inside the WordPress admin, not only in blocks. Import components like `Button`, `Panel`, `TextControl`, `Notice`, `Modal`, and `SelectControl` the same way you'd import from any other React component library. Because they're part of WordPress's package system, @wordpress/dependency-extraction-webpack-plugin handles them the same way as @wordpress/element — they're excluded from your bundle and loaded from the WordPress global. The components match the rest of wp-admin's visual design, so your plugin's UI blends in with the native admin interface without custom CSS.

Admin-side React (covered in this guide) runs in wp-admin where WordPress already loads React via wp-element. Your bundle can be small because it only contains your application code. Authentication is handled by nonces passed via wp_localize_script. Frontend React (for public pages) is different: wp-element isn't loaded for visitors, so you either bundle React yourself (adds ~45 KB gzipped) or use a headless/decoupled architecture where the frontend is a separate React/Next.js app that consumes WordPress as a REST API data source. Authentication on the frontend uses application passwords or JWT tokens rather than session-based nonces. The @wordpress/scripts toolchain works for both, but the output and deployment strategies differ significantly.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →