How to Build Vue Components in a WordPress Theme

This tutorial was written for Vue 2 and uses "inline templates". Vue 3 has deprecated this feature, but there are alternatives (like putting your templates in script tags) that you could translate the idea to.

A few months ago, I was building a WordPress website that required a form with a bunch of fancy conditional fields. Different options and info were required for different choices you could make on the form, and our client needed complete control over all fields1. In addition, the form needed to appear in multiple places in each page, with slightly different configs.

And the header instance of the form needed to be mutually exclusive with the hamburger menu, so that opening one closes the other.

And the form had text content that was relevant to SEO.

And we wanted the server response to present some cute animated feedback.

(Phew.)

The whole thing felt complex enough that I didn't want to handle all that state manually. I remembered reading Sarah Drasner’s article "Replacing jQuery With Vue.js: No Build Step Necessary” which shows how to replace classic jQuery patterns with simple Vue micro-apps. That seemed like a good place to start, but I quickly realized that things would get messy on the PHP side of WordPress.

What I really needed were reusable components.

PHP → JavaScript

I love the static-first approach of Jamstack tools, like Nuxt, and was looking to do something similar here — send the full content from the server, and progressively enhance on the client side.

But PHP doesn’t have a built-in way to work with components. It does, however, support require-ing files inside other files 2. WordPress has an abstraction of require called get_template_part, that runs relative to the theme folder and is easier to work with. Dividing code into template parts is about the closest thing to components that WordPress provides 3.

Vue, on the other hand, is all about components — but it can only do its thing after the page has loaded and JavaScript is running.

The secret to this marriage of paradigms turns out to be the lesser-known Vue directive inline-template. Its great and wonderful powers allow us to define a Vue component using the markup we already have. It’s the perfect middle ground between getting static HTML from the server, and mounting dynamic DOM elements in the client.

First, the browser gets the HTML, then Vue makes it do stuff. Since the markup is built by WordPress, rather than by Vue in the browser, components can easily use any information that site administrators can edit. And, as opposed to .vue files (which are great for building more app-y things), we can keep the same separation of concerns we use for the whole site — structure and content in PHP, style in CSS, and functionality in JavaScript.

To show how this all fits together, we’re going to build a few features for a recipe blog. First, we’ll add a way for users to rate recipes. Then we’ll build a feedback form based on that rating. Finally, we’ll allow users to filter recipes, based on tags and rating.

We’ll build a few components that share state and live on the same page. To get them to play nicely together — and to make it easy to add additional components in the future — we’ll make the whole page our Vue app, and register components inside it.

Each component will live in its own PHP file and be included in the theme using get_template_part.

Laying the groundwork

There are a few special considerations to take into account when applying Vue to existing pages. The first is that Vue doesn't want you loading scripts inside it — it will send ominous errors to the console if you do. The easiest way to avoid this is to add a wrapper element around the content for every page, then load scripts outside of it (which is already a common pattern for all kinds of reasons). Something like this:

<?php /* header.php */ ?>
    
<body <?php body_class(); ?>>
<div id="site-wrapper">

<?php /* footer.php */ ?> 

</div> <!-- #site-wrapper -->
<?php wp_footer(); ?>

The second consideration is that Vue has to be called at the end of body element so that it will load after the rest of the DOM is available to parse. We’ll pass true as the fifth argument (in_footer) for the wp_enqueue_script function. Also, to make sure Vue is loaded first, we’ll register it as a dependency of the main script.

<?php // functions.php

add_action( 'wp_enqueue_scripts', function() {
  wp_enqueue_script('vue', get_template_directory_uri() . '/assets/js/lib/vue.js', null, null, true); // change to vue.min.js for production
  wp_enqueue_script('main', get_template_directory_uri() . '/assets/js/main.js', 'vue', null, true);
}

Finally, in the main script, we’ll initialize Vue on the site-wrapper element.

// main.js
    
new Vue({
  el: document.getElementById('site-wrapper')
})

The star rating component

Our single post template currently looks like this:

<?php /* single-post.php */ ?>

<article class="recipe">
  <?php /* ... post content */ ?>

  <!-- star rating component goes here -->
</article>

We’ll register the star rating component and add some logic to manage it:

// main.js

Vue.component('star-rating', {
  data () {
    return {
      rating: 0
    }
  },
  methods: {
    rate (i) { this.rating = i }
  },
  watch: {
    rating (val) {
      // prevent rating from going out of bounds by checking it to on every change
      if (val < 0) 
        this.rating = 0
      else if (val > 5) 
        this.rating = 5

      // ... some logic to save to localStorage or somewhere else
    }
  }
})

// make sure to initialize Vue after registering all components
new Vue({
  el: document.getElementById('site-wrapper')
})

We’ll write the component template in a separate PHP file. The component will comprise six buttons (one for unrated, 5 with stars). Each button will contain an SVG with either a black or transparent fill.

<?php /* components/star-rating.php */ ?>
    
<star-rating inline-template>
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="rating === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in 5)" @click="rate(i)">
      <svg><path d="..." :fill="rating >= i ? 'black' : 'transparent'"></svg>
    </button>
  </div>
</star-rating>

As a rule of thumb, I like to give a component’s top element a class name that is identical to that of the component itself. This makes it easy to reason between markup and CSS (e.g. <star-rating> can be thought of as .star-rating).

And now we’ll include it in our page template.

<?php /* single-post.php */ ?>
    
<article class="recipe">
  <?php /* post content */ ?>

  <?php get_template_part('components/star-rating'); ?>
</article>

All the HTML inside the template is valid and understood by the browser, except for <star-rating>. We can go the extra mile to fix that by using Vue’s is directive:

<div is="star-rating" inline-template>...</div>

Now let’s say that the maximum rating isn’t necessarily 5, but is controllable by the website’s editor using Advanced Custom Fields, a popular WordPress plugin that adds custom fields for pages, posts and other WordPress content. All we need to do is inject that value as a prop of the component that we’ll call maxRating:

<?php // components/star-rating.php

// max_rating is the name of the ACF field
$max_rating = get_field('max_rating');
?>
<div is="star-rating" inline-template :max-rating="<?= $max_rating ?>">
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="rating === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in maxRating) @click="rate(i)">
      <svg><path d="..." :fill="rating >= i ? 'black' : 'transparent'"></svg>
    </button>
  </div>
</div>

And in our script, let’s register the prop and replace the magic number 5:

// main.js

Vue.component('star-rating', {
  props: {
    maxRating: {
      type: Number,
      default: 5 // highlight
    }
  },
  data () {
    return {
      rating: 0
    }
  },
  methods: {
    rate (i) { this.rating = i }
  },
  watch: {
    rating (val) {
      // prevent rating from going out of bounds by checking it to on every change
      if (val < 0) 
        this.rating = 0
      else if (val > maxRating) 
        this.rating = maxRating

      // ... some logic to save to localStorage or somewhere else
    }
  }
})

In order to save the rating of the specific recipe, we’ll need to pass in the ID of the post. Again, same idea:

<?php // components/star-rating.php

$max_rating = get_field('max_rating');
$recipe_id = get_the_ID();
?>
<div is="star-rating" inline-template :max-rating="<?= $max_rating ?>" recipe-id="<?= $recipe_id ?>">
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="rating === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in maxRating) @click="rate(i)">
      <svg><path d="..." :fill="rating >= i ? 'black' : 'transparent'"></svg>
    </button>
  </div>
</div>
// main.js

Vue.component('star-rating', {
  props: {
    maxRating: { 
      // Same as before
    },
    recipeId: {
      type: String,
      required: true
    }
  },
  // ...
  watch: {
    rating (val) {
      // Same as before

      // on every change, save to some storage
      // e.g. localStorage or posting to a WP comments endpoint
      someKindOfStorageDefinedElsewhere.save(this.recipeId, this.rating)
    }
  },
  mounted () {
    this.rating = someKindOfStorageDefinedElsewhere.load(this.recipeId)    
  }
})

Now we can include the same component file in the archive page (a loop of posts), without any additional setup:

<?php // archive.php

if (have_posts()): while ( have_posts()): the_post(); ?>
<article class="recipe">
  <?php // Excerpt, featured image, etc. then:
  get_template_part('components/star-rating'); ?>
</article>
<?php endwhile; endif; ?>

The feedback form

The moment a user rates a recipe is a great opportunity to ask for more feedback, so let’s add a little form that appears right after the rating is set.

// main.js

Vue.component('feedback-form', {
  props: {
    recipeId: {
      type: String,
      required: true
    },
    show: { type: Boolean, default: false }
  },
  data () {
    return {
      name: '',
      subject: ''
      // ... other form fields
    }
  }
})
<?php // components/feedback-form.php

$recipe_id = get_the_ID();
?>
<div is="feedback-form" inline-template recipe-id="<?= $recipe_id ?>" v-if="showForm(recipe-id)">
  <form class="recipe-feedback-form" id="feedback-form-<?= $recipe_id ?>">
    <input type="text" :id="first-name-<?= $recipe_id ?>" v-model="name">
    <label for="first-name-<?= $recipe_id ?>">Your name</label>
    <?php /* ... */ ?>
  </form>
</div>

Notice that we’re appending a unique string (in this case, recipe-id) to each form element’s ID. This is to make sure they all have unique IDs, even if there are multiple copies of the form on the page.

So, where do we want this form to live? It needs to know the recipe’s rating so it knows it needs to open. We’re just building good ol’ components, so let’s use composition to place the form inside the <star-rating>:

<?php // components/star-rating.php

$max_rating = get_field('max_rating');
$recipe_id = get_the_ID();
?>
<div is="star-rating" inline-template :max-rating="<?= $max_rating ?>" recipe-id="<?= $recipe_id ?>">
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="rating === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in maxRating) @click="rate(i)">
      <svg><path d="..." :fill="rating >= i ? 'black' : 'transparent'"></svg>
    </button>
    <?php get_template_part('components/feedback-form'); ?>
  </div>
</div>

If at this point you’re thinking, “We really should be composing both components into a single parent component that handles the rating state,” then please give yourself 10 points and wait patiently.

A small progressive enhancement we can add to make the form usable without JavaScript, is to give it the traditional PHP action and then override it in Vue. We’ll use @submit.prevent to prevent the original action, then run a submit method to send the form data in JavaScript.

<?php // components/feedback-form.php

$recipe_id = get_the_ID();
?>
<div is="feedback-form" inline-template recipe-id="<?= $recipe_id ?>">
  <form action="path/to/feedback-form-handler.php" 
      @submit.prevent="submit"
      class="recipe-feedback-form" 
      id="feedback-form-<?= $recipe_id ?>">
    <input type="text" :id="first-name-<?= $recipe_id ?>" v-model="name">
    <label for="first-name-<?= $recipe_id ?>">Your name</label>
   <!-- ... -->
  </form>
</div>

Then, assuming we want to use fetch, our submit method can be something like this:

// main.js

Vue.component('feedback-form', {
  // Same as before

  methods: {
    submit () {
      const form = this.$el.querySelector('form')
      const URL = form.action
      const formData = new FormData(form)
      fetch(URL, {method: 'POST', body: formData})
        .then(result => { ... })
        .catch(error => { ... })
    }
  }
})

OK, so what do we want to do in .then and .catch? Let’s add a component that will show real-time feedback for the form’s submit status. First let’s add the state to track sending, success, and failure, and a computed property telling us if we’re pending results.

// main.js

Vue.component('feedback-form', {
  // Same as before

  data () {
    return {
      name: '',
      subject: '',
      // ... other form fields
      sent: false,
      success: false,
      error: null
    }
  },
  methods: {
    submit () {
      const form = this.$el.querySelector('form')
      const URL = form.action
      const formData = new FormData(form)
      fetch(URL, {method: 'POST', body: formData})
        .then(result => { 
          this.success = true
         })
        .catch(error => { 
          this.error = error
         })
      this.sent = true
    }
  }
})

To add the markup for each message type (success, failure, pending), we could make another component like the others we’ve built so far. But since these messages are meaningless when the server renders the page, we’re better off rendering them only when necessary. To do this we’re going to place our markup in a native HTML <template> tag, which doesn't render anything in the browser. Then we’ll reference it by id as our component’s template.

<?php /* components/form-status.php */ ?>

<template id="form-status-component" v-if="false">
  <div class="form-message-wrapper">
    <div class="pending-message" v-if="pending">
      <img src="<?= get_template_directory_uri() ?>/spinner.gif">
      <p>Patience, young one.</p>
    </div>
    <div class="success-message" v-else-if="success">
      <img src="<?= get_template_directory_uri() ?>/beer.gif">
      <p>Huzzah!</p>
    </div>
    <div class="success-message" v-else-if="error">
      <img src="<?= get_template_directory_uri() ?>/broken.gif">
      <p>Ooh, boy. It would appear that: {{ error.text }}</p>
    </div>
  </div
</template>

Why add v-if="false" at the top, you ask? It’s a tricky little thing. Once Vue picks up the HTML <template>, it will immediately think of it as a Vue <template> and render it. Unless, you guessed it, we tell Vue not to render it. A bit of a hack, but there you have it.

Since we only need this markup once on the page, we’ll include the PHP component in the footer.

<?php /* footer.php */ ?>

</div> <!-- #site-wrapper -->
<?php get_template_part('components/form-status'); ?>
<?php wp_footer(); ?>

Now we’ll register the component with Vue…

// main.js

Vue.component('form-status', {
  template: '#form-status-component'
  props: {
    pending: { type: Boolean, required: true },
    success: { type: Boolean, required: true },
    error: { type: [Object, null], required: true },
  }
})

…and call it inside our form component:

<?php // components/feedback-form.php

$recipe_id = get_the_ID();
?>
<div is="feedback-form" inline-template recipe-id="<?= $recipe_id ?>">
  <form action="path/to/feedback-form-handler.php" 
        @submit.prevent="submit"
        class="recipe-feedback-form" 
        id="feedback-form-<?= $recipe_id ?>">
    <input type="text" :id="first-name-<?= $recipe_id ?>" v-model="name">
    <label for="first-name-<?= $recipe_id ?>">Your name</label>
    <?php // ... ?>
  </form>
  <form-status v-if="sent" :pending="pending" :success="success" :error="error" />
</div>

Since we registered <form-status> using Vue.component, it's available globally, without specifically including it in the parent’s components: { }.

Filtering recipes

Now that users can personalize some bits of their experience on our blog, we can add all kinds of useful functionality. Specifically, let's allow users to set a minimum rating they want to see, using an input at the top of the page.
The first thing we need is some global state to track the minimum rating set by the user. Since we started off by initializing a Vue app on the whole page, global state will just be data on the Vue instance:

// main.js
// Same as before

new Vue({
  el: document.getElementById('site-wrapper'),
  data: {
    minimumRating: 0
  }
})

And where can we put the controls to change this? Since the whole page is the app, the answer is almost anywhere. For instance, at the top of the archive page:

<?php /* archive.php */ ?>

<label for="minimum-rating-input">Only show me recipes I've rated at or above:</label>
<input type="number" id="minimum-rating-input" v-model="minimumRating">

<?php if (have_posts()): while ( have_posts()): the_post(); ?>
<article class="recipe">
  <?php /* Post excerpt, featured image, etc. */ ?>

  <?php get_template_part('components/star-rating'); ?>
</article>
<?php endwhile; endif; ?>

As long as it’s inside our site-wrapper and not inside another component, it’ll just work. If we want, we could also build a filtering component that would change the global state. And if we wanted to get all fancy, we could even add Vuex to the mix (since Vuex can’t persist state between pages by default, we could add something like vuex-persist to use localStorage).

So, now we need to hide or show a recipe based on the filter. To do this, we’ll need to wrap the recipe content in its own component, with a v-show directive. It’s probably best to use the same component for both the single page and the archive page. Unfortunately, neither require nor get_template_part can pass parameters into the called file — but we can use global variables:

<?php /* archive.php */ ?>

<label for="minimum-rating-input">Only show me recipes I've rated at or above:</label>
<input type="number" id="minimum-rating-input" v-model="minimumRating">

<?php 
$is_archive_item = true;
if (have_posts()): while ( have_posts()): the_post();
  get_template_part('components/recipe-content');
endwhile; endif; ?>

We can then use $is_archive_item as a global variable inside the PHP component file to check if it is set and true. Since we won’t need to hide the content on the single post page, we’ll conditionally add the v-show directive.

<?php  // components/recipe-content.php

global $is_archive_item; ?>
<div is="recipe-content">
  <article class="recipe" 
    <?php if ($is_archive_item): ?>
       v-show="show"
    <?php endif; ?>
  >
    <?php
    if ($is_archive_item):
      the_excerpt();
    else
      the_content();
    endif;
    
    get_template_part('components/star-rating');
    ?>
  </article>
</div>

In this specific example, we could have also tested with is_archive() inside the component, but in most cases we’ll need to set explicit props.

We’ll need to move the rating state and logic up into the <recipe-content> component so it can know if it needs to hide itself. Inside <star-rating>, we’ll make a custom v-model by replacing rating with value, and this.rating = i with $emit('input', i) as well . So our component registration will now look like this:

// main.js

Vue.component('recipe-content', {
  data () {
    rating: 0
  },
  watch: {
    rating (val) {
      // ...
    }
  },
  mounted () {
    this.rating = someKindOfStorageDefinedElsewhere.load(this.recipeId)    
  }
})

Vue.component('star-rating', {
  props: {
    maxRating: { /* ... */ },
    recipeId: { /* ... */ },
    value: { type: Number, required: true }
  },
  methods: {
    rate (i) { this.$emit('input', i) }
  },
})

We’ll add v-model in star-rating.php and change rating to value. In addition, we can now move the <feedback-form> up into <recipe-content>:

<?php // components/star-rating.php

$max_rating = get_field('max_rating');
$recipe_id = get_the_ID();
?>
<div is="star-rating" 
  inline-template 
  :max-rating="<?= $ max_rating ?>" 
  recipe-id="<?= $recipe_id ?>" 
  v-model="value"
>
  <div class="star-rating">
    <p>Rate recipe:</p>
    <button @click="rate(0)">
      <svg><path d="..." :fill="value === 0 ? 'black' : 'transparent'"></svg>
    </button>
    <button v-for="(i in maxRating) @click="rate(i)">
      <svg><path d="..." :fill="value >= i ? 'black' : 'transparent'"></svg>
    </button>
  </div>
</div>
<?php // components/recipe-content.php

global $is_archive_item; ?>
<div is="recipe-content">
  <article class="recipe" 
    <?php if ($is_archive_item): ?>
       v-show="show"
    <?php endif; ?>
  >
    
    <?php
    if ($is_archive_item):
      the_excerpt();
    else
      the_content();
    endif;
    
    get_template_part('components/star-rating');
    get_template_part('components/feedback-form');
    ?>
  </article>
</div>

Now everything is set up so the initial render shows all recipes, and then the user can filter them based on their rating. Moving forward, we could add all kinds of parameters to filter content. And it doesn’t have to be based on user input — we can allow filtering based on the content itself (e.g. number of ingredients or cooking time) by passing the data from PHP to Vue.

Conclusion

Well, that was a bit of a long ride, but look at what we’ve built: independent, composable, maintainable, interactive, progressively enhanced components in our WordPress theme. We brought together the best of all worlds!

I’ve been using this approach in production for a while now, and I love the way it allows me to reason about the different parts of my themes. I hope I’ve inspired you to try it out too.


  1. Of course, two days before launch, the client’s legal department decided they don't want to collect all that info. Currently the live form is but a shadow of its development self.
  2. Fun fact: Rasmus Lerdorf said that his original intent was for PHP to be templating only, with all business logic handled in C. Let that sink in for a moment. Then clear an hour from your schedule and watch the whole talk.
  3. There are third-party WordPress templating engines that can compile down to optimized PHP. Twig, for example, comes to mind. We’re trying to go the reverse route and send vanilla PHP to be handled by JavaScript.