Update Nov 2022: This tutorial uses Vue 2. If you prefer Vue 3, check out my WP Vue Boilerplate that comes with Registration and ForgotPassword.
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 project in “/my-app” folder.
vue create my-app
This tutorial uses Vue 2. But feel free to use Vue 3 if you know which part to change:
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
I assume you have an existing WordPress installation, either a local or live one. If you don’t, create localhost with LOCAL.
Install this plugin in your WordPress: JWT Authentication.
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
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.
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.
Thank you for reading, feel free to ask any question in the comment below.
Thank you Henner, I have been looking for a working approach! I pulled your finished code from github. The login worked just fine.
But the posts don't load. I get a 401 (unauthorized) executing the wpApi.get('/posts?_embed'). Any ideas?
best regards Martin
Hi, have you changed the environment variable at
.env.development
?Yes, I have. I couldn't have logged in with my credentials successfully otherwise, I believe.
Hi Martin, I just checked my repo and everything working as intended. Maybe you're using older WP version that doesn't support
?_embed
parameter?Try going directly to http://yoursite.test/wp-json/wp/v2/posts?_embed and see whether it returns the correct API
Hi Henner, yes, when I'm logged in my wp installation I get an Api response.
I added a console.log() in your helpers.js:
// If logged in, add Authentication Header to the API call const token = localStorage.getItem('token'); console.log("token from my wp: " + token) if (token) { const addTokenHeader = (config) => { config.headers.Authorization = token ?
Bearer ${token}
: ''; return config; };=> The returened token has a value of 'null'
What am I missing here?
Thank you friend ! You saved my life !