Why Vue in a WordPress Theme?
WordPress's PHP templating system and Vue's JavaScript component model seem worlds apart. PHP has no native component concept, instead offering get_template_part for reusing template fragments. Vue is entirely component-based but only renders in the browser after the page loads. The two can coexist elegantly through Vue's inline templates (note: this approach uses Vue 2, as Vue 3 deprecated the inline-template attribute, though similar results are achievable with templates in script tags).
This pattern is particularly useful when building interactive content, like conditional forms or filtering systems, that must include SEO-relevant markup from the server, be controllable from WordPress's admin interface, and appear in multiple places with different configurations. Using inline templates, the browser receives server-rendered HTML, and Vue progressively enhances it—getting static content from PHP while retaining client-side interactivity.
The workflow requires a wrapper element around page content, with Vue loaded after the DOM via the footer. Register it with wp_enqueue_script, passing true for the in_footer argument and adding the Vue library as a dependency for 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);
To demonstrate the pattern, we can build a few features for a recipe blog: star-based ratings, a conditional feedback form, and filtering by rating. While each component lives in its own PHP file and is included with get_template_part, the entire page is registered as one Vue app, allowing components to share state via the root instance.
Creating a Star-Rating Component
A single post with content and a star rating can start as a base template. The approach follows a typical component structure for the star rating itself:
// 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')
})
The component template is in its own PHP file, and contains buttons (constructed via a loop) that include SVGs whose fill changes based on the selected rating.
<?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>
Component markup can then be included directly in templates with get_template_part, but plain custom elements won't validate as HTML. To make sure that the markup is valid, use the is directive:
<div is="star-rating" inline-template>...</div>
Values from the WordPress ecosystem are passed into components as props, such as a maximum rating set via the Advanced Custom Fields plugin:
<?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>
That desired value is then added to the component's prop definition, eliminating any magic numbers:
// 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
}
}
})
Knowing the post ID is similarly required to persist the rating. Again, it's passed through a component attribute:
<?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)
}
})
Since every server response is its own component instance, those attributes can be set on any declaration of <star-rating>—including in archive loops:
<?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; ?>
Extending Rating With a Feedback Form
A natural addition to a rating action is a brief feedback form. Following composition patterns, it makes sense for the form to live inside the star-rating section so it has access to the recipe's rating. The rating state itself should move into a parent recipe-content component, though, to keep things manageable. Inside the star rating, the rating prop is replaced by a standard Vue v-model, emitting input events rather than directly mutating local state.
// 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) }
},
})
In the template, the form appears after rating input, using the v-model directive and passing the post ID:
<?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>
The feedback form should work without JavaScript, so its markup retains a traditional PHP action. When available, JavaScript takes over through @submit.prevent, which cancels the native action and calls a Vue method that sends data via a fetch request. Each form element also has an ID suffixed with the post ID (recipe-id) so ids remain unique where multiple forms occur.
// 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 ?>">
<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>
A further enhancement adds server response feedback. A separate form-status component displays different messages for pending, successful, and failed requests. Since none of this markup needs to exist until a user submits, these might be defined in an HTML <template> tag, which defaults to nothing on the server. To keep this powerful tag inert until then, the top is wrapped in a v-if="false" block, ensuring Vue doesn't immediately pick it for rendering.
<?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>
// 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 },
}
})
The status component is declared globally through Vue.component registration, making it available for use within children components without explicit import:
<?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>
Filtering the Archive
With rating controlled and existing as data, additional interactive states like filtering can be added. The global filter value—a minimum rating—lives as data on the root Vue instance:
// main.js
// Same as before
new Vue({
el: document.getElementById('site-wrapper'),
data: {
minimumRating: 0
}
})
Controls bound to that state can be placed anywhere within the wrapper, such as an input at the top of an archive.
<?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; ?>
Recipe content needs a wrapper component to hide each recipe by rating. Because get_template_part cannot accept parameters, set a PHP global beforehand to conditionally apply the v-show directive, which displays or hides the item. If this component is used on single posts alongside archive pages, extra logic could distinguish between the two cases—indeed, the global flag might simply check is_archive() internally in its own include.
<?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; ?>
<?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>
Both layout and component use v-model for the rating now living in the recipe-content scope, keeping data consistent across all sub-components. The initial server response renders every recipe; one click sets a minimum, hiding all unrated posts. A clearer page configuration uses global PHP variables to pass richer contextual data from the server to these declarative components.
<?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>
What emerges across this feature set is a way to keep sane separation of concerns—content and markup in PHP, visible styles in CSS, behavior in JavaScript files—while still assembling rich, interactive widgets.



