Why I Decoupled WordPress

For a long time, my site worked in the most dangerous way possible. It worked just well enough that I ignored the cost. The CMS lived on shared hosting across Namecheap and Spaceship. It accumulated posts, media, embeds, galleries, and historical cruft for years. The database grew. The uploads directory crossed multiple gigabytes. And slowly, almost invisibly, the experience of writing degraded. The editor lagged. Autosave jittered. Media uploads stalled. None of this was dramatic enough to trigger a rewrite, which is exactly why it lingered.

The core problem was not performance in isolation. It was responsibility. WordPress was responsible for too many things at once. It was my editor, renderer, router, interaction layer, image pipeline, cache strategy, and security surface. Any improvement in one area risked regression in another. That coupling was the real issue.
So the first decision was not “go headless.” It was draw a boundary.
The boundary: WordPress is content, not presentation
Once I made this explicit, everything else followed naturally.
WordPress would remain the system of record for content. Posts, categories, comments, media, editorial workflows. That part is extremely good and not worth replacing. But WordPress would never again render a public page. No themes. No PHP templates. No conditional logic in the frontend. The only thing allowed to leave WordPress was JSON.
This immediately reframed hosting choices. Shared hosting made sense when WordPress was serving pages. Once it became an API and admin interface only, the constraints changed. I wanted fast admin UX, reliable media delivery, and zero server maintenance. WordPress.com was the obvious move. I migrated the entire CMS to cms.craiggomes.com, routed DNS without proxying, and let WordPress.com handle scaling, caching, and media delivery.
The effect was immediate. Writing became fluid again. Image uploads felt local. Scrolling through the media library no longer stalled. That alone validated the decision.
Frontend architecture: Remix on Cloudflare Pages
With the backend stabilized, the frontend could finally be designed as a system, not a theme.
I chose Remix for one reason above all others: it forces honesty about data. Routes declare what they need. Loaders run on the server. HTML is the default output. Suspense works with streaming instead of against it. This matters for a content site. SEO matters. First paint matters. Reading should not wait for JavaScript to negotiate state.

The frontend runs on Cloudflare Pages using the Workers runtime. That gives me global edge rendering, HTTP/3 by default, and a deployment model that is trivial to reason about. Every request can render fresh HTML without hitting a centralized origin.
Data flow and rendering model
The mental model is simple and strict.
- Content is authored and edited in WordPress.
- Remix loaders fetch data from the WordPress REST API.
- The Workers runtime renders the route on request.
- HTML and critical CSS stream immediately.
- The client hydrates progressively.
- Secondary data like comments and images load after content is visible.
WordPress is treated as a slow, external system. The UI never blocks on it.
A typical loader looks like this conceptually:
export const loader = async () => { const res = await fetch( `${process.env.WORDPRESS_API_URL}/posts?_embed&per_page=10` ); if (!res.ok) { throw new Response("Failed to fetch posts", { status: 500 }); } return json(await res.json());};
This runs at the edge. The response is cached opportunistically by Cloudflare. The browser receives rendered HTML, not a blank shell.
The hardest problem: legacy media
If you have years of WordPress content, media is where headless implementations go to die.
Featured images missing from _embedded. Jetpack CDN rewriting URLs. -scaled suffixes. Multiple size permutations. Old uploads referencing domains that no longer exist. In a PHP theme, you tolerate this. In a headless system, broken images are fatal to perceived quality.
I solved this by treating image resolution as a progressive fallback pipeline, not a single lookup.
The order of operations is:
- Use
_embedded["wp:featuredmedia"][0].source_urlif it exists. - If missing, parse
post.content.renderedand extract the first<img>. - Normalize the URL back to the CMS domain.
- Attempt known WordPress size permutations.
- Attempt
-scaledvariants. - Only give up after exhausting all options.
This logic lives next to rendering so failures are handled immediately, not deferred.
Image loading without layout shift
Resolving URLs is only half the problem. Images must feel fast.
Every non gallery image renders inside a fixed aspect ratio wrapper with a skeleton placeholder. The skeleton reserves space. The image fades in only after the browser confirms it has loaded.
const [isLoading, setIsLoading] = useState(true);useEffect(() => { if (!imgRef.current || !featuredImage?.source_url) return; const img = imgRef.current; if (img.complete && img.naturalHeight !== 0) { img.classList.add("opacity-100"); setIsLoading(false); } else { img.classList.add("opacity-0"); img.addEventListener( "load", () => { img.classList.replace("opacity-0", "opacity-100"); setIsLoading(false); }, { once: true } ); }}, [featuredImage]);
Gallery images opt out of skeletons because wrapping them introduces visual gaps. That distinction matters. Headless gives you the freedom to make it.
Rewriting post content safely
WordPress injects inline widths, heights, margins, and gallery spacing that assume PHP themes. I strip all of it.
The PostContent component walks the rendered HTML, wraps images with loading containers, removes inline styles, and lets CSS handle layout.
images.forEach((img) => { if (img.dataset.galleryImage === "true") return; if (!img.parentElement?.classList.contains("image-loading-wrapper")) { const wrapper = document.createElement("div"); wrapper.className = "image-loading-wrapper relative overflow-hidden"; const skeleton = document.createElement("div"); skeleton.className = "absolute inset-0 animate-pulse"; wrapper.appendChild(skeleton); wrapper.appendChild(img); } if (img.complete && img.naturalHeight !== 0) { img.classList.add("opacity-100"); img.parentElement?.querySelector("[data-skeleton]")?.remove(); } else { img.classList.add("opacity-0"); img.addEventListener( "load", () => { img.classList.replace("opacity-0", "opacity-100"); img.parentElement?.querySelector("[data-skeleton]")?.remove(); }, { once: true } ); }});
This pairs with Tailwind overrides that remove WordPress gallery margins and eliminate massive white gaps. The result is readable, stable content.
Comments and secondary data
Comments are real, but not urgent. Remix lets me defer them cleanly.


The page renders immediately. Comment counts and threads load later via Suspense. No blocking. No spinner walls. This alone dramatically improves perceived performance.
UI interactions without WordPress constraints
Modals are where headless becomes obvious.
Subscribe flows, profile popovers, share menus. In a traditional theme, these require PHP templates, AJAX handlers, and compromises. Here, they are just React components rendered via portals.



The SubscribeModal opens instantly. Network happens after.
const response = await fetch("/api/subscribe", { method: "POST", body: formData});
The endpoint proxies Mailchimp so API keys never touch the client. The ProfileModal anchors to the avatar’s DOMRect, clamps to the viewport, and exposes links WordPress was never designed to express.
None of this touches the CMS.
Deployment and environment isolation
The deployment pipeline is intentionally boring.
GitHub holds the repo. Cloudflare Pages watches main. Every push triggers a build. Remix and Vite compile without manual chunking so Workers can manage imports. The server bundle deploys automatically. Environment variables define the WordPress API base URL.
WORDPRESS_API_URL=https://cms.craiggomes.com/wp-json/wp/v2NODE_VERSION=18
Swap CMS endpoints without touching code. Rollbacks are instant. Observability lives at the edge.
Local development mirrors production
Locally, Remix runs as usual. WordPress can run locally via Local or point to production. The frontend does not care. That separation is the entire point.
What I learned
The most important insight is this:
WordPress does not need to be fast for users.
It needs to be fast for writers.
The frontend should never wait on it.
Once you accept that, everything simplifies. Design stops being migration work. Performance problems surface clearly. Systems become calm instead of fragile.
This rebuild was not about modern stacks. It was about restoring focus. WordPress does what it does best. The frontend does what it does best. And the boundary between them is explicit, enforced, and liberating.
That is the real benefit.
You can visit craiggomes.com to explore the new site and see how this approach comes together in practice. I’ve also implemented an AI chatbot trained on my blog posts, site content, and writing style. It’s designed to let readers explore ideas, navigate articles, and interact with the work in a more conversational way. I’ll be writing a separate piece on how that system was built and what it changes in a future edition.
Ask me about this article
Have questions? I'm here to help.

0 Comments
No comments yet
Be the first to share your thoughts!