How I structure WordPress data for headless builds
The content model is the foundation of a headless site. Get it wrong early and you pay for it in every query, every component, and every editor complaint.
Most headless WordPress projects hit the same wall: the content model was designed for a traditional theme, then handed to a front end that expects clean, structured data. Pages with sprawling block layouts get passed to a component that has no idea what to do with nested markup.
The fix isn’t cleverness on the front end. It’s modeling the data correctly before anything gets built. (Still deciding whether headless is the right call at all? Start with when I reach for headless WordPress and Astro.)
Start with questions, not fields
Before I touch a custom field or register a post type, I map out what the content actually needs to do:
- What are the distinct things on this site: services, team members, projects, testimonials, locations?
- Which fields change per entry, and which are the same across all of them?
- What relationships exist? Do projects link to services? Do testimonials point at a project?
- Which fields will editors update weekly, and which get set once and left alone?
The answers decide whether something should be a custom post type, a taxonomy, a repeater, or a plain field. Those are not interchangeable, and guessing is what creates the mess later.
Custom post types for real entities
A custom post type is the right call when you have several instances of a clearly defined thing: team members, services, locations, testimonials. Not blog posts, not generic pages.
Each one gets its own endpoint in the REST API, its own field group, and its own clean fetch at build time. The front end can then ask for exactly what it wants and get back a predictable, typed list.
What to avoid: using pages for everything. I’ve inherited “pages” titled “Team Member: Sarah Chen” with a custom template and a dozen hidden meta fields. That works in a traditional theme. It’s painful to consume from an API.
Fields, and the relationships between them
Advanced Custom Fields (or the block-native equivalent) handles the structured fields on each post type. A service might have a tagline, a repeater of features, an optional pricing note, and a relationship field pointing at related services.
Relationships are the part people get wrong. WordPress has no native foreign keys, so links between post types are managed through relationship fields or taxonomy terms. For specific one-to-one or one-to-many links, like projects belonging to services, I reach for relationship fields. Taxonomies are better for categories that show up in lots of contexts, like service categories or project types.
At build time, you resolve those links in memory once rather than querying over and over:
const projects = await getCustomPostType('project');
const services = await getCustomPostType('service');
const projectsWithServices = projects.map((project) => ({
...project,
services: (project.acf.related_services ?? [])
.map((id) => services.find((s) => s.id === id))
.filter(Boolean),
}));
Fetch once, resolve in memory, no repeated round trips.
What the front end gets to rely on
When the model is right, the components are boring in the best way. A service card receives a service object with a title, a description, a tagline, and a features array. It doesn’t parse blocks or inspect templates. The data is what it says it is.
That’s the whole goal: a model where the shape of the data is obvious and consistent before the first component is written. I map this out in the first session of a project, before WordPress is even configured.
Getting this structure right is the heart of my CMS consulting work, and step one of every WordPress to Astro migration I run. If you want it right from the start, book a call and we’ll talk through it.