Build a User Management App with Ionic Vue
This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:
- Supabase Database - a Postgres database for storing your user data and Row Level Security so data is protected and users can only access their own information.
- Supabase Auth - allow users to sign up and log in.
- Supabase Storage - allow users to upload a profile photo.

If you get stuck while working through this guide, refer to the full example on GitHub.
Project setup
Before you start building you need to set up the Database and API. You can do this by starting a new Project in Supabase and then creating a "schema" inside the database.
Create a project
- Create a new project in the Supabase Dashboard.
- Enter your project details.
- Wait for the new database to launch.
Set up the database schema
Now set up the database schema. You can use the "User Management Starter" quickstart in the SQL Editor, or you can copy/paste the SQL from below and run it.
- Go to the SQL Editor page in the Dashboard.
- Click User Management Starter under the Community > Quickstarts tab.
- Click Run.
You can pull the database schema down to your local project by running the db pull command. Read the local development docs for detailed instructions.
1supabase link --project-ref <project-id>2# You can get <project-id> from your project's dashboard URL: https://supabase.com/dashboard/project/<project-id>3supabase db pullGet API details
Now that you've created some database tables, you are ready to insert data using the auto-generated API.
To do this, you need to get the Project URL and key. Get the URL from the API settings section of a project and the key from the the API Keys section of a project's Settings page.
Changes to API keys
Supabase is changing the way keys work to improve project security and developer experience. You can read the full announcement, but in the transition period, you can use both the current anon and service_role keys and the new publishable key with the form sb_publishable_xxx which will replace the older keys.
To get the key values, open the API Keys section of a project's Settings page and do the following:
- For legacy keys, copy the
anonkey for client-side operations and theservice_rolekey for server-side operations from the Legacy API Keys tab. - For new keys, open the API Keys tab, if you don't have a publishable key already, click Create new API Keys, and copy the value from the Publishable key section.
Building the app
Let's start building the Vue app from scratch.
Initialize an Ionic Vue app
We can use the Ionic CLI to initialize an app called supabase-ionic-vue:
1npm install -g @ionic/cli2ionic start supabase-ionic-vue blank --type vue3cd supabase-ionic-vueThen let's install the only additional dependency: supabase-js
1npm install @supabase/supabase-jsAnd finally we want to save the environment variables in a .env.
All we need are the API URL and the key that you copied earlier.
1VITE_SUPABASE_URL=YOUR_SUPABASE_URL2VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEYNow that we have the API credentials in place, let's create a helper file to initialize the Supabase client. These variables will be exposed on the browser, and that's completely fine since we have Row Level Security enabled on our Database.
1import { createClient } from '@supabase/supabase-js';23const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string;4const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY as string;56export const supabase = createClient(supabaseUrl, supabasePublishableKey);Set up a login route
Let's set up a Vue component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
1<template>2 <ion-page>3 <ion-header>4 <ion-toolbar>5 <ion-title>Login</ion-title>6 </ion-toolbar>7 </ion-header>89 <ion-content>10 <div class="ion-padding">11 <h1>Supabase + Ionic Vue</h1>12 <p>Sign in via magic link with your email below</p>13 </div>14 <ion-list inset="true">15 <form @submit.prevent="handleLogin">16 <ion-item>17 <ion-label position="stacked">Email</ion-label>18 <ion-input v-model="email" name="email" autocomplete type="email"></ion-input>19 </ion-item>20 <div class="ion-text-center">21 <ion-button type="submit" fill="clear">Login</ion-button>22 </div>23 </form>24 </ion-list>25 <p>{{ email }}</p>26 </ion-content>27 </ion-page>28</template>2930<script lang="ts">31 import { supabase } from '../supabase'32 import {33 IonContent,34 IonHeader,35 IonPage,36 IonTitle,37 IonToolbar,38 IonList,39 IonItem,40 IonLabel,41 IonInput,42 IonButton,43 toastController,44 loadingController,45 } from '@ionic/vue'46 import { defineComponent, ref } from 'vue'4748 export default defineComponent({49 name: 'LoginPage',50 components: {51 IonContent,52 IonHeader,53 IonPage,54 IonTitle,55 IonToolbar,56 IonList,57 IonItem,58 IonLabel,59 IonInput,60 IonButton,61 },62 setup() {63 const email = ref('')64 const handleLogin = async () => {65 const loader = await loadingController.create({})66 const toast = await toastController.create({ duration: 5000 })6768 try {69 await loader.present()70 const { error } = await supabase.auth.signInWithOtp({ email: email.value })7172 if (error) throw error7374 toast.message = 'Check your email for the login link!'75 await toast.present()76 } catch (error: any) {77 toast.message = error.error_description || error.message78 await toast.present()79 } finally {80 await loader.dismiss()81 }82 }83 return { handleLogin, email }84 },85 })86</script>Account page
After a user is signed in we can allow them to edit their profile details and manage their account.
Let's create a new component for that called Account.vue.
1<template>2 <ion-page>3 <ion-header>4 <ion-toolbar>5 <ion-title>Account</ion-title>6 </ion-toolbar>7 </ion-header>89 <ion-content>10 <form @submit.prevent="updateProfile">11 <ion-item>12 <ion-label>13 <p>Email</p>14 <p>{{ user?.email }}</p>15 </ion-label>16 </ion-item>1718 <ion-item>19 <ion-label position="stacked">Name</ion-label>20 <ion-input type="text" v-model="profile.username" />21 </ion-item>2223 <ion-item>24 <ion-label position="stacked">Website</ion-label>25 <ion-input type="url" v-model="profile.website" />26 </ion-item>2728 <div class="ion-text-center">29 <ion-button type="submit" fill="clear">Update Profile</ion-button>30 </div>31 </form>3233 <div class="ion-text-center">34 <ion-button fill="clear" @click="signOut">Log Out</ion-button>35 </div>36 </ion-content>37 </ion-page>38</template>3940<script lang="ts">41 import {42 IonPage,43 IonHeader,44 IonToolbar,45 IonTitle,46 IonContent,47 IonItem,48 IonLabel,49 IonInput,50 IonButton,51 toastController,52 loadingController,53 } from '@ionic/vue'54 import { defineComponent, onMounted, ref } from 'vue'55 import { useRouter } from 'vue-router'56 import { supabase } from '@/supabase'57 import type { User } from '@supabase/supabase-js'5859 export default defineComponent({60 name: 'AccountPage',61 components: {62 IonPage,63 IonHeader,64 IonToolbar,65 IonTitle,66 IonContent,67 IonItem,68 IonLabel,69 IonInput,70 IonButton,71 },72 setup() {73 const router = useRouter()74 const user = ref<User | null>(null)7576 const profile = ref({77 username: '',78 website: '',79 avatar_url: '',80 })8182 const getProfile = async () => {83 const loader = await loadingController.create()84 const toast = await toastController.create({ duration: 5000 })85 await loader.present()8687 try {88 const { data, error, status } = await supabase89 .from('profiles')90 .select('username, website, avatar_url')91 .eq('id', user.value?.id)92 .single()9394 if (error && status !== 406) throw error9596 if (data) {97 profile.value = {98 username: data.username,99 website: data.website,100 avatar_url: data.avatar_url,101 }102 }103 } catch (error: any) {104 toast.message = error.message105 await toast.present()106 } finally {107 await loader.dismiss()108 }109 }110111 const updateProfile = async () => {112 const loader = await loadingController.create()113 const toast = await toastController.create({ duration: 5000 })114 await loader.present()115116 try {117 const updates = {118 id: user.value?.id,119 ...profile.value,120 updated_at: new Date(),121 }122123 const { error } = await supabase.from('profiles').upsert(updates, {124 returning: 'minimal',125 })126127 if (error) throw error128 } catch (error: any) {129 toast.message = error.message130 await toast.present()131 } finally {132 await loader.dismiss()133 }134 }135136 const signOut = async () => {137 const loader = await loadingController.create()138 const toast = await toastController.create({ duration: 5000 })139 await loader.present()140141 try {142 const { error } = await supabase.auth.signOut()143 if (error) throw error144 router.push('/')145 } catch (error: any) {146 toast.message = error.message147 await toast.present()148 } finally {149 await loader.dismiss()150 }151 }152153 onMounted(async () => {154 const loader = await loadingController.create()155 await loader.present()156157 const { data } = await supabase.auth.getSession()158 user.value = data.session?.user ?? null159160 if (!user.value) {161 router.push('/')162 } else {163 await getProfile()164 }165166 await loader.dismiss()167 })168169 return {170 user,171 profile,172 updateProfile,173 signOut,174 }175 },176 })177</script>Launch!
Now that we have all the components in place, let's update App.vue and our routes:
1import { createRouter, createWebHistory } from '@ionic/vue-router'2import { RouteRecordRaw } from 'vue-router'3import LoginPage from '../views/Login.vue'4import AccountPage from '../views/Account.vue'5const routes: Array<RouteRecordRaw> = [6 {7 path: '/',8 name: 'Login',9 component: LoginPage,10 },11 {12 path: '/account',13 name: 'Account',14 component: AccountPage,15 },16]1718const router = createRouter({19 history: createWebHistory(import.meta.env.BASE_URL),20 routes,21})2223export default routerOnce that's done, run this in a terminal window:
1ionic serveAnd then open the browser to localhost:3000 and you should see the completed app.

Bonus: Profile photos
Every Supabase project is configured with Storage for managing large files like photos and videos.
Create an upload widget
First install two packages in order to interact with the user's camera.
1npm install @ionic/pwa-elements @capacitor/cameraCapacitor is a cross-platform native runtime from Ionic that enables web apps to be deployed through the app store and provides access to native device API.
Ionic PWA elements is a companion package that will polyfill certain browser APIs that provide no user interface with custom Ionic UI.
With those packages installed we can update our main.ts to include an additional bootstrapping call for the Ionic PWA Elements.
1import { createApp } from 'vue'2import App from './App.vue'3import router from './router'45import { IonicVue } from '@ionic/vue'6/* Core CSS required for Ionic components to work properly */7import '@ionic/vue/css/ionic.bundle.css'89/* Theme variables */10import './theme/variables.css'1112import { defineCustomElements } from '@ionic/pwa-elements/loader'13defineCustomElements(window)14const app = createApp(App).use(IonicVue).use(router)1516router.isReady().then(() => {17 app.mount('#app')18})Then create an AvatarComponent.
1<template>2 <div class="avatar">3 <div class="avatar_wrapper" @click="uploadAvatar">4 <img v-if="avatarUrl" :src="avatarUrl" />5 <ion-icon v-else name="person" class="no-avatar"></ion-icon>6 </div>7 </div>8</template>910<script lang="ts">11 import { ref, toRefs, watch, defineComponent } from 'vue'12 import { supabase } from '../supabase'13 import { Camera, CameraResultType } from '@capacitor/camera'14 import { IonIcon } from '@ionic/vue'15 import { person } from 'ionicons/icons'16 export default defineComponent({17 name: 'AppAvatar',18 props: { path: String },19 emits: ['upload', 'update:path'],20 components: { IonIcon },21 setup(prop, { emit }) {22 const { path } = toRefs(prop)23 const avatarUrl = ref('')2425 const downloadImage = async () => {26 try {27 const { data, error } = await supabase.storage.from('avatars').download(path.value)28 if (error) throw error29 avatarUrl.value = URL.createObjectURL(data!)30 } catch (error: any) {31 console.error('Error downloading image: ', error.message)32 }33 }3435 const uploadAvatar = async () => {36 try {37 const photo = await Camera.getPhoto({38 resultType: CameraResultType.DataUrl,39 })40 if (photo.dataUrl) {41 const file = await fetch(photo.dataUrl)42 .then((res) => res.blob())43 .then((blob) => new File([blob], 'my-file', { type: `image/${photo.format}` }))4445 const fileName = `${Math.random()}-${new Date().getTime()}.${photo.format}`46 const { error: uploadError } = await supabase.storage47 .from('avatars')48 .upload(fileName, file)49 if (uploadError) {50 throw uploadError51 }52 emit('update:path', fileName)53 emit('upload')54 }55 } catch (error) {56 console.log(error)57 }58 }5960 watch(path, () => {61 if (path.value) downloadImage()62 })6364 return { avatarUrl, uploadAvatar, person }65 },66 })67</script>68<style>69 .avatar {70 display: block;71 margin: auto;72 min-height: 150px;73 }74 .avatar .avatar_wrapper {75 margin: 16px auto 16px;76 border-radius: 50%;77 overflow: hidden;78 height: 150px;79 aspect-ratio: 1;80 background: var(--ion-color-step-50);81 border: thick solid var(--ion-color-step-200);82 }83 .avatar .avatar_wrapper:hover {84 cursor: pointer;85 }86 .avatar .avatar_wrapper ion-icon.no-avatar {87 width: 100%;88 height: 115%;89 }90 .avatar img {91 display: block;92 object-fit: cover;93 width: 100%;94 height: 100%;95 }96</style>Add the new widget
And then we can add the widget to the Account page:
1<template>2 <ion-page>3 <ion-header>4 <ion-toolbar>5 <ion-title>Account</ion-title>6 </ion-toolbar>7 </ion-header>89 <ion-content>10 <avatar v-model:path="profile.avatar_url" @upload="updateProfile"></avatar>11...12</template>13<script lang="ts">14import Avatar from '../components/Avatar.vue';15export default defineComponent({16 name: 'AccountPage',17 components: {18 Avatar,19 ....20 }2122</script>At this stage you have a fully functional application!