Decoupling WordPress From The Front End

WordPress has shipped with a REST API since version 4.7, and that changed the possibilities for how the CMS can be used. Instead of being locked into PHP-based theme development, the content management layer can now be separated from the presentation layer entirely. This is commonly referred to as a headless CMS approach, and it’s not unique to WordPress — Drupal and other major CMS platforms offer similar models.

In a headless setup, WordPress serves purely as the content management interface. Data entered in the admin panel is syndicated via the API to any front end that requests it, whether that’s a static site, a progressive web app, or something else entirely. The practical benefit is that your existing WordPress content workflow remains unchanged while the front-end architecture can be rebuilt for better performance and security.

Foundation: Vue, Nuxt, And A Demo

To demonstrate this pattern, we’ll build a demo project that pairs a Nuxt-based Vue application with WordPress as the content source. The assumptions for getting started are that WordPress is installed and running, there is content to pull from the API, and the front end will be built with Vue using Nuxt. Deployments will be handled through Netlify, which can trigger builds automatically when changes are pushed to a repository.

Nuxt brings together several features that would otherwise need to be configured manually in a typical Vue project: bundling, hot reloading, server-side rendering, and routing. By relying on Nuxt’s standard project setup, we can move quickly to the parts that matter for the headless integration. The structure of a standard Nuxt project includes a /pages directory for route components and a /store directory for the Vuex store.

Hooking Up Netlify And The Repository

Before connecting the app to Netlify, create a new repository on GitHub and push the local project to the master branch. Netlify will need authorization to read repositories from your GitHub, GitLab, or BitBucket account. After selecting the repository, Netlify will ask which branch to use for deployments and what build settings to apply.

For a Nuxt project, the settings are:

  • Build command: yarn generate or npm run generate
  • Directory: dist

Once those are configured, the site can be deployed, and subsequent pushes to the chosen branch will trigger new deployments automatically.

Building The Vuex Store For Post Data

Nuxt includes support for a Vuex store out of the box, which gives the application a central location to hold data that components can consume. By default, the /store directory is empty. The setup needed for our index page uses the standard Vuex pattern:

state holds the data. For this project, we’ll replace the default value with an empty array reserved for posts: posts: []. This is where all the fetched content will live.

mutations are the only mechanism that can update the state. The mutation we need should be renamed to updatePosts, and it will take a payload of data and change the state to use that payload.

actions are where asynchronous calls can be made. We’ll use the action to fetch data from the WordPress API and then commit a mutation to update the state. As a safeguard, the action should first check whether the posts array in the state already has content—if it does, the API call has already been made and doesn’t need to be repeated. Any errors encountered during the fetch should be caught and logged to the console, though in a production app it’s worth checking whether the environment is development before logging.

When fetching posts from the API, it’s important not to store everything the API returns. REST APIs typically return all fields, but WordPress stores a substantial amount of data per post. Instead, we filter the response to only include what’s needed:

  1. Published posts only, to keep drafts out of the feed.
  2. The Post ID, to distinguish between individual posts.
  3. The slug, which is useful for linking up post pages.
  4. The title and excerpt for content display on the index.

Applying a .filter() method and .map() to the response keeps only the schema we need and provides a meaningful performance boost when transferring and storing data.

Displaying Posts In The UI

The action needs to be called from a component to trigger the data fetch and make the posts visible. The index.vue file in the /pages directory is the component Nuxt uses as the homepage, and it’s the natural place for a blog post index.

To render the posts:

  • Dispatch the action in the created lifecycle method, which kicks off the API call when the component is instantiated.
  • Store the posts that come back in a computed property, allowing the template to react to any updates in the data.
  • Loop through the posts in the template, rendering the title and the excerpt from each one.
  • Link each post title off to an individual post page where the full content will live—similar to a single.php template in a traditional WordPress theme.

That provides the complete flow from content entry in WordPress, through the REST API, into the Vuex store, and finally to a React-friendly index page ready for linking off to full single-post pages.

Generating Post Pages the Nuxt Way

Nuxt makes creating dynamic post pages straightforward. Start by setting up a directory for your post template. In our case, we create a blog folder and place a page file inside with an underscore prefix to denote a dynamic segment:

index.vue

blog/

   _slug.vue
<script>
export default {
computed: {
  posts() {
    return this.$store.state.posts;
  }
},
created() {
  this.$store.dispatch("getPosts");
}
};
</script>

Within this page, we dispatch the getPosts action to load the data from the store (catering for direct visits to a single post URL). We then need to tell the page which post to display. We grab the current slug from this.$route.params.slug and use a computed property with a filter to locate the matching post in our stored data:

computed: {
  ...
  post() {
    return this.posts.find(el => el.slug === this.slug);
  }
},
data() {
  return {
    slug: this.$route.params.slug
  };
},

With the specific post object available, the template renders the title and the post's content. Since the content is an HTML string from the API, we use the v-html directive to ensure the markup is rendered correctly by the browser:

<template>
<main class="post individual">
  <h1>{{ post.title.rendered }}</h1>
  <section v-html="post.content.rendered"></section>
</main>
</template>
(Large preview)

Finally, we must instruct Nuxt to generate these dynamic routes during its static build. In nuxt.config.js, we add a function to the generate options. This function, which we'll call dynamicRoutes, will return an array of routes for Nuxt to pre-render:

generate: {
  routes: dynamicRoutes
},

To fetch the list of slugs, we add axios with yarn add axios and import it at the top of our script. We then define dynamicRoutes. This function builds an array by making an API call and mapping the result to the required URL structure:

import axios from "axios"
let dynamicRoutes = () => {
return axios
  .get("https://css-tricks.com/wp-json/wp/v2/posts?page=1&per_page=20")
  .then(res => {
    return res.data.map(post => `/blog/${post.slug}`)
  })
}

This generates an array of route objects. Nuxt uses this to create all the individual post pages as static files during the build process:

export default {
 generate: {
   routes: [
     '/blog/post-title-one',
     '/blog/post-title-two',
     '/blog/post-title-three'
   ]
 }
}

Adding Tag-Based Filtering

Let's enhance the application by allowing users to filter posts by tags. This same pattern applies to categories or any other WordPress taxonomy. Refer to the official WordPress REST API reference to discover everything you can query.

In the second version of the REST API, posts no longer include the tag names directly—only their IDs. To display names, we need an additional API call.

We'll create a server-side plugin to fetch all tags at build time, mirroring our approach for posts. This ensures they are instantly available to the end user (viva la JAMstack!):

export default async ({ store }) => {
  await store.dispatch("getTags")
}

Next, we add a getTags action. Its API call is similar to fetching posts, but it must include the tag IDs. The query parameter to filter posts by tags uses include followed by a comma-separated list of IDs:

https://css-tricks.com/wp-json/wp/v2/tags?include=1,2,3

To get that list of IDs, we use a .reduce() method on the array of posts to extract and aggregate all their tags properties:

async getTags({ state, commit }, posts) {
  if (state.tags.length) return
  let allTags = posts.reduce((acc, item) => {
    return acc.concat(item.tags);
  }, [])
  allTags = allTags.join()
  try {
    let tags = await fetch(
      `https://css-tricks.com/wp-json/wp/v2/tags?page=1&per_page=40&include=${allTags}`
    ).then(res => res.json())
    tags = tags.map(({ id, name }) => ({
      id, name
    }))
    commit("updateTags", tags)
  } catch (err) {
    console.log(err)
  }
}

Just like with posts, we need a place to store the tags data. We'll set up a tags property in the state and a corresponding mutation to update it:

export const state = () => ({
  posts: [],
  tags: []
})

export const mutations = {
  updatePosts: (state, posts) => {
    state.posts = posts
  },
  updateTags: (state, tags) => {
    state.tags = tags
  }
}

On our main index.vue page, we can now import the tags from the store and render them on screen:

computed: {
  tags() {
    return this.$store.state.tags;
  },
}

<aside>
    <h2>Categories</h2>
    <div class="tags-list">
      <ul>
        <li
           v-for="tag in tags"
          :key="tag.id">
            <a>{{ tag.name }}</a>
        </li>
      </ul>
    </div>
  </aside>

This will display a list of all available tags:

(Large preview)

To make these tags functional, we need to filter the displayed posts. Vue's computed properties make this simple. First, we define a local data property selectedTag, initially set to null:

In the template, we attach a click handler to each tag that calls an updateTag method. This method takes the tag ID as its argument. It either sets selectedTag to that ID, or resets it back to null if the same tag is selected again (effectively deselecting it). We also update our main v-for loop to iterate over a new sortedPosts computed property instead of the raw posts list. This computed property checks selectedTag: if it's null, it returns all posts; otherwise, it filters to return only those that contain the selected tag ID:

<template>
<div class="posts">
  <aside>
    <h2>Categories</h2>
    <div class="tags-list">
      <ul>
        <li
          @click="updateTag(tag)"
          v-for="tag in tags"
          :key="tag.id">
            <a>{{ tag.name }}</a>
        </li>
      </ul>
    </div>
  </aside>
</div>
</template>

<script>
export default {
data() {
  return {
    selectedTag: null
  }
},
methods: {
  updateTag(tag) {
    if (!this.selectedTag) {
      this.selectedTag = tag.id
    } else {
      this.selectedTag = null
    }
  }
},
 ...
};
</script>
<template>
<main>
    <h2>Posts</h2>
    <div class="post" v-for="post in sortedPosts" :key="post.id">
    </div>
  </main>
</template>
<script>
computed: {
  sortedPosts() {
    if (!this.selectedTag) return this.posts
    return this.posts.filter(el => el.tags.includes(this.selectedTag))
  }
},
</script>

As a final polish, we style the currently selected tag differently and add a user hint that it can be deselected:

<template>
<div class="posts">
  <aside>
    <h2>Categories</h2>
    <div class="tags-list">
      <ul>
        <li
          @click="updateTag(tag)"
          v-for="tag in tags"
          :key="tag.id"
          :class="[tag.id === selectedTag ? activeClass : '']">
            <a>{{ tag.name }}</a>
            <span v-if="tag.id === selectedTag">✕</span>
        </li>
      </ul>
    </div>
  </aside>
</div>
</template>

<script>
export default {
data() {
  return {
    selectedTag: null,
    activeClass: 'active'
  }
},
</script>

With that, the application is functionally complete. You get all the benefits of a powerful content management system like WordPress for content editors, combined with the performance and security advantages of a static JAMstack site. The frontend is now decoupled from the content source, opening up the modern JavaScript framework ecosystem for your development stack.

Deploy to Netlify buttonIf you'd prefer not to build from scratch, you can deploy a pre-configured template from the link above and adapt it for your specific needs. This article serves as a guide for understanding the underlying architecture.

For a deeper dive, check out related articles on modern WordPress patterns, using Astro Islands with a headless CMS, and alternative approaches to building SSGs.