---
title: 'Build a User Management App with Ionic Vue'
description: 'Learn how to use Supabase in your Ionic Vue App.'
---

<$Partial path="quickstart_intro.mdx" />

![Supabase User Management example](/docs/img/ionic-demos/ionic-angular-account.png)

<Admonition type="note">

If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/mhartington/supabase-ionic-vue).

</Admonition>

<$Partial path="project_setup.mdx" variables={{ "framework": "vuejs", "tab": "frameworks" }} />

## Building the app

Let's start building the Vue app from scratch.

### Initialize an Ionic Vue app

We can use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize an app called `supabase-ionic-vue`:

```bash
npm install -g @ionic/cli
ionic start supabase-ionic-vue blank --type vue
cd supabase-ionic-vue
```

Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js)

```bash
npm install @supabase/supabase-js
```

And 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](#get-api-details).

<$CodeTabs>

```bash name=.env
VITE_SUPABASE_URL=YOUR_SUPABASE_URL
VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY
```

</$CodeTabs>

Now 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](/docs/guides/auth#row-level-security) enabled on our Database.

<$CodeTabs>

```js name=src/supabase.ts
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string;
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY as string;

export const supabase = createClient(supabaseUrl, supabasePublishableKey);
```

</$CodeTabs>

### 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.

<$CodeTabs>

```html name=/src/views/Login.vue
<template>
  <ion-page>
    <ion-header>
      <ion-toolbar>
        <ion-title>Login</ion-title>
      </ion-toolbar>
    </ion-header>

    <ion-content>
      <div class="ion-padding">
        <h1>Supabase + Ionic Vue</h1>
        <p>Sign in via magic link with your email below</p>
      </div>
      <ion-list inset="true">
        <form @submit.prevent="handleLogin">
          <ion-item>
            <ion-label position="stacked">Email</ion-label>
            <ion-input v-model="email" name="email" autocomplete type="email"></ion-input>
          </ion-item>
          <div class="ion-text-center">
            <ion-button type="submit" fill="clear">Login</ion-button>
          </div>
        </form>
      </ion-list>
      <p>{{ email }}</p>
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
  import { supabase } from '../supabase'
  import {
    IonContent,
    IonHeader,
    IonPage,
    IonTitle,
    IonToolbar,
    IonList,
    IonItem,
    IonLabel,
    IonInput,
    IonButton,
    toastController,
    loadingController,
  } from '@ionic/vue'
  import { defineComponent, ref } from 'vue'

  export default defineComponent({
    name: 'LoginPage',
    components: {
      IonContent,
      IonHeader,
      IonPage,
      IonTitle,
      IonToolbar,
      IonList,
      IonItem,
      IonLabel,
      IonInput,
      IonButton,
    },
    setup() {
      const email = ref('')
      const handleLogin = async () => {
        const loader = await loadingController.create({})
        const toast = await toastController.create({ duration: 5000 })

        try {
          await loader.present()
          const { error } = await supabase.auth.signInWithOtp({ email: email.value })

          if (error) throw error

          toast.message = 'Check your email for the login link!'
          await toast.present()
        } catch (error: any) {
          toast.message = error.error_description || error.message
          await toast.present()
        } finally {
          await loader.dismiss()
        }
      }
      return { handleLogin, email }
    },
  })
</script>
```

</$CodeTabs>

### 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`.

<$CodeTabs>

```html name=src/views/Account.vue
<template>
  <ion-page>
    <ion-header>
      <ion-toolbar>
        <ion-title>Account</ion-title>
      </ion-toolbar>
    </ion-header>

    <ion-content>
      <form @submit.prevent="updateProfile">
        <ion-item>
          <ion-label>
            <p>Email</p>
            <p>{{ user?.email }}</p>
          </ion-label>
        </ion-item>

        <ion-item>
          <ion-label position="stacked">Name</ion-label>
          <ion-input type="text" v-model="profile.username" />
        </ion-item>

        <ion-item>
          <ion-label position="stacked">Website</ion-label>
          <ion-input type="url" v-model="profile.website" />
        </ion-item>

        <div class="ion-text-center">
          <ion-button type="submit" fill="clear">Update Profile</ion-button>
        </div>
      </form>

      <div class="ion-text-center">
        <ion-button fill="clear" @click="signOut">Log Out</ion-button>
      </div>
    </ion-content>
  </ion-page>
</template>

<script lang="ts">
  import {
    IonPage,
    IonHeader,
    IonToolbar,
    IonTitle,
    IonContent,
    IonItem,
    IonLabel,
    IonInput,
    IonButton,
    toastController,
    loadingController,
  } from '@ionic/vue'
  import { defineComponent, onMounted, ref } from 'vue'
  import { useRouter } from 'vue-router'
  import { supabase } from '@/supabase'
  import type { User } from '@supabase/supabase-js'

  export default defineComponent({
    name: 'AccountPage',
    components: {
      IonPage,
      IonHeader,
      IonToolbar,
      IonTitle,
      IonContent,
      IonItem,
      IonLabel,
      IonInput,
      IonButton,
    },
    setup() {
      const router = useRouter()
      const user = ref<User | null>(null)

      const profile = ref({
        username: '',
        website: '',
        avatar_url: '',
      })

      const getProfile = async () => {
        const loader = await loadingController.create()
        const toast = await toastController.create({ duration: 5000 })
        await loader.present()

        try {
          const { data, error, status } = await supabase
            .from('profiles')
            .select('username, website, avatar_url')
            .eq('id', user.value?.id)
            .single()

          if (error && status !== 406) throw error

          if (data) {
            profile.value = {
              username: data.username,
              website: data.website,
              avatar_url: data.avatar_url,
            }
          }
        } catch (error: any) {
          toast.message = error.message
          await toast.present()
        } finally {
          await loader.dismiss()
        }
      }

      const updateProfile = async () => {
        const loader = await loadingController.create()
        const toast = await toastController.create({ duration: 5000 })
        await loader.present()

        try {
          const updates = {
            id: user.value?.id,
            ...profile.value,
            updated_at: new Date(),
          }

          const { error } = await supabase.from('profiles').upsert(updates, {
            returning: 'minimal',
          })

          if (error) throw error
        } catch (error: any) {
          toast.message = error.message
          await toast.present()
        } finally {
          await loader.dismiss()
        }
      }

      const signOut = async () => {
        const loader = await loadingController.create()
        const toast = await toastController.create({ duration: 5000 })
        await loader.present()

        try {
          const { error } = await supabase.auth.signOut()
          if (error) throw error
          router.push('/')
        } catch (error: any) {
          toast.message = error.message
          await toast.present()
        } finally {
          await loader.dismiss()
        }
      }

      onMounted(async () => {
        const loader = await loadingController.create()
        await loader.present()

        const { data } = await supabase.auth.getSession()
        user.value = data.session?.user ?? null

        if (!user.value) {
          router.push('/')
        } else {
          await getProfile()
        }

        await loader.dismiss()
      })

      return {
        user,
        profile,
        updateProfile,
        signOut,
      }
    },
  })
</script>
```

</$CodeTabs>

### Launch!

Now that we have all the components in place, let's update `App.vue` and our routes:

<$CodeTabs>

```ts name=src/router.index.ts
import { createRouter, createWebHistory } from '@ionic/vue-router'
import { RouteRecordRaw } from 'vue-router'
import LoginPage from '../views/Login.vue'
import AccountPage from '../views/Account.vue'
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Login',
    component: LoginPage,
  },
  {
    path: '/account',
    name: 'Account',
    component: AccountPage,
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

export default router
```

```html name=src/App.vue
<template>
  <ion-app>
    <ion-router-outlet />
  </ion-app>
</template>

<script lang="ts">
  import { IonApp, IonRouterOutlet, useIonRouter } from '@ionic/vue'
  import { defineComponent, ref, onMounted } from 'vue'
  import { supabase } from './supabase'

  export default defineComponent({
    name: 'App',
    components: {
      IonApp,
      IonRouterOutlet,
    },
    setup() {
      const router = useIonRouter()
      const user = ref(null)

      onMounted(() => {
        supabase.auth
          .getSession()
          .then((resp) => {
            user.value = resp.data.session?.user ?? null
          })
          .catch((err) => {
            console.log('Error fetching session', err)
          })

        supabase.auth.onAuthStateChange((_event, session) => {
          user.value = session?.user ?? null
        })
      })

      return { user }
    },
  })
</script>
```

</$CodeTabs>

Once that's done, run this in a terminal window:

```bash
ionic serve
```

And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.

![Supabase Ionic Vue](/docs/img/ionic-demos/ionic-vue.png)

## Bonus: Profile photos

Every Supabase project is configured with [Storage](/docs/guides/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.

```bash
npm install @ionic/pwa-elements @capacitor/camera
```

[Capacitor](https://capacitorjs.com) 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.

<$CodeTabs>

```ts name=src/main.tsx
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

import { IonicVue } from '@ionic/vue'
/* Core CSS required for Ionic components to work properly */
import '@ionic/vue/css/ionic.bundle.css'

/* Theme variables */
import './theme/variables.css'

import { defineCustomElements } from '@ionic/pwa-elements/loader'
defineCustomElements(window)
const app = createApp(App).use(IonicVue).use(router)

router.isReady().then(() => {
  app.mount('#app')
})
```

</$CodeTabs>

Then create an `AvatarComponent`.

<$CodeTabs>

```html name=src/components/Avatar.vue
<template>
  <div class="avatar">
    <div class="avatar_wrapper" @click="uploadAvatar">
      <img v-if="avatarUrl" :src="avatarUrl" />
      <ion-icon v-else name="person" class="no-avatar"></ion-icon>
    </div>
  </div>
</template>

<script lang="ts">
  import { ref, toRefs, watch, defineComponent } from 'vue'
  import { supabase } from '../supabase'
  import { Camera, CameraResultType } from '@capacitor/camera'
  import { IonIcon } from '@ionic/vue'
  import { person } from 'ionicons/icons'
  export default defineComponent({
    name: 'AppAvatar',
    props: { path: String },
    emits: ['upload', 'update:path'],
    components: { IonIcon },
    setup(prop, { emit }) {
      const { path } = toRefs(prop)
      const avatarUrl = ref('')

      const downloadImage = async () => {
        try {
          const { data, error } = await supabase.storage.from('avatars').download(path.value)
          if (error) throw error
          avatarUrl.value = URL.createObjectURL(data!)
        } catch (error: any) {
          console.error('Error downloading image: ', error.message)
        }
      }

      const uploadAvatar = async () => {
        try {
          const photo = await Camera.getPhoto({
            resultType: CameraResultType.DataUrl,
          })
          if (photo.dataUrl) {
            const file = await fetch(photo.dataUrl)
              .then((res) => res.blob())
              .then((blob) => new File([blob], 'my-file', { type: `image/${photo.format}` }))

            const fileName = `${Math.random()}-${new Date().getTime()}.${photo.format}`
            const { error: uploadError } = await supabase.storage
              .from('avatars')
              .upload(fileName, file)
            if (uploadError) {
              throw uploadError
            }
            emit('update:path', fileName)
            emit('upload')
          }
        } catch (error) {
          console.log(error)
        }
      }

      watch(path, () => {
        if (path.value) downloadImage()
      })

      return { avatarUrl, uploadAvatar, person }
    },
  })
</script>
<style>
  .avatar {
    display: block;
    margin: auto;
    min-height: 150px;
  }
  .avatar .avatar_wrapper {
    margin: 16px auto 16px;
    border-radius: 50%;
    overflow: hidden;
    height: 150px;
    aspect-ratio: 1;
    background: var(--ion-color-step-50);
    border: thick solid var(--ion-color-step-200);
  }
  .avatar .avatar_wrapper:hover {
    cursor: pointer;
  }
  .avatar .avatar_wrapper ion-icon.no-avatar {
    width: 100%;
    height: 115%;
  }
  .avatar img {
    display: block;
    object-fit: cover;
    width: 100%;
    height: 100%;
  }
</style>
```

</$CodeTabs>

### Add the new widget

And then we can add the widget to the Account page:

<$CodeTabs>

```html name=src/views/Account.vue
<template>
  <ion-page>
    <ion-header>
      <ion-toolbar>
        <ion-title>Account</ion-title>
      </ion-toolbar>
    </ion-header>

    <ion-content>
      <avatar v-model:path="profile.avatar_url" @upload="updateProfile"></avatar>
...
</template>
<script lang="ts">
import Avatar from '../components/Avatar.vue';
export default defineComponent({
  name: 'AccountPage',
  components: {
    Avatar,
    ....
  }

</script>
```

</$CodeTabs>

At this stage you have a fully functional application!
