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 SystemThe Ultimate “Stealth” Architecture
When building a modern Headless WordPress site with Gatsby and deploying it to Netlify (like my production setup at https://cool-wp.netlify.app/), performance and airtight security are your primary goals. You want a lightning-fast frontend while keeping your backend CMS entirely hidden and inaccessible to the public.
However, you quickly discover a major architectural loophole: Your secret CMS URL leaks everywhere by default. It compromises your asset source paths, embeds itself into inline body links, and even surfaces inside the encoded parameter strings generated dynamically by the Gatsby Image CDN. If you think running a surface-level string replace directly inside your frontend React components will safeguard your stack, you are leaving your backend wide open to automated scanners and scrapers.
In this guide, we will implement a true Stealth Architecture. We won’t just mask URLs; we will surgically wipe any trace of the backend domain from the generated static files at build time, while establishing a robust, production-ready dynamic routing layout.
2. The Core Architecture: Moving to Build-Time Resolvers
The naive approach to headless sanitization relies on frontend filters applied during rendering. This creates a severe data leak: your raw, unfiltered database strings—complete with your original CMS domain—get compiled directly into Gatsby’s public page-data.json files. Anyone inspecting the browser’s Network tab can instantly expose your origin server.
To achieve complete isolation, our sanitization must happen on the build server. We achieve this by hooking into Gatsby’s GraphQL data layer via custom resolvers, purging the origin domain before the static schema is even generated.
3. Setting Up the Dynamic Netlify Edge Proxy
Instead of forcing client browsers to request assets directly from the WordPress upload directory, we will transform Netlify into an anonymous reverse proxy. At the end of the build cycle, we dynamically generate a native Netlify _redirects file that maps an internal path (/media/*) directly to our origin server.
Add this lifecycle hook into your gatsby-node.js:
exports.onPostBuild = async () => {
const fs = require('fs');
const path = require('path');
// Extract the base CMS URL from your environment variables
const graphqlUrl = process.env.WPGRAPHQL_URL || '';
const rawUrl = graphqlUrl.replace(/\/graphql\/?$/i, '').replace(/\/+$/, '');
if (rawUrl) {
// A 200 status code instructs Netlify to perform a silent rewrite (Proxy), not a redirect
const redirectContent = `\n/media/* ${rawUrl}/wp-content/uploads/:splat 200\n`;
const redirectsPath = path.join(__dirname, 'public', '_redirects');
fs.writeFileSync(redirectsPath, redirectContent, { flag: 'a' });
}
};
4. The Multi-Layer Content Sanitizer
Our sanitization workflow must target two distinct string formats: plain HTML URLs (like href and src tags) and URL-encoded queries wrapped inside Gatsby’s Image CDN requests.
We also implement an environment bypass to ensure the sanitizer remains dormant during local development—allowing the local dev server to download remote media successfully—while activating seamlessly during production builds.
Add this helper function to your configuration file:
const sanitizeWpContent = (htmlContent) => {
if (typeof htmlContent !== 'string') return '';
// Bypass during local development so Gatsby can fetch images smoothly
if (process.env.NODE_ENV === 'development') {
return htmlContent;
}
let sanitized = htmlContent;
try {
const graphqlUrl = process.env.WPGRAPHQL_URL || '';
const rawUrl = graphqlUrl.replace(/\/graphql\/?$/i, '').replace(/\/+$/, '');
const wpDomain = rawUrl.replace(/^https?:\/\//, '');
const publicUrl = process.env.URL || 'https://cool-wp.netlify.app';
if (!rawUrl) return htmlContent;
const escapeRegex = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Pattern matching for standard media tags and domain entries
const uploadsRegex = new RegExp(`${escapeRegex(rawUrl)}/wp-content/uploads/`, 'g');
const domainRegex = new RegExp(`${escapeRegex(wpDomain)}/`, 'g');
// Pattern matching for encoded strings hijacked by the Gatsby Image asset optimizer
const encodedUploads = encodeURIComponent(`${rawUrl}/wp-content/uploads/`);
const encodedUploadsRegex = new RegExp(escapeRegex(encodedUploads), 'g');
const encodedMediaReplacement = encodeURIComponent(`${publicUrl}/media/`);
// Execute absolute backend masking
sanitized = sanitized.replace(encodedUploadsRegex, encodedMediaReplacement);
sanitized = sanitized.replace(uploadsRegex, '/media/');
sanitized = sanitized.replace(domainRegex, '');
} catch (error) {
console.error("Error during content build-time sanitization:", error);
return htmlContent;
}
return sanitized;
};
5. The Hybrid “Radar” Custom Schema Resolvers
To maintain strict DRY (Don’t Repeat Yourself) design patterns, we shouldn’t write distinct filter properties manually for every single Custom Post Type (CPT). Instead, we build an automated schema discovery radar that intercepts all WordPress nodes possessing content properties and mounts our sanitization fields programmatically.
exports.createResolvers = ({ createResolvers, getNodes }) => {
const allNodes = getNodes();
// Dynamically map all active WordPress types loaded in the schema
const dynamicWpTypes = allNodes
.filter(node => node.internal.type.startsWith('Wp') && (typeof node.content !== 'undefined' || typeof node.title !== 'undefined'))
.map(node => node.internal.type);
// Core fallback array ensuring production types are always patched
const allTypesToPatch = [...new Set([
...dynamicWpTypes,
'WpPost',
'WpPage',
'WpProject'
])];
const resolvers = {};
allTypesToPatch.forEach(type => {
if (type === 'WpUser') return; // Bypass metadata types lacking markup fields
resolvers[type] = {
content: {
type: `String`,
resolve: (source) => sanitizeWpContent(source.content)
},
safeContent: {
type: `String`,
resolve: (source) => source.content ? sanitizeWpContent(source.content) : `<p>No content available.</p>`
},
safeTitle: {
type: `String`,
resolve: (source) => source.title || `Untitled`
},
safeExcerpt: {
type: `String`,
resolve: (source) => source.excerpt || `<p>No excerpt available.</p>`
}
};
});
createResolvers(resolvers);
};
6. Preventing GraphQL Interface Validation Crashes
When you pass custom properties into schema types via createResolvers, Gatsby’s validation engine may fail if those types are evaluated inside broader polymorphism containers—such as GraphQL interfaces (e.g., ... on WpNodeWithTitle).
To eliminate validation errors, explicitly append your customized fields to the corresponding schema interfaces:
exports.createSchemaCustomization = ({ actions }) => {
const { createTypes } = actions;
const typeDefs = `
interface WpNodeWithTitle {
safeTitle: String
}
interface WpNodeWithContent {
safeContent: String
}
interface WpNodeWithExcerpt {
safeExcerpt: String
}
`;
createTypes(typeDefs);
};
7. Dynamic Route Management & Conflict Deflection
A structural challenge when managing a Headless site is syncing the WordPress Reading Settings (Static Front Page vs. Posts Page) dynamically. When a page is designated as the blog index, WPGraphQL updates the core post type endpoint path but strips the standard node URI, exposing your build process to path: null crashes or route overlap conflicts.
We fix this by dynamically analyzing node flags (isFrontPage / isPostsPage) and implementing an execution shield inside the page generation cycle:
exports.createPages = async gatsbyUtilities => {
const pagesResult = await gatsbyUtilities.graphql(`
query {
allWpPage {
nodes { id uri isFrontPage isPostsPage }
}
}
`);
const allPages = pagesResult.data?.allWpPage?.nodes || [];
const postsPage = allPages.find(p => p.isPostsPage);
const blogArchiveUri = postsPage?.uri || '/blog/';
// Inside your core node creation loop (createNodePages):
return Promise.all(edges.map(async (edge) => {
const pagePath = edge.node.uri;
// Shield 1: Catch and skip any orphaned null URIs hijacked by the Posts Page assignment
if (!pagePath) return;
// Shield 2: Block standard single page generation from overwriting the dedicated archive route
if (contentType.name === "page" && pagePath === blogArchiveUri && blogArchiveUri !== "/") {
return;
}
// ... proceed with native actions.createPage mapping
}));
};
Conclusion
By routing our asset pipes through build-time schema interception, your deployment environment converts into an airtight sandbox. The architecture stays modular, GraphQL queries remain natively DRY, image files utilize full CDN optimization without CORS constraints, and the stack maps its paths fluidly relative to your live database states.
The origin layer is officially decoupled, obscured, and secure. Happy coding!

Leave a Reply