
Headless WordPress unlocks a variety of powerful use cases that are often difficult or less efficient to build with a traditional setup. For example, developers and designers may feel constrained by the structure of WordPress themes. A headless approach removes these limitations entirely. Headless architecture also excels in areas where speed is paramount, such as high-traffic sites, news portals, and eCommerce stores. Multi-channel content delivery is one of the most powerful use cases for headless sites. All content is managed in the familiar WordPress admin panel, and then exposed through GraphQL or REST API to any platform that can make an HTTP request.
In this article, we’ll create a headless WordPress site from the ground up. However, we’re not really going to build it completely from scratch. In the same way that you can speed up plugin creation by using plugin boilerplate, you can build headless sites quicker by using a blueprint.
What is Headless WordPress?
Traditional WordPress is a “monolithic” CMS. In other words, the backend where you create posts and manage content is tightly coupled with the frontend theme that displays that content to your visitors.
Headless WordPress decouples these two parts. Your WordPress installation becomes a pure, content-only repository. You still get the familiar and powerful WordPress admin dashboard to create, edit, and manage all your content, but the public-facing website is a completely separate application, built with a modern JavaScript framework like React or Next.js. This application is responsible for the entire visual presentation and user experience.
These two parts communicate through an API. The frontend asks the WordPress backend for content, and WordPress serves it as structured data.
The WP Engine Headless Platform
While you can build a headless architecture with any hosting, the WP Engine Headless Platform is purpose-built to eliminate complexity. It provides a cohesive, optimized environment by managing both your WordPress backend and your Node.js frontend application from a single, unified dashboard.
This streamlined workflow is further enhanced by developer-centric tools like the Faust.js framework and Git-based deployment, allowing you to build faster and more efficiently.
Performance is paramount, and the platform is engineered for the sub-second page loads users expect by leveraging a globally-distributed CDN and optimized Node.js servers. This focus on speed is matched by a commitment to security. The decoupled architecture inherently reduces a site’s attack surface, and WP Engine layers on its monitoring and threat detection to protect your application.
What We’re Building
In this guide, we’ll create a simple but fully functional blog as a ready-to-deploy headless site. This site will feature a main page that lists blog articles, and clicking an article will take the user to a dynamic, individual page for that specific post.
Setting Up Your Toolbox
Before we dive into building our site, let’s ensure you have all the necessary tools and accounts ready to go.
A WP Engine Account: You will need an active WP Engine account with a Headless Platform plan. This specific plan is essential as it provides both the managed WordPress installation for our backend and the dedicated Node.js hosting environment for our frontend application.
Node.js and npm: Modern JavaScript development runs on Node.js. We recommend downloading and installing the latest Long-Term Support (LTS) version from the official Node.js website. Node.js comes bundled with npm (Node Package Manager), which we will use to install and manage our project’s code packages.
Git: The WP Engine Headless Platform uses Git as its deployment engine. You will need Git installed on your computer to push your finished code to WP Engine’s servers and take your site live. You can download it from the official Git website.
A Code Editor: You don’t actually need a code editor. Feel free to edit all the files in a basic text editor if you want. With that said, Visual Studio Code is free, works well, and has a built-in terminal.
Configuring the Backend on WP Engine
With our tools in place, our first hands-on task is to set up the WordPress content backend. The WP Engine Headless Platform provides a streamlined workflow to get you started with a pre-configured blueprint, which sets up both your backend and a starter frontend project.
Starting a New Project from a Blueprint
First, log in to your WP Engine User Portal. From the main navigation menu, find and click on the dedicated Headless tab. You will be presented with two options: “Start with Blueprint” and “Pull from repository.” A blueprint is a starter kit that provisions everything you need. Since we are creating a new project, this is a good choice.
Click the Start with Blueprint button. You will see the available blueprints, including options like “Portfolio” and “Shopify.” We’ll pick the “Scaffolding” blueprint. As its description notes, this option provides the basic essentials to start a new project quickly, without imposing a pre-designed theme.
Select the Scaffolding blueprint. You will then be prompted to connect to a Git repository, with a choice between GitHub, Bitbucket, and GitLab. In this case, we’re going to use GitHub. Connecting to your Git repo is pretty much a matter of following the prompts. Once you have it set up, decide if you want to make your repository private, and then select your region. Next, click Add app to proceed to the next stage
The process may take several minutes to complete as WP Engine provisions your WordPress backend and creates the new “Scaffolding” starter repository in your connected GitHub account.
Accessing Your WordPress Backend
Once the creation process is complete, you’ll be taken to your new app’s “Overview” page. Here you will see the two core components that were just created: the GitHub repository for your frontend (under “Source”) and the WordPress environment for your backend (under “Environment details”.
Click on the WordPress environment to open its dedicated dashboard. On this page, you will find important information about your backend. Click the WP Admin link to be taken directly to your new WordPress site’s admin. The initial admin username and password has been automatically generated, but you’ll probably want to change it.
Creating Your Content
The WordPress dashboard is entirely familiar. For this project, its only job is to manage content. We won’t be installing any frontend themes or appearance-related plugins.
Before we add content, let’s verify some key components. Navigate to Plugins from the left-hand menu. You should see that the WPGraphQL and WPGraphQL for ACF plugins are already installed and activated, as are Advanced Custom Fields and Faust.js. WP Engine’s Headless Platform uses ACF for content modeling, so we’ll definitely need that. Faust.js acts as a middleware layer between the WordPress backend and your frontend application.
Next, we need to create some posts in the backend so there’s something to display on the frontend. You can use real posts, make up some gibberish, or use a plugin like WP Dummy Content Generator.
No matter which method you use, having this content ready will be essential for the next stages, where we will build the frontend to fetch and display it.
Building the Frontend with Faust.js
The “Scaffolding” blueprint we used in the previous step didn’t just create a WordPress installation; it also created a new repository in our GitHub account containing a starter frontend project. This project is built with Faust.js, WP Engine’s open-source framework designed specifically to simplify headless WordPress development.
What is Faust.js?
Faust.js is a framework built on top of the popular React framework, Next.js. While you can use any JavaScript framework for your headless site, Faust.js is highly recommended when using the WP Engine Headless Platform because it provides out-of-the-box solutions for common challenges, including simplified data fetching from your WordPress WPGraphQL API, a ready-to-use system for template creation, and integration with the WordPress preview engine.
Essentially, Faust.js handles the complex plumbing, allowing you to focus on building your site’s theme and components.
Cloning and Exploring the Project
Our first step is to get the starter code from the new Git repository onto our local machine.
Navigate to the Git account you used and find the new repository that WP Engine created for you (it should be named whatever you named your project). If you’re using GitHub, click the <> Code button, ensure the “HTTPS” tab is selected, and copy the repository URL.
Open your computer’s terminal (or the integrated terminal in VS Code), navigate to the directory where you store your projects, and run the following command, replacing the URL with the one you copied:
git clone https://github.com/your-username/my-headless-blog.git
Once the clone is complete, move into the new project directory:
cd my-headless-blog
Now, install all the necessary project dependencies using npm
. The command below installs all the dependencies listed in the project’s package. json
file.
npm install
Once npm install
is finished, you can open the entire project folder in VS Code to explore its structure.
Resolving a Critical File Collision
After running the git clone
command, you may see a significant warning message in your terminal that looks something like this:
warning: the following paths have collided (e.g. case-sensitive paths
on a case-insensitive filesystem) and only one from the same
colliding group is in the working tree:
'components/Footer.js'
'components/footer.js'
'components/Header.js'
'components/header.js'
This happens because the blueprint’s Git repository is case-sensitive and contains files with the same name but different capitalization (e.g., Header.js
and header.js
). However, your computer’s filesystem (macOS or Windows) is likely case-insensitive and sees these as the same file, causing a collision. This can result in one or both files being missing from your project folder.
Fortunately, the fix is straightforward. We just need to tell Git to remove the incorrect, lowercase versions of these files from its tracking system.
First, make sure you have moved into the new project directory with the cd my-headless-blog
command (replacing my-headless-blog
with your project’s folder name).
Next, run the following two commands to remove the conflicting files from Git’s tracking:
git rm components/footer.js
git rm components/header.js
Next, we commit this fix to our local Git history. This saves the change and ensures our project is in a stable state.
git commit -m "Fix file case collision from blueprint"
With the file collision resolved and the fix committed, your project folder is now in a clean, stable state, and you are ready to proceed.
Connecting the Frontend to WordPress
The most critical step in this section is telling our local frontend application where to find our WordPress backend and how to securely authenticate with it. This connection is managed using a special configuration file called an “environment file.”
First, we need to gather two pieces of information from the WordPress backend: the Site URL and a unique Secret Key.
Find Your Credentials in WordPress
Find Your WordPress URL: Log in to your WordPress admin dashboard. In the left-hand menu, navigate to Settings > General. Copy the URL from the Site Address (URL) field. This is the base URL for your WordPress backend (e.g.,
https://myheadlessblog.wpengine.com
).Find Your Faust.js Secret Key: This key is essential for authorizing requests between your frontend and backend. In your WordPress admin, click on the Faust tab in the left-hand menu. On this settings page, you will see a field labeled “Secret Key”. Copy this string of characters.
Create Your Local Environment File
Now that we have our credentials, let’s add them to our project. In the root of your project folder, you will find a file named .env.local.sample
. This is a template for your configuration.
Create a copy of this file and rename the copy to simply .env.local
. The .local
extension is a special convention that ensures this file is loaded into your local environment but is ignored by Git, keeping your secret key private.
Open the new .env.local
file in your code editor. Delete any existing content and replace it with the following, pasting in the URL and Secret Key you just copied from WordPress.
# This is the "Site Address (URL)" from your WordPress General Settings.
# Do NOT add /graphql or a slash to the end of it.
NEXT_PUBLIC_WORDPRESS_URL="https://myheadlessblog.wpengine.com"
# This is the secret key from the FaustWP plugin settings page in WordPress.
FAUST_SECRET_KEY="paste-the-long-secret-key-you-found-here"
Save the .env.local
file. With this file correctly configured, your frontend application should now have everything it needs to securely connect to your WordPress backend and fetch data.
Running the App Locally
With the project downloaded and configured, you are ready to see it in action. In your terminal, from the root of your project directory, run the following command:
npm run dev
This starts the local development server. After a few moments, you should see a message indicating that the server is running. You can now open your web browser and navigate to http://localhost:3000
.
If everything’s hooked up correctly, you should see a Faust.js boilerplate site. We’ll start fetching and displaying our own content in the next section.
Fetching and Displaying Content
Now that our local frontend is connected to our WordPress backend, we can start fetching content and displaying it. The Faust.js “Scaffolding” blueprint has already set up the basic files for this, which we will now customize.
We’re going to focus on just one key file, /wp-templates/front-page.js
, which controls the homepage of our site.
Customizing the Blog Index Page
Let’s modify our site’s homepage to display a clean list of our posts with their titles, excerpts, and featured images.
We know the main homepage is controlled by /wp-templates/front-page.js
, so open this file in your code editor. We will replace its contents with code that fetches and displays our blog posts.
Replace the entire contents of /wp-templates/front-page.js
with the following code:
import { gql } from '@apollo/client';
import Head from 'next/head';
import Link from 'next/link';
import Image from 'next/image';
import Header from '../components/Header';
import Footer from '../components/Footer';
export default function Component(props) {
// Loading state
if (props.loading) {
return <>Loading...</>;
}
// Get data, using optional chaining to protect against errors
const { title, description } = props.data?.generalSettings ?? {};
const posts = props.data?.posts?.nodes;
return (
<>
<Head>
<title>{title}</title>
</Head>
<Header title={title} description={description} />
<main className="container">
{posts && posts.length > 0 ? (
<div className="posts-list">
{posts.map((post) => (
<article key={post.id} className="post-item">
{post.featuredImage && (
<Link href={`/${post.slug}`}>
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText || post.title}
width={post.featuredImage.node.mediaDetails.width}
height={post.featuredImage.node.mediaDetails.height}
className="featured-image"
/>
</Link>
)}
<h2>
<Link href={`/${post.slug}`}>
{post.title}
</Link>
</h2>
<div
className="post-excerpt"
dangerouslySetInnerHTML={{ __html: post.excerpt }}
/>
</article>
))}
</div>
) : (
<p>No posts found.</p>
)}
</main>
<Footer copyrightHolder={title} />
</>
);
}
Component.query = gql`
query GetHomePage {
generalSettings {
title
description
}
posts(first: 100) {
nodes {
id
title
slug
excerpt
featuredImage {
node {
sourceUrl
altText
mediaDetails {
height
width
}
}
}
}
}
}
`;
This code block works by first defining the data it needs from WordPress and then rendering that data using a React component. At the bottom of the file, the Component.query
static property defines a GraphQL query. This query tells WordPress what data to send back, asking for both the generalSettings
(for the site title and tagline) and a list of posts
.
We ask for posts(first: 100)
, as many GraphQL servers require you to specify a limit to prevent accidentally requesting thousands of items at once. For each post, we request all the data we need, including the id
, title
, slug
for the URL, the excerpt
, and the featuredImage
with its URL and dimensions.
The main Component
function receives the results of this query via its props
. To prevent the kind of runtime errors we saw earlier, the code safely extracts the data using a modern JavaScript feature called optional chaining (?.
). The line const posts = props.data?.posts?.nodes;
ensures the application won’t crash even if the data
or posts
objects are missing from the response. The component then checks if the posts
array actually contains any items. If it doesn’t, it displays the “No posts found.” message, helpful for debugging.
If posts do exist, the code uses the standard .map()
method to loop through each post
in the array and render a separate <article>
for it. To create a link to the full post, each title is wrapped in Next.js’s <Link>
component. Finally, to display the post excerpt correctly, we use a special React property called dangerouslySetInnerHTML
. This is necessary because content from WordPress, like an excerpt, often includes its own HTML tags (like <p>...</p>
), and this property tells React to render that raw HTML rather than treating it as plain text. Try clicking on a title—it will likely lead to a “404 Not Found” page or a differently styled page for now. We’ll fix that next.
Building the Single Post Page
Now that our homepage correctly lists all the posts, we need to ensure that clicking on a post takes the user to a properly formatted page showing that post’s full content.
Just as /wp-templates/front-page.js
controls the homepage, the Faust.js templating system looks for a file named /wp-templates/single.js
to render individual posts. The “Scaffolding” blueprint may not have created this file for us, so we will create it and add the necessary code.
If you have a /pages/[postSlug].js
or /pages/[...wordpressNode].js
file, you can delete it to avoid confusion, as /wp-templates/single.js
will take precedence for your posts.
Create a new file named single.js
inside your /wp-templates
directory. Paste the following code into it:
import { gql } from '@apollo/client';
import Head from 'next/head';
import Link from 'next/link';
import Image from 'next/image';
import Header from '../components/Header';
import Footer from '../components/Footer';
export default function Component(props) {
// Loading state
if (props.loading) {
return <>Loading...</>;
}
// Get data for the post and site
const { post, generalSettings } = props.data;
// Handle case where post is not found
if (!post) {
return (
<>
<Header title="Not Found" description="Could not find the requested post." />
<main className="container">
<p>The post you were looking for could not be found.</p>
<Link href="/">
Return to the homepage
</Link>
</main>
<Footer copyrightHolder={generalSettings.title} />
</>
);
}
// Format the date
const postDate = new Date(post.date).toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<>
<Head>
<title>{post.title} - {generalSettings.title}</title>
</Head>
<Header title={generalSettings.title} description={generalSettings.description} />
<main className="container">
<article className="post-content-single">
<h1>{post.title}</h1>
<p className="post-meta">
By {post.author.node.name} on {postDate}
</p>
{post.featuredImage && (
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText || post.title}
width={post.featuredImage.node.mediaDetails.width}
height={post.featuredImage.node.mediaDetails.height}
className="featured-image"
/>
)}
<div
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
<p style={{ marginTop: '2rem' }}>
<Link href="/">
← Back to all posts
</Link>
</p>
</main>
<Footer copyrightHolder={generalSettings.title} />
</>
);
}
Component.query = gql`
query GetPost($databaseId: ID!, $asPreview: Boolean = false) {
post(id: $databaseId, idType: DATABASE_ID, asPreview: $asPreview) {
title
content
date
author {
node {
name
}
}
featuredImage {
node {
sourceUrl
altText
mediaDetails {
height
width
}
}
}
}
generalSettings {
title
description
}
}
`;
Component.variables = ({ databaseId }, ctx) => {
return {
databaseId,
asPreview: ctx?.asPreview,
};
};
This code is similar to our homepage template but has some key differences. The GraphQL query is designed to fetch a single post
using its databaseId
as a unique identifier. This is a more robust method than using the post’s slug. We also request more data, like the full content
and the author’s name. Critically, the Component.variables
function at the bottom is what makes this dynamic template work; it takes the ID of the post being requested from the page’s context and passes it into the GraphQL query.
Inside the component, we safely access the data and even include a check to see if a post was found. If not, we display a helpful “Not Found” message. The rest of the code renders the post’s title, metadata, featured image, and the full content, once again using dangerouslySetInnerHTML
to properly display the HTML from WordPress. We’ve also included a “Back to all posts” link using the modern Next.js <Link>
syntax.
After saving this file as /wp-templates/single.js
, your development server will detect it. Now, when you go to your homepage at http://localhost:3000
and click on any post title or image, you will be taken to a fully rendered page for that specific post.
Adding Featured Images to the Homepage
So far, we’ve managed to create a wall of text. It’s functional, but very boring. Pulling in the featured image for each post will make our headless more visual and engaging. The process follows the pattern we’ve established: update the GraphQL query to ask for the new data, then update our component to display it.
Update the GraphQL Query
First, we need to edit our query in /wp-templates/front-page.js
to ask for the featured image information for each post. It’s best practice to request not just the image URL, but also its altText
for accessibility and its mediaDetails
for sizing.
In your /wp-templates/front-page.js
file, find the Component.query
at the bottom and add the featuredImage
object to the nodes
field, like so:
// ... inside the Component.query ...
posts(first: 100) {
nodes {
id
title
slug
excerpt
featuredImage {
node {
sourceUrl
altText
mediaDetails {
height
width
}
}
}
}
}
// ...
Render the Image Component
Now that our query is fetching the image data, we can render it. We’ll use the built-in Next.js <Image>
component for its performance benefits like automatic image optimization and lazy loading.
First, add Image
to the list of imports at the top of /wp-templates/front-page.js
:
import Image from 'next/image';
Next, find the posts.map()
loop in your component. We will add the <Image>
component inside the <article>
tag, right before the <h2>
title. We’ll also wrap it in a <Link>
so clicking the image will navigate to the post. It’s important to add a conditional check (post.featuredImage && ...
) to prevent errors if a post doesn’t have a featured image set.
After these changes, your posts.map()
loop should look like this:
//...
{posts.map((post) => (
<article key={post.id} className="post-item">
{post.featuredImage && (
<Link href={`/${post.slug}`}>
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText || post.title}
width={post.featuredImage.node.mediaDetails.width}
height={post.featuredImage.node.mediaDetails.height}
/>
</Link>
)}
<h2>
<Link href={`/${post.slug}`}>
{post.title}
</Link>
</h2>
<div
className="post-excerpt"
dangerouslySetInnerHTML={{ __html: post.excerpt }}
/>
</article>
))}
//...
After saving this file, your application will try to reload, but it will immediately crash with an error. This is expected behavior. The error is a security feature of Next.js that we will fix in the next step.
Configuring the Image Hostname
When your page reloads, you will see a runtime error stating: hostname "..." is not configured under images in your next.config.js
.
This is a built-in security feature of the Next.js <Image>
component. To prevent abuse, Next.js requires you to explicitly “whitelist” all external domains you plan to load images from. We need to tell our application that it’s safe to load images from our WP Engine site.
In the root directory of your project, find and open the file named next.config.js
. Replace its contents with the following code. This adds the necessary approval for your WP Engine domain. Be sure to replace myheadlessblog.wpengine.com
with your actual WordPress hostname.
const { withFaust } = require('@faustwp/core');
/** @type {import('next').NextConfig} */
module.exports = withFaust({
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'myheadlessblog.wpengine.com', // <-- REPLACE THIS
port: '',
pathname: '/wp-content/uploads/**',
},
],
},
});
This is a critical step. After saving the next.config.js
file, you must completely stop and restart your development server. This configuration file is only read when the server first starts. Go to your terminal, press Ctrl+C to stop the server, and run npm run dev
again to restart it.
Once the server restarts, the error will be gone, and your featured images will load correctly, though they may be very large and unstyled.
Styling the Featured Images with CSS
Now that the images are loading, we can control their size and appearance using CSS. We’ll assign a class name to the images and then define styles for that class in our project’s global stylesheet.
Assign a CSS Class
In your /wp-templates/front-page.js
file, find the <Image>
component again and add a className
prop to it.
// ...
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText || post.title}
width={post.featuredImage.node.mediaDetails.width}
height={post.featuredImage.node.mediaDetails.height}
className="featured-image" // <-- ADD THIS LINE
/>
// ...
Add the CSS Rules
Now, open your global stylesheet, located at /styles/globals.css
, and add the following CSS code block to the bottom of the file.
/* Styles for our Featured Images on the homepage */
.post-item .featured-image {
width: 100%;
height: 250px; /* You can change this height to whatever you like */
object-fit: cover; /* This prevents the image from being stretched or squished */
display: block;
margin-bottom: 1rem; /* Adds some space between the image and the title */
border-radius: 8px; /* Optional: gives the images nice rounded corners */
}
This CSS tells the browser to make the images take up the full width of their container but maintain a fixed height of 250px
, cropping them neatly to fit without distortion (object-fit: cover
).
After saving both files, your browser will refresh, and you should see your list of posts, each with a beautifully sized and styled featured image.
Wrapping Up
We’ve managed to create a headless WordPress site, but it might seem like we put in a lot of effort to create something that would be easier to accomplish if we simply used traditional monolithic WordPress. However, what we’ve created is a solid foundation for fetching and displaying any type of content from WordPress, including posts, featured images, and global site settings.
Think of this project as a starting point. With the core mechanics in place, you can expand on this foundation in different ways. For example, you can create a truly unique design by diving deeper into the /styles/globals.css
file, or you could integrate a framework like Tailwind CSS to accelerate your styling workflow. To build out the site’s full user interface, you can create a dynamic <Nav>
component that renders your site’s navigation by querying the menu data from WordPress via GraphQL.
Beyond visual changes, you can greatly expand the site’s capabilities by creating new content types. Using the pre-installed Advanced Custom Fields plugin, you could move beyond just “Posts” and create structured content like “Portfolio Items,” “Team Members,” or “Products,” all of which can be fetched with new GraphQL queries. To further improve the content management experience, you can explore one of the most powerful features of Faust.js: enabling post previews. While it requires some additional configuration, this feature provides a seamless workflow for your content team by allowing them to see their draft content directly on the headless frontend before hitting “Publish.” Have you built any headless sites? Did you go truly from scratch, or use a blueprint/boilerplate to speed up the process? Let us know in the comments!