You can build a WordPress theme where the frontend is a React app rather than PHP templates. WordPress handles content management and data via its REST API; React handles rendering. This is sometimes called a “hybrid” or “headless-lite” approach — WordPress runs as normal (admin, plugins, content editing), but the visitor-facing output comes from React components rather than theme PHP files.
This approach makes sense when you need interactive UI behaviour (filtering, live search, infinite scroll, single-page navigation) that would be awkward to build in PHP. For a standard blog or brochure site, a traditional PHP theme or Gutenberg block development is simpler and performs equally well. Note that React blocks within Gutenberg (building custom blocks for the editor) is a different pattern — this guide covers the headless-lite theme approach, not block development. If you want the classic PHP-first path, the WordPress theme from scratch guide covers that in full.
Prerequisites
- Node.js v18+ (LTS): required for Vite and React tooling
- WordPress development environment: Local by Flywheel, Lando, or Docker
- Basic React familiarity: components, hooks (
useState,useEffect), JSX
Project setup with Vite
create-react-app was retired by the React team in early 2023 and is no longer maintained. Use Vite — it’s faster, produces smaller bundles, and is the current official recommendation for new React projects.
Create a Vite + React project inside your WordPress theme directory:
# From wp-content/themes/
npm create vite@latest my-react-theme -- --template react
cd my-react-theme
npm install
Your theme directory should now look like this:
wp-content/themes/my-react-theme/
├── src/ ← React source files
│ ├── App.jsx
│ ├── components/
│ └── main.jsx
├── public/
├── index.html
├── vite.config.js
├── package.json
├── style.css ← WordPress theme header (required)
├── index.php ← WordPress template entry point
└── functions.php ← Enqueue scripts
Configure Vite to build into the theme directory
By default, Vite builds to a dist/ folder. Configure it to output assets directly into a subfolder of your theme, and disable filename hashing so functions.php can reference the output files with static names.
Edit vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'assets',
emptyOutDir: true,
rollupOptions: {
input: 'src/main.jsx',
output: {
entryFileNames: 'js/main.js',
chunkFileNames: 'js/[name].js',
assetFileNames: ({ name }) =>
name?.endsWith('.css') ? 'css/main.css' : 'assets/[name][extname]'
}
}
},
server: {
cors: true
}
})
After running npm run build, Vite outputs to my-react-theme/assets/js/main.js and my-react-theme/assets/css/main.css — fixed paths your functions.php can reference reliably.
WordPress theme scaffold
WordPress requires two files to recognise a directory as a theme: style.css (with the theme header comment) and index.php (the default template).
style.css — theme header only:
/*
Theme Name: My React Theme
Theme URI: https://yourwebsite.com
Author: Your Name
Description: WordPress theme with React frontend
Version: 1.0.0
License: GPL v2 or later
Text Domain: my-react-theme
*/
index.php — outputs the HTML shell React mounts into:
<?php get_header(); ?>
<main id="root"><!-- React mounts here --></main>
<?php get_footer(); ?>
header.php:
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
footer.php:
<?php wp_footer(); ?>
</body>
</html>
functions.php — enqueue the React build and pass WordPress data to it:
<?php
function my_react_theme_enqueue() {
$theme_uri = get_template_directory_uri();
$version = wp_get_theme()->get('Version');
wp_enqueue_script(
'react-app',
$theme_uri . '/assets/js/main.js',
array(),
$version,
true // load in footer
);
// Pass WordPress data to React via window.wpData
wp_localize_script('react-app', 'wpData', array(
'apiUrl' => rest_url('wp/v2/'),
'nonce' => wp_create_nonce('wp_rest'),
'siteUrl' => get_site_url(),
'themeUrl' => $theme_uri,
));
wp_enqueue_style(
'react-style',
$theme_uri . '/assets/css/main.css',
array(),
$version
);
}
add_action('wp_enqueue_scripts', 'my_react_theme_enqueue');
wp_localize_script makes window.wpData available in your React app. Using the REST API URL from rest_url() means your React code works correctly regardless of the WordPress installation path — no hardcoded domain names.
Fetch WordPress content from React
Update src/main.jsx to mount React to the #root element:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
Create src/components/BlogPosts.jsx:
import { useEffect, useState } from 'react'
function BlogPosts() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
// Use the API URL passed by WordPress — no hardcoded domain
const apiUrl = window.wpData?.apiUrl || '/wp-json/wp/v2/'
fetch(`${apiUrl}posts?_fields=id,title,excerpt,link&per_page=10`)
.then(res => {
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
})
.then(data => {
setPosts(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
if (loading) return <p>Loading posts...</p>
if (error) return <p>Error: {error}</p>
return (
<div className="blog-posts">
{posts.map(post => (
<article key={post.id} className="post">
<h2 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
<div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
<a href={post.link}>Read more</a>
</article>
))}
</div>
)
}
export default BlogPosts
Two notes on dangerouslySetInnerHTML: WordPress already sanitizes content server-side before it reaches the REST API, so using it here is safe for your own site’s data. If you’re ever rendering content from an external or user-controlled source, sanitize it client-side with DOMPurify first. The _fields query parameter limits the API response to only the fields you need, which speeds up the fetch.
Use BlogPosts in src/App.jsx:
import BlogPosts from './components/BlogPosts'
function App() {
return (
<div className="app">
<BlogPosts />
</div>
)
}
export default App
Build and activate the theme
Run the production build:
npm run build
This outputs assets/js/main.js and assets/css/main.css into your theme directory. Activate the theme in WordPress (Appearance → Themes) and the React app loads and fetches your posts on every page visit.
For local development, run npm run dev to start Vite’s dev server with hot module replacement. You’ll access it directly at http://localhost:5173 — it’s disconnected from WordPress in dev mode, so mock your API calls or point apiUrl at your local WordPress installation. A production build is always required to test the full WordPress integration.
Deployment and CI/CD
The React build step needs to run before you deploy. The cleanest approach: add the build to your CI/CD pipeline so the compiled assets are always up to date on the server.
In a GitHub Actions workflow, add a build step before the SSH deploy:
- name: Install dependencies
run: npm install
working-directory: wp-content/themes/my-react-theme
- name: Build React app
run: npm run build
working-directory: wp-content/themes/my-react-theme
If the React source is separate from the WordPress repo (a common structure), the pipeline fetches both, runs the build, then deploys the compiled assets only. For the full CI/CD setup for WordPress themes, see the WordPress CI/CD pipeline guide.
Performance considerations
- Code splitting: Vite handles code splitting automatically for dynamic imports. Use
React.lazy()andSuspensefor route-level splitting if your app grows beyond a single page. - API response caching: Cache REST API responses using the WordPress Transients API on the server side (a cached PHP endpoint reduces client-side fetch latency), or use React Query for client-side cache management.
- Image optimisation: WordPress generates
srcsetattributes and WebP versions automatically. When displaying post featured images, use the_embedparameter in your API call to include media objects:posts?_embed&_fields=id,title,_embedded. - SEO limitation: Client-side React rendering means search engines receive an empty
<div id="root">on the initial HTML load. For a content-focused site where SEO matters, consider Next.js with the WordPress REST API as the data source — it provides server-side rendering (SSR) and static generation with the same component model. For an interactive app (dashboard, member area, single-page app) where SEO isn’t a primary concern, client-side rendering is fine.
If you want to use React within WordPress without the headless tradeoff — keeping full server-rendered HTML and SEO — build React blocks for the Gutenberg editor instead. The WordPress plugin with React JS guide covers that pattern. For managing PHP dependencies on the WordPress side of this architecture (Composer for theme dependencies), see the Composer for WordPress guide.
If you need a Node.js process alongside WordPress rather than React in the theme (for WebSockets, real-time data, or an Express backend), that’s a different architecture covered in the WordPress Node.js plugin guide. For the full developer tooling setup that complements this workflow, see 5 tools to streamline WordPress development.


