Published: July 25 2022

Vue 3 + Pinia - User Registration and Login Example & Tutorial

Tutorial built with Vue 3.2.33 and Pinia 2.0.14

Other versions available:

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

  1. Install Node.js and npm from https://nodejs.org.
  2. Download or clone the project source code from https://github.com/cornflourblue/vue-3-pinia-registration-login-example
  3. Install all required npm packages by running npm install from the command line in the project root folder (where the package.json is located).
  4. Start the application by running npm run dev from the command line in the project root folder.
  5. 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.

  1. Install the .NET SDK from https://dotnet.microsoft.com/download.
  2. Download or clone the project source code from https://github.com/cornflourblue/dotnet-6-registration-login-api
  3. 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 message Now listening on: http://localhost:4000.
  4. 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.

  1. 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.
  2. Download or clone the project source code from https://github.com/cornflourblue/node-mssql-registration-login-api
  3. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  4. Start the api by running npm start from the command line in the project root folder, you should see the message Server listening on port 4000.
  5. 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.

  1. Install MongoDB Community Server from  https://www.mongodb.com/download-center.
  2. Run MongoDB, instructions are available on the install page for each OS at https://docs.mongodb.com/manual/administration/install-community/
  3. Download or clone the project source code from https://github.com/cornflourblue/node-mongo-registration-login-api
  4. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  5. Start the api by running npm start from the command line in the project root folder, you should see the message Server listening on port 4000.
  6. 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, the Layout 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:

 

Base (Global) CSS Styles

Path: /src/assets/base.css

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

Path: /src/components/Alert.vue

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

Path: /src/components/Nav.vue

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

Path: /src/helpers/fake-backend.js

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

Path: /src/_helpers/fetch-wrapper.js

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

Path: /src/router/account.routes.js

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:

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

Path: /src/router/users.routes.js

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:

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

Path: /src/router/index.js

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

Path: /src/stores/alert.store.js

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

Path: /src/stores/auth.store.js

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

Path: /src/stores/users.store.js

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

Path: /src/views/account/Layout.vue

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

Path: /src/views/account/Login.vue

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

Path: /src/views/account/Register.vue

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

Path: /src/views/users/AddEdit.vue

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

Path: /src/views/users/Layout.vue

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

Path: /src/views/users/List.vue

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

Path: /src/views/Home.vue

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

Path: /src/App.vue

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

Path: /src/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

Path: /.env

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

Path: /index.html

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

Path: /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

Path: /vite.config.js

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

On Twitter or RSS.


When I'm not coding...

Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!


Comments


Supported by