Headless WordPress is more powerful than most people thought. The API is easy to customize and most people are already familiar with the admin UI.
One thing I had trouble with is integrating Login authentication with Vue. So I will share what I have learned here:
TABLE OF CONTENT
- Vue Installation
- WP Installation
- Project Setup
- Router
- Login UI
- Store
- Environment Variable
- Finishing
- Try it Out
TLDR: Read the finished code from my Github.
1. Vue Installation
Let’s start by generating the boilerplate files. If you don’t have Vue CLI installed, run this:
npm install -g @vue/cli
Then run this command to create a new Vue project inside “my-app” folder:
vue create my-app

After the boilerplate is generated, modify package.json
to add axios, vue-router, vuex, node-sass, and sass-loader:
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"axios": "^0.21.1",
"core-js": "^3.6.5",
"vue": "^2.6.11",
"vue-router": "^3.5.2",
"vuex": "^3.4.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.13",
"@vue/cli-plugin-eslint": "~4.5.13",
"@vue/cli-service": "~4.5.13",
"babel-eslint": "^10.1.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.6.11",
"node-sass": "^4.12.0",
"sass-loader": "^8.0.2"
},
"babel": {
"presets": [
"@vue/cli-plugin-babel/preset"
]
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
Finally, run this command to install all packages:
npm install
2. WP Installation
Install this plugin in your WordPress: JWT Authentication.
I assume you have an existing WordPress installation, either a local or live one. If you don’t, create localhost with LOCAL.
Then add this to your wp-config:
define('JWT_AUTH_CORS_ENABLE', true);
define('JWT_AUTH_SECRET_KEY', 'insert-random-hash');
You can get the random hash by picking any from https://api.wordpress.org/secret-key/1.1/salt/
3. Project Setup
We will create these 7 new files:
src/router.js
– handles URL routing and permission.src/UserLogin.vue
– Login page.src/components/Loading.vue
– Loading animation.src/store.js
– handles global state like “isLoggedIn” or “isAdmin”.src/Home.vue
– Plain homepage just to see if we successfully logged in..env.development
and.env.production
– Environment variable.
Now, your project structure looks like this:
my-app/
├── public/...
├── src/
│ ├── assets/...
│ ├── components/
│ │ ├── Loading.vue
│ ├── App.vue
│ ├── Home.vue
│ ├── main.js
│ ├── router.js
│ ├── store.js
│ └── UserLogin.vue
├── .env.development
├── .env.production
├── .gitignore
├── README.md
└── package.json
4. Router
There will be two primary functions of router.js
:
- Manage URL – going to /login will render
UserLogin.vue
. - Manage Permission – If we’re not logged in, we can’t access Home.
This is the code: (all of the important parts are commented on)
import Vue from 'vue';
import VueRouter from 'vue-router';
import store from './store';
import Home from './Home.vue';
import UserLogin from './UserLogin.vue';
Vue.use(VueRouter);
const routes = [
{
path: '/',
name: 'Home',
component: Home,
meta: {
title: 'Home',
},
},
{
path: '/login',
name: 'UserLogin',
component: UserLogin,
meta: {
title: 'Login',
noAuthRequired: true,
},
},
];
const router = new VueRouter({
mode: 'history',
base: '/',
routes,
scrollBehavior() {
document.getElementById('app').scrollIntoView({ behavior: 'smooth' });
},
});
// Set SEO metatag
router.beforeEach((to, from, next) => {
document.title = `${to.meta.title} | My App`;
next();
});
// Logged-in state check
router.beforeEach((to, from, next) => {
// redirect to Login page if auth required but not logged in
if (!to.meta.noAuthRequired && !store.state.isLoggedIn) {
next({
name: 'UserLogin',
query: {
redirectTo: to.name,
message: 'Please login to visit that page',
},
});
}
next();
});
export default router;
5. Login UI
We will create a simple interface with submit listener.
Here is the UserLogin.vue file:
<template>
<div class="form-wrapper">
<!-- Error message -->
<div v-if="message" class="form-message is-error-message">
{{ message }}
</div>
<!-- When redirected from secured page -->
<div v-else-if="$route.query.message" class="form-message">
{{ $route.query.message }}
</div>
<!-- Main form -->
<form class="form" @submit.prevent="login">
<label>
<span>Username</span>
<input
v-model.trim="username"
type="text"
placeholder="username or email"
>
</label>
<label>
<span>Password</span>
<input
v-model.trim="password"
type="password"
>
</label>
<button type="submit">
Log In
</button>
</form>
<Loading v-if="isLoading" />
</div>
</template>
<script>
import Loading from './components/Loading.vue';
export default {
name: 'UserLogin',
components: {
Loading,
},
data() {
return {
username: '',
password: '',
message: '',
isLoading: false,
};
},
methods: {
async login() {
this.message = '';
this.isLoading = true;
try {
// Call the login function in store.js
await this.$store.dispatch('login', {
username: this.username,
password: this.password,
});
this.isLoading = true;
// if redirected to login from secured page, redirect back
if (this.$route.query.redirectTo) {
this.$router.push({ name: this.$route.query.redirectTo });
}
// else redirect to Home
else {
this.$router.push({ name: 'Home' });
}
} catch (error) {
this.isLoading = false;
this.message = 'Email or password is wrong';
}
}
}
}
</script>
<style lang="sass">
.form-wrapper
max-width: 360px
margin: 3rem auto
.form-message
padding: 0.25rem
margin-bottom: 1rem
box-shadow: 0 0 10px 0 rgba(black, .1)
&.is-error-message
color: red
.form
padding: 1rem
border: 1px solid gray
label
display: block
margin-bottom: 1rem
input
width: 100%
padding: 0.5rem
span
display: block
text-transform: uppercase
font-size: smaller
button
padding: 0.5rem 1rem
background-color: green
color: white
</style>
Notice the $store.dispatch()
in the login listener? This is calling the function inside store.js
. We will implement that later.
We also added Loading animation by importing Loading.vue. Here’s the code:
<template>
<div class="loading">
<span />
</div>
</template>
<script>
export default {
name: 'Loading',
};
</script>
<style lang="sass" scoped>
.loading
top: 0
left: 0
z-index: 101
height: 100%
width: 100%
background-color: rgba(black, .5)
position: fixed
display: flex
justify-content: center
align-items: center
@keyframes spin
to
transform: rotateZ(360deg)
span
display: block
width: 60px
height: 60px
margin: 0 auto
border: 3px solid transparent
border-top-color: #fff
border-bottom-color: #fff
border-radius: 50%
animation: spin ease 1000ms infinite
</style>
6. Store
Store is a Vuex feature to manage global variables and functions. It consisted of three parts:
- state – The global variables.
- mutations – Functions to modify the global variables.
- actions – The global functions.
Here’s our store.js
file:
import Vue from 'vue';
import Vuex from 'vuex';
import axios from 'axios';
Vue.use(Vuex);
const mutations = {
cacheUser(state, { token, email, displayName }) {
state.isLoggedIn = true;
state.userToken = token;
state.userEmail = email;
state.userDisplayName = displayName;
},
deleteUserCache(state) {
state.isLoggedIn = false;
state.userToken = '';
state.userEmail = '';
state.userDisplayName = '';
},
};
const actions = {
async login({ commit }, payload) {
const response = await axios.post(`${process.env.VUE_APP_API_URL}/jwt-auth/v1/token`, {
username: payload.username,
password: payload.password,
});
const data = response.data;
localStorage.setItem('isLoggedIn', true);
localStorage.setItem('token', data.token);
localStorage.setItem('displayName', data.user_display_name);
localStorage.setItem('email', data.user_email);
// call cacheUser() from mutations
await commit('cacheUser', {
token: data.token,
email: data.user_email,
displayName: data.user_display_name,
});
},
async logout({ commit }) {
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('token');
localStorage.removeItem('email');
localStorage.removeItem('displayName');
commit('deleteUserCache');
},
/**
* Check if user if logged in
*/
async checkLoginState({ commit }) {
const token = localStorage.getItem('token');
// if no token, empty the loggedIn cache
if (!token) {
await commit('deleteUserCache');
return false;
}
// if has token, check if it's still valid
try {
await axios.post(
`${process.env.VUE_APP_API_URL}/jwt-auth/v1/token/validate`,
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
// if still valid, cache it again
await commit('cacheUser', {
token,
email: localStorage.getItem('email'),
displayName: localStorage.getItem('displayName'),
});
return true;
} catch (error) {
localStorage.setItem('token', '');
return false;
}
},
}
const store = {
// This is global data, use mutations and actions to change this value.
state: {
isAdmin: false,
isLoggedIn: localStorage.getItem('isLoggedIn') === 'true',
userToken: localStorage.getItem('token') || '',
userEmail: localStorage.getItem('email') || '',
userDisplayName: localStorage.getItem('displayName') || '',
},
mutations,
actions,
modules: {
},
};
export default new Vuex.Store(store);
7. Environment Variable
Check out the login()
function above. The API call is using environment variable for the domain name.
You set this in .env.development
and .env.production
:
VUE_APP_API_URL=http://mysite.test/wp-json
VUE_APP_API_URL=http://mysite.com/wp-json
Note: The variable name has to be prefixed with VUE_APP.
8. Finishing
We are left with 3 more files to customize:
Home.vue
– App dashboard. But for this tutorial, we only put sample text.App.vue
– Contains site layout like Header and Footer.main.js
– Entry point
Here’s Home.vue: (nothing special, just plain text)
<template>
<div class="page">
<h1>Hello World</h1>
<p>
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Corporis nisi aliquid sunt culpa quia dolores porro repellat a magni aliquam, quo consectetur vero labore minus accusantium ducimus!
</p>
<p>
Ratione consectetur voluptatibus dolorum, facilis rerum maxime architecto magni? Corporis fuga necessitatibus qui mollitia! Nesciunt eligendi doloribus eum quos sapiente officia ex architecto?
</p>
</div>
</template>
<script>
export default {
name: 'Home',
data() {
return {};
},
}
</script>
<style lang="sass">
.page
max-width: 1000px
margin: 2rem auto
</style>
App.vue contains Header and Footer. The header has a logout link, so we will implement that:
<template>
<div id="app">
<header class="main-header">
<router-link :to="{ name: 'Home' }">
<img alt="Logo" src="./assets/logo.png">
</router-link>
<nav v-if="$store.state.isLoggedIn">
<!-- Show menu navigation -->
<a href="#logout" @click.prevent="logout">
Logout
</a>
</nav>
</header>
<router-view />
<footer class="main-footer">
© Copyright My App - All rights reserved
</footer>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {};
},
// check if auth token is still valid
async created() {
// only check valid if coming from pages that require auth
if (this.$route.meta.noAuthRequired) {
return;
}
const isValid = await this.$store.dispatch('checkLoginState');
if (!isValid) {
this.$router.push({
name: 'UserLogin',
query: {
redirectTo: this.$route.name,
},
});
}
},
methods: {
async logout() {
await this.$store.dispatch('logout');
this.$router.push({ name: 'UserLogin' });
}
}
}
</script>
<style lang="sass">
*,
*::before,
*::after
box-sizing: border-box
#app
font-family: Avenir, Helvetica, Arial, sans-serif
-webkit-font-smoothing: antialiased
-moz-osx-font-smoothing: grayscale
color: #2c3e50
margin-top: 60px
.main-header
display: flex
justify-content: center
max-width: 1000px
margin: 0 auto 2rem
padding: 0.25rem 0
border-bottom: 1px solid
img
max-height: 60px
nav
margin-left: auto
.main-footer
max-width: 1000px
margin: 3rem auto 0
padding: 0.25rem 0
border-top: 1px solid
text-align: center
font-size: smaller
</style>
Finally, main.js is your entry point. We need to import our router and store implementation here:
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';
Vue.config.productionTip = false;
let app;
if (!app) {
new Vue({
router,
store,
render: (h) => h(App),
}).$mount('#app');
}
9. Try it Out
Make sure the JWT plugin is activated, constants in wp-config are set, and the environment variable in your Vue app is correct.
Then run this command to launch the Vue server:
npm run dev
Open the localhost URL as shown in the command result:

Since you’re not logged in, you will be redirected to the Login page:

After logging in, you will be redirected back to Home:

Conclusion
You will learn a lot by converting your old app into Vue. I believe this is the best way to practice.
Also if the tutorial above isn’t working, you can cross-check it with our finished code on Github. We might miss something while writing the blog.
I was planning to add Register and Forgot Password functions. But the tutorial is already very long, so I will keep it for the future.
Thank you for reading, feel free to ask any question in the comment below.