There is a specific thrill that comes with the “Maker’s Mindset”—the drive to pull things apart, understand how they tick, and rebuild them better. Recently, I set out to transition a monolithic WordPress setup into a modern Jamstack architecture using Gatsby and Netlify.
💡 See it live! You can browse the final, deployed Headless app right here: Witty Paws – Headless WP Demo (Note: The rich content you see is populated using the official WordPress Theme Unit Test Data, ensuring that all visual and functional edge cases have been thoroughly covered).
While the concept of Headless WordPress isn’t new, moving from a standard setup to a truly production-ready, bulletproof system exposes a lot of edge cases. This isn’t just a tutorial; it’s a deep look at the architectural decisions, the security hardening, and the clean code patterns that bridged the gap between a robust CMS and a lightning-fast frontend.
Here are the biggest architectural lessons from this journey.
1. Bulletproof Dynamic Routing: The Fallback System
The Problem: In a traditional Gatsby + WP setup, every time you create a new Custom Post Type (CPT) in WordPress (e.g., “Movies” or “Projects”), you have to manually write a new React template and update gatsby-node.js. If you forget, or if an editor spins up a CPT without your knowledge, the build crashes.
The Architecture: We need a system that adapts dynamically. Instead of failing when a specific template is missing, the Gatsby Node API should intelligently route to a “Fallback Template.”
You can see how this system behaves in the wild on the live app:
- Movie Fallback Single: A movie post to check the live loading…
- Movie Fallback Archive: Movies Archive Page
- Project Specific Single: My second portfolio live
- Project Specific Archive: Projects Archive Page
By utilizing Node’s fs.promises.stat, we check if a specialized template exists. If it doesn’t, we gracefully fall back to a default.js template.
// Inside gatsby-node.js
const specificTemplatePath = `./src/templates/archive/${contentType.name}.js`;
const specificTemplateExists = await fileExists(path.resolve(specificTemplatePath));
// The Magic: Route to the specific template if it exists, otherwise use the default!
const componentPath = specificTemplateExists
? path.resolve(specificTemplatePath)
: DEFAULT_ARCHIVE_TEMPLATE;
if (await fileExists(componentPath)) {
await gatsbyUtilities.actions.createPage({
path: getPagePath(pageNumber),
component: componentPath,
context: {
contentType: contentType.name, // Passed to the template to know what to query
},
})
}
Using GraphQL interfaces (wpContentNode), the default template can query the title and content of any post type. This means a content editor can spin up a new CPT in WordPress, and the frontend will generate the pages automatically—zero code required.
2. WordPress-Side Hardening: Locking Down the Frontend
The Problem: Once your Gatsby site is live on your production domain (e.g., yourdomain.com), the original WordPress theme frontend becomes redundant and a massive security risk. Leaving it accessible exposes a “broken” default theme to users, risks duplicate content penalties on search engines (SEO), and leaves common entry points vulnerable to automated bots.
The Architecture: We need to completely isolate the WordPress ecosystem, transforming it strictly into a private API machine. This requires intercepting requests at the WordPress core level using the template_redirect hook.
By adding this snippet to the WordPress functions.php file, we explicitly block anyone from viewing the default frontend, routing them directly to our production Gatsby site, while keeping API lanes completely clear:
add_action( 'template_redirect', 'redirect_wp_frontend_to_headless' );
function redirect_wp_frontend_to_headless() {
$headless_domain = 'https://headllesswp.netlify.app';
// Absolute Exception: Allow WPGraphQL requests to pass through unhindered
$is_graphql = function_exists( 'is_graphql_request' ) && is_graphql_request();
// Exception: Allow WP REST API requests for essential plugins
$is_rest = defined( 'REST_REQUEST' ) && REST_REQUEST;
// If it's a regular user or bot trying to access the theme layer, execute a 301 redirect
if ( ! $is_graphql && ! $is_rest ) {
$redirect_url = $headless_domain . $_SERVER['REQUEST_URI'];
wp_redirect( $redirect_url, 301 );
exit;
}
}
With this piece of infrastructure in place, the administrative backend (/wp-admin/) and data pipelines remain perfectly operational, but the public-facing footprint of your server is effectively erased.
3. The “Invisible” CMS: Zero Data Leak & Reverse Proxies
The Problem: WordPress notoriously hardcodes absolute URLs into its database content. If an editor links to a media file or inserts an image, the raw HTML output will leak your hidden, private CMS domain (secretcms.yourdomain.com). This completely defeats the purpose of hiding your backend.
The Architecture: We solved this via a combined approach: a local abstraction utility and an edge-routing layer on our server.
First, we write a content sanitizer regex to look through raw WP content fields. Instead of leaking the absolute endpoint, we replace it with a clean, unified internal route (/media/):
const sanitizeWpContent = (htmlContent) => {
if (!htmlContent) return '';
let sanitized = htmlContent;
// Mask the absolute CMS media paths to an internal route
sanitized = sanitized.replace(/https:\/\/secretcms\.yourdomain\.com\/wp-content\/uploads\//g, '/media/');
// Remove any remaining raw references to the backend domain
sanitized = sanitized.replace(/secretcms\.yourdomain\.com\//g, '');
return sanitized;
};
Second, to avoid throwing 404 errors when a browser looks for a file inside the nonexistent /media/ folder, we declare a Reverse Proxy Ruleset on Netlify using a _redirects configuration file packaged into Gatsby’s static distribution folder:
/media/* https://secretcms.yourdomain.com/wp-content/uploads/:splat 200
Because we specify a 200 status code, Netlify acts as a secure intermediary. It fetches the resource from the private WordPress instance in the background and pipes it down to the client under the primary domain mask. The end-user never discovers the underlying infrastructure.
4. Schema Customization: Keeping the UI Clean with createResolvers
The Problem: In a headless environment, you constantly run into unstable data inputs: posts missing excerpts, empty page content blocks, or content titles wrapped in stray inline HTML formats (like Title <em>With</em> <b>Markup</b>). Handling this at the component rendering tier produces massive boilerplate bloat and brittle templates.
The Architecture: We shift data sanitization away from runtime browser execution and move it directly into the static compilation process. By hooking into Gatsby’s native createResolvers Node API, we intercept data fields and build custom, standardized fields directly into the GraphQL layer.
// Extending Gatsby Schema in gatsby-node.js
exports.createResolvers = ({ createResolvers }) => {
createResolvers({
WpPost: {
safeTitle: {
type: `String`,
resolve(source) {
return source.title ? source.title : `Untitled`;
},
},
safeContent: {
type: `String`,
resolve(source) {
if (!source.content) return `<p>No content available for this item.</p>`;
return sanitizeWpContent(source.content); // Sanitization happens at build time!
},
},
safeSeoTitle: {
type: `String`,
resolve(source) {
return source.title ? stripHtmlTags(source.title) : `Untitled`;
}
}
}
});
};
Now, your React templates don’t care about absolute paths, formatting flaws, or missing content fallbacks. They just query safeContent or safeSeoTitle, ensuring the UI code is strictly presentation-focused, DRY (Don’t Repeat Yourself), and resilient against unexpected database changes.
5. Advanced GraphQL: Taming Taxonomy Chaos
The Problem: WordPress shares global categories. If you assign a “Development” category to both a blog post and a custom portfolio item type, the default WordPress GraphQL category count property aggregates them together. Displaying this inaccurate total on a dedicated Blog Listing template ruins the user experience.
The Architecture: We isolated the data relation by applying Gatsby’s strict elemMatch schema filtering syntax inside our structural queries.
Instead of pulling every term from the database blindly, we force the query to validate that a term contains standard core posts with valid active IDs before returning it:
query InquireBlogCategories {
categories: allWpCategory(
filter: {posts: {nodes: {elemMatch: {id: {ne: null}}}}}
) {
nodes {
id
name
uri
posts {
nodes {
id
}
}
}
}
}
Inside our front-end React components, checking category.posts.nodes.length now evaluates exactly to the context of the blog section alone, completely filtering out other post types sharing that taxonomy term.
6. Environment Variables & CI/CD Pipelines
Hiding a private API endpoint means making sure it never leaks into a public repository. We containerized all configuration parameters by implementing a strict environment pipeline:
- Local Isolation: Storing variables inside distinct local files (
.env.developmentand.env.production) which are explicitly declared in our.gitignorerules to block accidental pushes to GitHub. - Gatsby Schema Hydration: Binding these configurations on compilation initialization inside
gatsby-config.js:
require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` })
- Netlify Injection: Mapping the exact identical key environments (
WP_GRAPHQL_URL = https://secretcms.yourdomain.com/graphql) inside Netlify’s encrypted Environment Variable Dashboard.
Finally, we automated the entire synchronization loop. By pairing Netlify Build Hooks with WordPress-side webhook triggers, every single database modification (saving a post, updating a project, editing a category) sends an encrypted handshake to Netlify. This immediately initializes an incremental, lightning-fast static rebuild in the background.
The Takeaway
Transitioning to Headless WordPress isn’t just about changing the tech stack; it’s about changing how you think about data flow, security boundaries, and routing logic. By decoupling the monolithic architecture, hardening the CMS backend, intercepting the data layer with custom schema resolvers, and leveraging modern edge proxies, you don’t just build a fast website—you build an unbreakable application.

Leave a Reply