Vue 3 + Pinia - User Registration and Login Example & Tutorial
Tutorial built with Vue 3.2.33 and Pinia 2.0.14
Other versions available:
- Vue: Vue 2 + Vuex
- Angular: Angular 15/16, 14, 10, 9, 8, 7, 6, 2/5
- React: React 18 + Redux, React 17 + Recoil, React 16 Hooks + Redux, React 16 + Redux
- Next.js: Next.js 13 (App Router) + MongoDB, Next.js 13 (Pages Router) + MongoDB, Next.js 13 (Pages Router) + MySQL, Next.js 11
- AngularJS: AngularJS
- .NET: Blazor WebAssembly
In this tutorial we'll go through an example of how to build a simple user registration, login and user management (CRUD) application with Vue 3 and Pinia.
Vue 3 + Pinia Example App Overview
The example app contains the following routes:
/account/login
- public page for logging into the Vue 3 + Pinia app. On submit the page sends a POST request to the API to authenticate user credentials, on success the API returns a JWT token to make authenticated requests to secure API routes./account/register
- public page for registering a new user account with the app./
- secure home page containing a simple welcome message to the logged in user./users
- secure page displaying a list of all users fetched from a secure API endpoint using the JWT token received after successful login. The page includes options to add, edit or delete users./users/add
- secure page for adding a new user./users/edit/[id]
- secure page for editing an existing user. Both the/add
and/edit/[id]
routes are implemented by a single users add/edit Vue component.
Pinia State Management
Pinia is a new state management library built by the Vuejs core team that simplifies global state management, it is the successor to Vuex, requires much less code than Vuex and is the recommended state management library for Vue 3.
State and business logic are defined in Pinia using stores, each store can contain state, getters and actions. State defines the data managed by a store, getters return a value that is derived (computed) from state and/or other getters, and actions are methods used to execute business logic or asynchronous operations such as API calls. They are the equivalent of data
, computed
and methods
in traditional (Options API) Vue components.
You can define as many Pinia stores as you like and they are globally accessible throughout your Vue application. For more info on Pinia see https://pinia.vuejs.org/core-concepts.
Vue Composition API
Components in the example are built with the new Vue Composition API that comes with Vue 3, component logic is located within a <script setup>
block that shares the same scope as the template, so variables and functions declared in the block are directly accessible to the template. For more info on the Vue Composition API see https://vuejs.org/guide/extras/composition-api-faq.html.
VeeValidate Forms
The forms in the example are implemented with VeeValidate, a library for building, validating and handling forms in Vue.js. VeeValidate 4 was recently released and is compatible with Vue 3, the official docs are available at https://vee-validate.logaretm.com/v4.
Fake Backend API
The Vue 3 + Pinia example app runs with a fake backend by default to enable it to run completely in the browser without a real backend API (backend-less), to switch to a real backend API you just have to remove or comment out the 2 lines below the comment // setup fake backend
located in the main.js file (/src/main.js
). You can build your own API or hook it up with the .NET or Node.js API available (instructions below).
Code on GitHub
The tutorial project is available on GitHub at https://github.com/cornflourblue/vue-3-pinia-registration-login-example.
Here it is in action:(See on StackBlitz at https://stackblitz.com/edit/vue-3-pinia-registration-login-example)
Run the Vue 3 + Pinia Tutorial Example Locally
- Install Node.js and npm from https://nodejs.org.
- Download or clone the project source code from https://github.com/cornflourblue/vue-3-pinia-registration-login-example
- Install all required npm packages by running
npm install
from the command line in the project root folder (where the package.json is located). - Start the application by running
npm run dev
from the command line in the project root folder. - Open a browser and go to the application at
http://localhost:3000
Run the Vue 3 + Pinia App with a .NET API
For full details about the example .NET JWT Auth API see the post .NET 6.0 - User Registration and Login Tutorial with Example API. But to get up and running quickly just follow the below steps.
- Install the .NET SDK from https://dotnet.microsoft.com/download.
- Download or clone the project source code from https://github.com/cornflourblue/dotnet-6-registration-login-api
- Start the api by running
dotnet run
from the command line in the project root folder (where the WebApi.csproj file is located), you should see the messageNow listening on: http://localhost:4000
. - Back in the Vue 3 + Pinia example app, remove or comment out the 2 lines below the comment
// setup fake backend
located in the/src/main.js
file, then start the Vue app and it should now be hooked up with the .NET API.
Run the Vue 3 + Pinia App with a Node.js + MSSQL API
For full details about the example Node.js + MSSQL API see the tutorial Node.js + MS SQL Server - Simple API for Authentication, Registration and User Management. But to get up and running quickly just follow the below steps.
- Install MSSQL Server from https://www.microsoft.com/sql-server/sql-server-downloads (the Express edition is free) and ensure it is started. You can also run MSSQL in a Docker container, the official docker images for SQL Server are available at https://hub.docker.com/_/microsoft-mssql-server.
- Download or clone the project source code from https://github.com/cornflourblue/node-mssql-registration-login-api
- Install all required npm packages by running
npm install
ornpm i
from the command line in the project root folder (where the package.json is located). - Start the api by running
npm start
from the command line in the project root folder, you should see the messageServer listening on port 4000
. - Back in the Vue 3 + Pinia example app, remove or comment out the 2 lines below the comment
// setup fake backend
located in the/src/main.js
file, then start the Vue app and it should now be hooked up with the Node + MSSQL API.
Run the Vue 3 + Pinia App with a Node.js + MongoDB API
For full details about the example Node.js + MongoDB API see the tutorial NodeJS + MongoDB - Simple API for Authentication, Registration and User Management. But to get up and running quickly just follow the below steps.
- Install MongoDB Community Server from https://www.mongodb.com/download-center.
- Run MongoDB, instructions are available on the install page for each OS at https://docs.mongodb.com/manual/administration/install-community/
- Download or clone the project source code from https://github.com/cornflourblue/node-mongo-registration-login-api
- Install all required npm packages by running
npm install
ornpm i
from the command line in the project root folder (where the package.json is located). - Start the api by running
npm start
from the command line in the project root folder, you should see the messageServer listening on port 4000
. - Back in the Vue 3 + Pinia example app, remove or comment out the 2 lines below the comment
// setup fake backend
located in the/src/index.jsx
file, then start the Vue 3 + Pinia app and it should now be hooked up with the Node + Mongo API.
Vue 3 + Pinia Project Structure
The base project structure was generated with the official Vue project scaffolding tool create-vue by running the command npm init vue@latest
and following the prompts. For more info about generating a new Vue 3 project from the command line see https://vuejs.org/guide/quick-start.html.
The project source (/src
) is organised into the following folders:
- assets
Static assets such as CSS stylesheets and images. - components
Vue components used by pages or by other Vue components. - helpers
Anything that doesn't fit into the other folders and doesn't justify having its own folder. - router
Route configuration for all sections of the Vue 3 app. - stores
Pinia state stores that define global state and actions for the Vue app. - views
Vue components for the pages/views of the application. Each feature/section (accounts, users) is organised into its own subfolder, theLayout
component in each subfolder is the root component for that section. It contains the outer HTML for all pages in the folder and a<router-view></router-view>
for rendering the currently routed component.
The main.js file that bootstraps the app is located directly in the /src
folder, alongside the root App component for the Vue 3 application.
JavaScript files are organised with export
statements at/near the top so it's easy to see all exported modules when you open a file. Export statements are followed by functions and other implementation code for each JS module.
The index.js
file in each folder re-exports all of the modules from that folder so they can be imported using only the folder path instead of the full path to each module, and to enable importing multiple modules in a single import (e.g. import { Layout, Login, Register } from '@/views/account';
).
The alias @
is configured for the src
folder in the vite.config.js file, so import statements prefixed with @
will be relative to the src
folder of the project. This removes the need for long relative paths like import { MyComponent } from '../../../anotherFolder';
.
Click any of the below links to jump down to a description of each file along with it's code:
- src
- assets
- components
- helpers
- fake-backend.js
- fetch-wrapper.js
- index.js
- router
- stores
- alert.store.js
- auth.store.js
- users.store.js
- index.js
- views
- account
- Layout.vue
- Login.vue
- Register.vue
- index.js
- users
- AddEdit.vue
- Layout.vue
- List.vue
- index.js
- Home.vue
- index.js
- account
- App.vue
- main.js
- .env
- index.html
- package.json
- vite.config.js
Base (Global) CSS Styles
The base stylesheet file contains CSS styles that are applied globally throughout the Vue application, it is imported in the <style>
block of the App component.
.app-container {
min-height: 350px;
}
.btn-delete-user {
width: 40px;
text-align: center;
box-sizing: content-box;
}
Vue 3 Alert Component
The alert component renders the alert from the Pinia alert store with bootstrap CSS classes, if the Pinia alert
state variable contains a null
value nothing is rendered for the component.
The close button clears the alert by calling the alertStore.clear()
method. Alerts are automatically cleared on route change using the router.beforeEach()
hook in the vue router.
<script setup>
import { storeToRefs } from 'pinia';
import { useAlertStore } from '@/stores';
const alertStore = useAlertStore();
const { alert } = storeToRefs(alertStore);
</script>
<template>
<div v-if="alert" class="container">
<div class="m-3">
<div class="alert alert-dismissable" :class="alert.type">
<button @click="alertStore.clear()" class="btn btn-link close">×</button>
{{alert.message}}
</div>
</div>
</div>
</template>
Vue 3 Nav Component
The nav component displays the main menu bar in the example. The user
state property of the Pinia auth store is used to reactively show/hide the main nav bar when the user logs in/out of the application.
The authStore.logout()
method is called from the logout link to log the user out and redirect them to the login page.
<script setup>
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
</script>
<template>
<nav v-show="authStore.user" class="navbar navbar-expand navbar-dark bg-dark">
<div class="navbar-nav">
<router-link to="/" class="nav-item nav-link">Home</router-link>
<router-link to="/users" class="nav-item nav-link">Users</router-link>
<button @click="authStore.logout()" class="btn btn-link nav-item nav-link">Logout</button>
</div>
</nav>
</template>
Fake Backend
In order to run and test the Vue 3 + Pinia app without a real backend API, the example uses a fake backend that intercepts the HTTP requests from the Vue app and sends back "fake" responses. This is done by monkey patching the window.fetch()
function to return fake responses for a specific set of routes.
Monkey patching is a technique used to alter the behaviour of an existing function either to extend it or change the way it works. In JavaScript this is done by storing a reference to the original function in a variable and replacing the original function with a new custom function that (optionally) calls the original function before/after executing some custom code.
The fake backend is organised into a top level handleRoute()
function that checks the request url and method to determine how the request should be handled. For fake routes one of the below // route functions
is called, for all other routes the request is passed through to the real backend by calling the original fetch request function (realFetch(url, opts)
). Below the route functions there are // helper functions
for returning different response types and performing small tasks.
export { fakeBackend };
// array in local storage for registered users
const usersKey = 'vue-3-pinia-registration-login-example-users';
let users = JSON.parse(localStorage.getItem(usersKey)) || [];
function fakeBackend() {
let realFetch = window.fetch;
window.fetch = function (url, opts) {
return new Promise((resolve, reject) => {
// wrap in timeout to simulate server api call
setTimeout(handleRoute, 500);
function handleRoute() {
switch (true) {
case url.endsWith('/users/authenticate') && opts.method === 'POST':
return authenticate();
case url.endsWith('/users/register') && opts.method === 'POST':
return register();
case url.endsWith('/users') && opts.method === 'GET':
return getUsers();
case url.match(/\/users\/\d+$/) && opts.method === 'GET':
return getUserById();
case url.match(/\/users\/\d+$/) && opts.method === 'PUT':
return updateUser();
case url.match(/\/users\/\d+$/) && opts.method === 'DELETE':
return deleteUser();
default:
// pass through any requests not handled above
return realFetch(url, opts)
.then(response => resolve(response))
.catch(error => reject(error));
}
}
// route functions
function authenticate() {
const { username, password } = body();
const user = users.find(x => x.username === username && x.password === password);
if (!user) return error('Username or password is incorrect');
return ok({
...basicDetails(user),
token: 'fake-jwt-token'
});
}
function register() {
const user = body();
if (users.find(x => x.username === user.username)) {
return error('Username "' + user.username + '" is already taken')
}
user.id = users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
users.push(user);
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function getUsers() {
if (!isAuthenticated()) return unauthorized();
return ok(users.map(x => basicDetails(x)));
}
function getUserById() {
if (!isAuthenticated()) return unauthorized();
const user = users.find(x => x.id === idFromUrl());
return ok(basicDetails(user));
}
function updateUser() {
if (!isAuthenticated()) return unauthorized();
let params = body();
let user = users.find(x => x.id === idFromUrl());
// only update password if entered
if (!params.password) {
delete params.password;
}
// if username changed check if taken
if (params.username !== user.username && users.find(x => x.username === params.username)) {
return error('Username "' + params.username + '" is already taken')
}
// update and save user
Object.assign(user, params);
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function deleteUser() {
if (!isAuthenticated()) return unauthorized();
users = users.filter(x => x.id !== idFromUrl());
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
// helper functions
function ok(body) {
resolve({ ok: true, ...headers(), json: () => Promise.resolve(body) })
}
function unauthorized() {
resolve({ status: 401, ...headers(), json: () => Promise.resolve({ message: 'Unauthorized' }) })
}
function error(message) {
resolve({ status: 400, ...headers(), json: () => Promise.resolve({ message }) })
}
function basicDetails(user) {
const { id, username, firstName, lastName } = user;
return { id, username, firstName, lastName };
}
function isAuthenticated() {
return opts.headers['Authorization'] === 'Bearer fake-jwt-token';
}
function body() {
return opts.body && JSON.parse(opts.body);
}
function idFromUrl() {
const urlParts = url.split('/');
return parseInt(urlParts[urlParts.length - 1]);
}
function headers() {
return {
headers: {
get(key) {
return ['application/json'];
}
}
}
}
});
}
}
Fetch Wrapper
The fetch wrapper is a lightweight wrapper around the native browser fetch()
function used to simplify the code for making HTTP requests. It returns an object with methods for get
, post
, put
and delete
requests, it automatically handles the parsing of JSON data from responses, and throws an error if the HTTP response is not successful (!response.ok
). If the response is 401 Unauthorized
or 403 Forbidden
the user is automatically logged out of the Vue 3 + Pinia app.
The authHeader()
function is used to automatically add a JWT auth token to the HTTP Authorization header of the request if the user is logged in and the request is to the application API URL. The current logged in state of the user is retrieved from the Pinia auth store.
The handleResponse()
function parses the response and returns a JSON object. It automatically logs the user out if a 401 Unauthorized or 403 Forbidden response is returned from the API.
With the fetch wrapper a POST
request can be made by simply calling fetchWrapper.post(url, body)
. It's used in the example app by the auth store and users store.
import { useAuthStore } from '@/stores';
export const fetchWrapper = {
get: request('GET'),
post: request('POST'),
put: request('PUT'),
delete: request('DELETE')
};
function request(method) {
return (url, body) => {
const requestOptions = {
method,
headers: authHeader(url)
};
if (body) {
requestOptions.headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
}
return fetch(url, requestOptions).then(handleResponse);
}
}
// helper functions
function authHeader(url) {
// return auth header with jwt if user is logged in and request is to the api url
const { user } = useAuthStore();
const isLoggedIn = !!user?.token;
const isApiUrl = url.startsWith(import.meta.env.VITE_API_URL);
if (isLoggedIn && isApiUrl) {
return { Authorization: `Bearer ${user.token}` };
} else {
return {};
}
}
async function handleResponse(response) {
const isJson = response.headers?.get('content-type')?.includes('application/json');
const data = isJson ? await response.json() : null;
// check for error response
if (!response.ok) {
const { user, logout } = useAuthStore();
if ([401, 403].includes(response.status) && user) {
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
logout();
}
// get error message from body or default to response status
const error = (data && data.message) || response.status;
return Promise.reject(error);
}
return data;
}
Vue 3 Account Routes
The account routes define the routes for the /account
section of the Vue 3 app. The exported route configuration is imported into the root vue 3 router and passed to the createRouter()
function on startup.
There are three routes configured:
/account
- the base route simply redirects to/account/login
./account/login
- renders the login component./account/register
- renders the register component.
import { Layout, Login, Register } from '@/views/account';
export default {
path: '/account',
component: Layout,
children: [
{ path: '', redirect: 'login' },
{ path: 'login', component: Login },
{ path: 'register', component: Register }
]
};
Vue 3 Users Routes
The users routes define the routes for the /users
section of the Vue 3 app. The exported route configuration is imported into the root vue 3 router and passed to the createRouter()
function on startup.
There are three routes configured:
/users
- the base route displays a list of all users with the users list component./users/add
- renders the users add/edit component in add mode./users/edit/:id
- renders the users add/edit component in edit mode.
import { Layout, List, AddEdit } from '@/views/users';
export default {
path: '/users',
component: Layout,
children: [
{ path: '', component: List },
{ path: 'add', component: AddEdit },
{ path: 'edit/:id', component: AddEdit }
]
};
Vue 3 Router
The router defines the routes for the Vue 3 application and creates a new Vue Router instance with the createRouter()
function. The exported router
instance is imported into main.js where it is passed to the Vue app on startup.
The createRouter()
function is part of Vue Router v4 which is compatible with Vue 3, the previous version of the router (Vue Router v3) is compatible with Vue 2. For more info on what's changed in the new version of the Vue Router see https://next.router.vuejs.org/guide/migration/.
The home route maps the root path ('/'
) of the app to the Home component. The account routes and users routes are defined in separate files and included in the routes
property of the createRouter()
function. The last route is a catch-all redirect route that redirects any unmatched paths to the home page.
Unauthenticated users are prevented from accessing restricted pages by the navigation guard function passed to router.beforeEach()
.
The linkActiveClass: 'active'
parameter sets the active CSS class on <router-link>
components to "active"
to make the nav bar links in the example compatible with Bootstrap CSS. The default active class for a Vue router link component is "router-link-active"
.
For more information on Vue routing see https://router.vuejs.org/.
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore, useAlertStore } from '@/stores';
import { Home } from '@/views';
import accountRoutes from './account.routes';
import usersRoutes from './users.routes';
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
linkActiveClass: 'active',
routes: [
{ path: '/', component: Home },
{ ...accountRoutes },
{ ...usersRoutes },
// catch all redirect to home page
{ path: '/:pathMatch(.*)*', redirect: '/' }
]
});
router.beforeEach(async (to) => {
// clear alert on route change
const alertStore = useAlertStore();
alertStore.clear();
// redirect to login page if not logged in and trying to access a restricted page
const publicPages = ['/account/login', '/account/register'];
const authRequired = !publicPages.includes(to.path);
const authStore = useAuthStore();
if (authRequired && !authStore.user) {
authStore.returnUrl = to.fullPath;
return '/account/login';
}
});
Pinia Alert Store
The alert store contains Pinia state and actions for displaying global alert notifications. The alert
state property holds an alert notification object with type
and message
properties.
The success()
and error()
action methods update the alert in Pinia state which is then reactively rendered by the alert component. The clear()
action method resets the Pinia alert property to null
which removes it from the UI.
import { defineStore } from 'pinia';
export const useAlertStore = defineStore({
id: 'alert',
state: () => ({
alert: null
}),
actions: {
success(message) {
this.alert = { message, type: 'alert-success' };
},
error(message) {
this.alert = { message, type: 'alert-danger' };
},
clear() {
this.alert = null;
}
}
});
Pinia Auth Store
The auth store contains Pinia state and actions for authentication. The user
state property holds the current logged in user, it is initialized with the 'user'
object from local storage to support staying logged in between page refreshes and browser sessions, or null
if localStorage is empty. The returnUrl
is used to redirect the user to the previous url after successful login, it is set by the router before it redirects an unauthenticated user to the login page.
The Pinia login()
action method posts credentials to the API, on success the returned user object is stored in Pinia state and localStorage, and the router redirects to the return url or home page. On fail an alert notification is displayed with the error by calling alertStore.error(error)
.
The logout()
action method sets the user to null in Pinia state, removes it from localStorage and redirects to the login page.
import { defineStore } from 'pinia';
import { fetchWrapper } from '@/helpers';
import { router } from '@/router';
import { useAlertStore } from '@/stores';
const baseUrl = `${import.meta.env.VITE_API_URL}/users`;
export const useAuthStore = defineStore({
id: 'auth',
state: () => ({
// initialize state from local storage to enable user to stay logged in
user: JSON.parse(localStorage.getItem('user')),
returnUrl: null
}),
actions: {
async login(username, password) {
try {
const user = await fetchWrapper.post(`${baseUrl}/authenticate`, { username, password });
// update pinia state
this.user = user;
// store user details and jwt in local storage to keep user logged in between page refreshes
localStorage.setItem('user', JSON.stringify(user));
// redirect to previous url or default to home page
router.push(this.returnUrl || '/');
} catch (error) {
const alertStore = useAlertStore();
alertStore.error(error);
}
},
logout() {
this.user = null;
localStorage.removeItem('user');
router.push('/account/login');
}
}
});
Pinia Users Store
The users store contains Pinia state and actions for users in the Vue 3 app.
The users
state property is used to store all users fetched from the API (getAll()
). It defaults to an empty object and can hold one the following values:
{}
- initial state.{ loading: true }
- users are currently being fetched from the API.[{ ... }, { ... }, { ... }]
- array of users returned by the API.{ error: 'an error message' }
- request to the API failed and an error was returned.
The user
state property is used to store a single specific user fetched from the API (getById()
). It defaults to an empty object and can hold one the following values:
{}
- initial state.{ loading: true }
- user is currently being fetched from the API.{ ... }
- user object returned by the API.{ error: 'an error message' }
- request to the API failed and an error was returned.
The Pinia register(user)
action method sends a POST
request to the API to create a new user.
The getAll()
action method fetches the users from the API and updates the users
state property based on the result.
The getById(id)
action method fetches a specific user by id from the API and updates the user
state property based on the result.
The update(id, params)
action method sends a PUT
request to the API to update the included params
of the user with the specified id
. If the current logged in user updates their own record, the authStore.user
is also updated to reflect the changes.
The Pinia delete(id)
action method sends a DELETE
request to the API to delete the user with the specified id
.
The delete(id)
action method first updates the specified user in Pinia state with an isDeleting = true
property so the UI can display a spinner on the delete button, it then sends an HTTP DELETE
request to the API to delete the user. The deleted user is then removed from the Pinia users
array to remove it from the UI. If the current user deletes their own record they are automatically logged out of the app.
import { defineStore } from 'pinia';
import { fetchWrapper } from '@/helpers';
import { useAuthStore } from '@/stores';
const baseUrl = `${import.meta.env.VITE_API_URL}/users`;
export const useUsersStore = defineStore({
id: 'users',
state: () => ({
users: {},
user: {}
}),
actions: {
async register(user) {
await fetchWrapper.post(`${baseUrl}/register`, user);
},
async getAll() {
this.users = { loading: true };
try {
this.users = await fetchWrapper.get(baseUrl);
} catch (error) {
this.users = { error };
}
},
async getById(id) {
this.user = { loading: true };
try {
this.user = await fetchWrapper.get(`${baseUrl}/${id}`);
} catch (error) {
this.user = { error };
}
},
async update(id, params) {
await fetchWrapper.put(`${baseUrl}/${id}`, params);
// update stored user if the logged in user updated their own record
const authStore = useAuthStore();
if (id === authStore.user.id) {
// update local storage
const user = { ...authStore.user, ...params };
localStorage.setItem('user', JSON.stringify(user));
// update auth user in pinia state
authStore.user = user;
}
},
async delete(id) {
// add isDeleting prop to user being deleted
this.users.find(x => x.id === id).isDeleting = true;
await fetchWrapper.delete(`${baseUrl}/${id}`);
// remove user from list after deleted
this.users = this.users.filter(x => x.id !== id);
// auto logout if the logged in user deleted their own record
const authStore = useAuthStore();
if (id === authStore.user.id) {
authStore.logout();
}
}
}
});
Vue 3 Account Layout Component
The account layout component is the root component of the account section / feature, it contains the outer HTML for all /account
pages and a <router-view />
for rendering the currently routed component.
The auth store is used check if the user is already logged in when they try to access an accounts page so they can be automatically redirected to the home page ('/'
), since authenticated users have no use for any of the accounts pages.
<script setup>
import { useAuthStore } from '@/stores';
import { router } from '@/router';
// redirect home if already logged in
const authStore = useAuthStore();
if (authStore.user) {
router.push('/');
}
</script>
<template>
<div class="container">
<div class="row">
<div class="col-sm-8 offset-sm-2 mt-5">
<router-view />
</div>
</div>
</div>
</template>
Vue 3 Login Component
The login component contains a form built with the VeeValidate library that contains username and password fields for logging into the Vue 3 + Pinia app.
Form validation rules are defined with the Yup schema validation library which VeeValidate supports out of the box, for more info on Yup see https://github.com/jquense/yup.
The onSubmit()
method posts the user credentials to the API by calling authStore.login()
. On successful authentication the user auth
data is stored in Pinia global state by the login()
action method in the auth store, and the user is redirected to the home page. On fail the auth store displays an error alert notification using the alert store.
The Vue component template contains the form with input fields and validation messages. The form and fields are built with the VeeValidate <Form />
and <Field />
components which automatically hook into the validation rules (schema) based on the name of the field.
The form calls the onSubmit()
method when the form is submitted and valid. Validation rules are bound to the form with the validation-schema
prop, and validation errors are provided to the form template via the scoped slot v-slot="{ errors }"
. For more info on form validation with Vue 3 and VeeValidate see Vue 3 + VeeValidate - Form Validation Example (Composition API).
<script setup>
import { Form, Field } from 'vee-validate';
import * as Yup from 'yup';
import { useAuthStore } from '@/stores';
const schema = Yup.object().shape({
username: Yup.string().required('Username is required'),
password: Yup.string().required('Password is required')
});
async function onSubmit(values) {
const authStore = useAuthStore();
const { username, password } = values;
await authStore.login(username, password);
}
</script>
<template>
<div class="card m-3">
<h4 class="card-header">Login</h4>
<div class="card-body">
<Form @submit="onSubmit" :validation-schema="schema" v-slot="{ errors, isSubmitting }">
<div class="form-group">
<label>Username</label>
<Field name="username" type="text" class="form-control" :class="{ 'is-invalid': errors.username }" />
<div class="invalid-feedback">{{ errors.username }}</div>
</div>
<div class="form-group">
<label>Password</label>
<Field name="password" type="password" class="form-control" :class="{ 'is-invalid': errors.password }" />
<div class="invalid-feedback">{{ errors.password }}</div>
</div>
<div class="form-group">
<button class="btn btn-primary" :disabled="isSubmitting">
<span v-show="isSubmitting" class="spinner-border spinner-border-sm mr-1"></span>
Login
</button>
<router-link to="register" class="btn btn-link">Register</router-link>
</div>
</Form>
</div>
</div>
</template>
Vue 3 Register Component
The register component contains a simple registration form built with the VeeValidate library that contains fields for first name, last name, username and password.
Form validation rules are defined with the Yup schema validation library which VeeValidate supports out of the box, for more info on Yup see https://github.com/jquense/yup.
The onSubmit()
method posts the registration details to the API by calling usersStore.register()
. On successful registration the user is redirected to the login page ('/account/login'
) and a success alert notification is displayed with the alert store. On fail an error alert notification is displayed.
The Vue component template contains the form with input fields and validation messages. The form and fields are built with the VeeValidate <Form />
and <Field />
components which automatically hook into the validation rules (schema) based on the name of the field.
The form calls the onSubmit()
method when the form is submitted and valid. Validation rules are bound to the form with the validation-schema
prop, and validation errors are provided to the form template via the scoped slot v-slot="{ errors }"
. For more info on form validation with Vue 3 and VeeValidate see Vue 3 + VeeValidate - Form Validation Example (Composition API).
<script setup>
import { Form, Field } from 'vee-validate';
import * as Yup from 'yup';
import { useUsersStore, useAlertStore } from '@/stores';
import { router } from '@/router';
const schema = Yup.object().shape({
firstName: Yup.string()
.required('First Name is required'),
lastName: Yup.string()
.required('Last Name is required'),
username: Yup.string()
.required('Username is required'),
password: Yup.string()
.required('Password is required')
.min(6, 'Password must be at least 6 characters')
});
async function onSubmit(values) {
const usersStore = useUsersStore();
const alertStore = useAlertStore();
try {
await usersStore.register(values);
await router.push('/account/login');
alertStore.success('Registration successful');
} catch (error) {
alertStore.error(error);
}
}
</script>
<template>
<div class="card m-3">
<h4 class="card-header">Register</h4>
<div class="card-body">
<Form @submit="onSubmit" :validation-schema="schema" v-slot="{ errors, isSubmitting }">
<div class="form-group">
<label>First Name</label>
<Field name="firstName" type="text" class="form-control" :class="{ 'is-invalid': errors.firstName }" />
<div class="invalid-feedback">{{ errors.firstName }}</div>
</div>
<div class="form-group">
<label>Last Name</label>
<Field name="lastName" type="text" class="form-control" :class="{ 'is-invalid': errors.lastName }" />
<div class="invalid-feedback">{{ errors.lastName }}</div>
</div>
<div class="form-group">
<label>Username</label>
<Field name="username" type="text" class="form-control" :class="{ 'is-invalid': errors.username }" />
<div class="invalid-feedback">{{ errors.username }}</div>
</div>
<div class="form-group">
<label>Password</label>
<Field name="password" type="password" class="form-control" :class="{ 'is-invalid': errors.password }" />
<div class="invalid-feedback">{{ errors.password }}</div>
</div>
<div class="form-group">
<button class="btn btn-primary" :disabled="isSubmitting">
<span v-show="isSubmitting" class="spinner-border spinner-border-sm mr-1"></span>
Register
</button>
<router-link to="login" class="btn btn-link">Cancel</router-link>
</div>
</Form>
</div>
</div>
</template>
Vue 3 Users Add/Edit Component
The users add/edit component contains a form built with the VeeValidate library that is used for both adding and editing users. The component determines if it's in add or edit mode by checking if there's a user id
in the route parameters.
Form validation rules are defined with the Yup schema validation library and passed with the validation-schema
prop, and validation errors are provided to the form template via the scoped slot v-slot="{ errors }"
, for more info on Yup see https://github.com/jquense/yup.
The Vue component template contains the form with input fields and validation messages. The form and fields are built with the VeeValidate <Form />
and <Field />
components which automatically hook into the validation rules (schema) based on the name of the field.
The onSubmit
function is called when the form is submitted and valid, and either creates or updates a user depending on which mode it is in.
The form is in edit mode when there a user id route parameter (route.params.id
), otherwise it is in add mode. In edit mode the user details are fetched into Pinia state when the component loads by calling usersStore.getById(id)
and preloaded into the form fields via the :initial-values
prop.
<script setup>
import { Form, Field } from 'vee-validate';
import * as Yup from 'yup';
import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useUsersStore, useAlertStore } from '@/stores';
import { router } from '@/router';
const usersStore = useUsersStore();
const alertStore = useAlertStore();
const route = useRoute();
const id = route.params.id;
let title = 'Add User';
let user = null;
if (id) {
// edit mode
title = 'Edit User';
({ user } = storeToRefs(usersStore));
usersStore.getById(id);
}
const schema = Yup.object().shape({
firstName: Yup.string()
.required('First Name is required'),
lastName: Yup.string()
.required('Last Name is required'),
username: Yup.string()
.required('Username is required'),
password: Yup.string()
.transform(x => x === '' ? undefined : x)
// password optional in edit mode
.concat(user ? null : Yup.string().required('Password is required'))
.min(6, 'Password must be at least 6 characters')
});
async function onSubmit(values) {
try {
let message;
if (user) {
await usersStore.update(user.value.id, values)
message = 'User updated';
} else {
await usersStore.register(values);
message = 'User added';
}
await router.push('/users');
alertStore.success(message);
} catch (error) {
alertStore.error(error);
}
}
</script>
<template>
<h1>{{title}}</h1>
<template v-if="!(user?.loading || user?.error)">
<Form @submit="onSubmit" :validation-schema="schema" :initial-values="user" v-slot="{ errors, isSubmitting }">
<div class="form-row">
<div class="form-group col">
<label>First Name</label>
<Field name="firstName" type="text" class="form-control" :class="{ 'is-invalid': errors.firstName }" />
<div class="invalid-feedback">{{ errors.firstName }}</div>
</div>
<div class="form-group col">
<label>Last Name</label>
<Field name="lastName" type="text" class="form-control" :class="{ 'is-invalid': errors.lastName }" />
<div class="invalid-feedback">{{ errors.lastName }}</div>
</div>
</div>
<div class="form-row">
<div class="form-group col">
<label>Username</label>
<Field name="username" type="text" class="form-control" :class="{ 'is-invalid': errors.username }" />
<div class="invalid-feedback">{{ errors.username }}</div>
</div>
<div class="form-group col">
<label>
Password
<em v-if="user">(Leave blank to keep the same password)</em>
</label>
<Field name="password" type="password" class="form-control" :class="{ 'is-invalid': errors.password }" />
<div class="invalid-feedback">{{ errors.password }}</div>
</div>
</div>
<div class="form-group">
<button class="btn btn-primary" :disabled="isSubmitting">
<span v-show="isSubmitting" class="spinner-border spinner-border-sm mr-1"></span>
Save
</button>
<router-link to="/users" class="btn btn-link">Cancel</router-link>
</div>
</Form>
</template>
<template v-if="user?.loading">
<div class="text-center m-5">
<span class="spinner-border spinner-border-lg align-center"></span>
</div>
</template>
<template v-if="user?.error">
<div class="text-center m-5">
<div class="text-danger">Error loading user: {{user.error}}</div>
</div>
</template>
</template>
Vue 3 Users Layout Component
The users layout component is the root component of the users section / feature, it contains the outer HTML for all /users
pages and a <router-view />
for rendering the currently routed component.
<template>
<div class="p-4">
<div class="container">
<router-view />
</div>
</div>
</template>
Vue 3 Users List Component
The users list component displays a list of all users in the Vue 3 + Pinia tutorial app and contains buttons for adding, editing and deleting users. On component load all users are fetched into Pinia shared state by calling the getAll()
method of the user store. storeToRefs()
is called to get a reactive ref of the users
property so the component will automatically update/re-render when there are changes to users
.
The delete button calls the usersStore.delete()
method which first updates the user in Pinia state with an isDeleting:true
property which displays a loading spinner on the delete button while the HTTP DELETE request is pending. When the request is completed the user is removed from Pinia state which reactively removes the row from the UI.
<script setup>
import { storeToRefs } from 'pinia';
import { useUsersStore } from '@/stores';
const usersStore = useUsersStore();
const { users } = storeToRefs(usersStore);
usersStore.getAll();
</script>
<template>
<h1>Users</h1>
<router-link to="/users/add" class="btn btn-sm btn-success mb-2">Add User</router-link>
<table class="table table-striped">
<thead>
<tr>
<th style="width: 30%">First Name</th>
<th style="width: 30%">Last Name</th>
<th style="width: 30%">Username</th>
<th style="width: 10%"></th>
</tr>
</thead>
<tbody>
<template v-if="users.length">
<tr v-for="user in users" :key="user.id">
<td>{{ user.firstName }}</td>
<td>{{ user.lastName }}</td>
<td>{{ user.username }}</td>
<td style="white-space: nowrap">
<router-link :to="`/users/edit/${user.id}`" class="btn btn-sm btn-primary mr-1">Edit</router-link>
<button @click="usersStore.delete(user.id)" class="btn btn-sm btn-danger btn-delete-user" :disabled="user.isDeleting">
<span v-if="user.isDeleting" class="spinner-border spinner-border-sm"></span>
<span v-else>Delete</span>
</button>
</td>
</tr>
</template>
<tr v-if="users.loading">
<td colspan="4" class="text-center">
<span class="spinner-border spinner-border-lg align-center"></span>
</td>
</tr>
<tr v-if="users.error">
<td colspan="4">
<div class="text-danger">Error loading users: {{users.error}}</div>
</td>
</tr>
</tbody>
</table>
</template>
Vue 3 Home Component
The home component is displayed after signing in to the application, it shows a welcome message with the logged in user's name and a link to the users section.
The current user is accessed via the Pinia auth store.
<script setup>
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
const { user } = storeToRefs(authStore);
</script>
<template>
<div v-if="user">
<h1>Hi {{user.firstName}}!</h1>
<p>You're logged in with Vue 3 + Pinia & JWT!!</p>
<p><router-link to="/users">Manage Users</router-link></p>
</div>
</template>
Vue 3 App Component
The App
component is the root component of the example Vue 3 + Pinia app, it contains the main nav component which is only displayed for authenticated users, a global alert component, and a <router-view />
component for displaying the contents of each view based on the current route / path.
<script setup>
import { Nav, Alert } from '@/components';
import { useAuthStore } from '@/stores';
const authStore = useAuthStore();
</script>
<template>
<div class="app-container" :class="authStore.user && 'bg-light'">
<Nav />
<Alert />
<div class="container pt-4 pb-4">
<router-view />
</div>
</div>
</template>
<style>
@import '@/assets/base.css';
</style>
Vue 3 Main.js
The main.js file bootstraps the Vue application by mounting the App
component in the #app
div element defined in the main index html file.
Before starting the Vue app it imports and enables the fake backend api. To disable the fake backend simply remove the 2 lines below the comment // setup fake backend
.
Pinia support is added to the Vue app with the line app.use(createPinia())
.
Vue routes are configured with the call to app.use(router)
, routes are defined in router.js.
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
// setup fake backend
import { fakeBackend } from './helpers';
fakeBackend();
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
dotenv
The dotenv file contains environment variables used in the example Vue app, the API URL is used in the auth store and users store to send HTTP requests to the API.
Environment variables set in the dotenv file that are prefixed with VITE_
are accessible in the Vue app via import.meta.env.<variable name>
(e.g. import.meta.env.VITE_API_URL
). For more info on using environment variables in a Vue app built with Vite see https://vitejs.dev/guide/env-and-mode.html.
VITE_API_URL=http://localhost:4000
Main Index Html File
The main index.html file is the initial page loaded by the browser that kicks everything off. The Vite dev server runs the application in development mode with the command npm run dev
.
Rather than bundling the javascript modules like traditional front-end build tools, Vite takes advantage of native ES module support in modern browsers and adds extra features like hot module replacement to support development. For more info on the Vite dev server features see https://vitejs.dev/guide/features.html.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue 3 + Pinia - User Registration and Login Example & Tutorial</title>
<!-- bootstrap css -->
<link href="//netdna.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
Package.json
The package.json file contains project configuration information including package dependencies that get installed when you run npm install
and scripts that are executed when you run npm run dev
or npm run build
etc. Full documentation is available at https://docs.npmjs.com/files/package.json.
{
"name": "vue-3-pinia-registration-login-example",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5050",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"dependencies": {
"pinia": "^2.0.13",
"vee-validate": "^4.5.11",
"vue": "^3.2.33",
"vue-router": "^4.0.14",
"yup": "^0.32.11"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.3.1",
"eslint": "^8.5.0",
"eslint-plugin-vue": "^8.2.0",
"vite": "^2.9.5"
}
}
Vite.js Configuration
Configuration for Vite, the official front-end build tool and dev server used by Vue projects created with the create-vue command npm init vue@latest
.
The plugin @vitejs/plugin-vue
provides support for Vue 3 single file components (SFC).
The alias '@'
makes import statements prefixed with an at symbol (@
) relative to the /src
folder of the project.
For more info on Vite configuration options see https://vitejs.dev/config/.
import { fileURLToPath, URL } from 'url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
});
Need Some Vue 3 Help?
Search fiverr for freelance Vue 3 developers.
Follow me for updates
When I'm not coding...
Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!