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/elementfor React,@wordpress/api-fetchfor authenticated REST calls, component structure - Build setup:
npm start(watch) andnpm run build(production)
Prerequisites
- A local WordPress development environment (Local,
wp-env, or Docker — see the local WordPress development environments guide) - Node.js 18.12+ and npm 6.14+
- Basic familiarity with React functional components and hooks
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-fetchprefixes relativepathstrings withrest_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,datein the query string limits the response payload to only what the component needs — important for performance on sites with many postsuseStateanduseEffectare imported from@wordpress/elementrather thanreactdirectly 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.myPluginDatawasn’t set. Check that theadmin_enqueue_scriptshook fires on your page (verify the$hookcheck 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_scriptis called afterwp_enqueue_scriptwith 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.


