Getting Started

Build a User Management App with Nuxt 3


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 User Management example

Project setup

Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase and then creating a "schema" inside the database.

Create a project

  1. Create a new project in the Supabase Dashboard.
  2. Enter your project details.
  3. Wait for the new database to launch.

Set up the database schema

Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor, or you can just copy/paste the SQL from below and run it yourself.

  1. Go to the SQL Editor page in the Dashboard.
  2. Click User Management Starter.
  3. Click Run.

_10
supabase link --project-ref <project-id>
_10
# You can get <project-id> from your project's dashboard URL: https://supabase.com/dashboard/project/<project-id>
_10
supabase db pull

Get the API Keys

Now that you've created some database tables, you are ready to insert data using the auto-generated API. We just need to get the Project URL and anon key from the API settings.

  1. Go to the API Settings page in the Dashboard.
  2. Find your Project URL, anon, and service_role keys on this page.

Building the app

Let's start building the Vue 3 app from scratch.

Initialize a Nuxt 3 app

We can use nuxi init to create an app called nuxt-user-management:


_10
npx nuxi init nuxt-user-management
_10
_10
cd nuxt-user-management

Then let's install the only additional dependency: NuxtSupabase. We only need to import NuxtSupabase as a dev dependency.


_10
npm install @nuxtjs/supabase --save-dev

And finally we want to save the environment variables in a .env. All we need are the API URL and the anon key that you copied earlier.

.env

_10
SUPABASE_URL="YOUR_SUPABASE_URL"
_10
SUPABASE_KEY="YOUR_SUPABASE_ANON_KEY"

These variables will be exposed on the browser, and that's completely fine since we have Row Level Security enabled on our Database. Amazing thing about NuxtSupabase is that setting environment variables is all we need to do in order to start using Supabase. No need to initialize Supabase. The library will take care of it automatically.

App styling (optional)

An optional step is to update the CSS file assets/main.css to make the app look nice. You can find the full contents of this file here.

nuxt.config.ts

_10
import { defineNuxtConfig } from 'nuxt'
_10
_10
// https://v3.nuxtjs.org/api/configuration/nuxt.config
_10
export default defineNuxtConfig({
_10
modules: ['@nuxtjs/supabase'],
_10
css: ['@/assets/main.css'],
_10
})

Set up Auth component

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.

/components/Auth.vue

_39
<script setup>
_39
const supabase = useSupabaseClient()
_39
_39
const loading = ref(false)
_39
const email = ref('')
_39
_39
const handleLogin = async () => {
_39
try {
_39
loading.value = true
_39
const { error } = await supabase.auth.signInWithOtp({ email: email.value })
_39
if (error) throw error
_39
alert('Check your email for the login link!')
_39
} catch (error) {
_39
alert(error.error_description || error.message)
_39
} finally {
_39
loading.value = false
_39
}
_39
}
_39
</script>
_39
_39
<template>
_39
<form class="row flex-center flex" @submit.prevent="handleLogin">
_39
<div class="col-6 form-widget">
_39
<h1 class="header">Supabase + Nuxt 3</h1>
_39
<p class="description">Sign in via magic link with your email below</p>
_39
<div>
_39
<input class="inputField" type="email" placeholder="Your email" v-model="email" />
_39
</div>
_39
<div>
_39
<input
_39
type="submit"
_39
class="button block"
_39
:value="loading ? 'Loading' : 'Send magic link'"
_39
:disabled="loading"
_39
/>
_39
</div>
_39
</div>
_39
</form>
_39
</template>

User state

To access the user information, use the composable useSupabaseUser provided by the Supabase Nuxt module.

Account component

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.

components/Account.vue

_92
<script setup>
_92
const supabase = useSupabaseClient()
_92
_92
const loading = ref(true)
_92
const username = ref('')
_92
const website = ref('')
_92
const avatar_path = ref('')
_92
_92
loading.value = true
_92
const user = useSupabaseUser()
_92
_92
const { data } = await supabase
_92
.from('profiles')
_92
.select(`username, website, avatar_url`)
_92
.eq('id', user.value.id)
_92
.single()
_92
_92
if (data) {
_92
username.value = data.username
_92
website.value = data.website
_92
avatar_path.value = data.avatar_url
_92
}
_92
_92
loading.value = false
_92
_92
async function updateProfile() {
_92
try {
_92
loading.value = true
_92
const user = useSupabaseUser()
_92
_92
const updates = {
_92
id: user.value.id,
_92
username: username.value,
_92
website: website.value,
_92
avatar_url: avatar_path.value,
_92
updated_at: new Date(),
_92
}
_92
_92
const { error } = await supabase.from('profiles').upsert(updates, {
_92
returning: 'minimal', // Don't return the value after inserting
_92
})
_92
if (error) throw error
_92
} catch (error) {
_92
alert(error.message)
_92
} finally {
_92
loading.value = false
_92
}
_92
}
_92
_92
async function signOut() {
_92
try {
_92
loading.value = true
_92
const { error } = await supabase.auth.signOut()
_92
if (error) throw error
_92
user.value = null
_92
} catch (error) {
_92
alert(error.message)
_92
} finally {
_92
loading.value = false
_92
}
_92
}
_92
</script>
_92
_92
<template>
_92
<form class="form-widget" @submit.prevent="updateProfile">
_92
<div>
_92
<label for="email">Email</label>
_92
<input id="email" type="text" :value="user.email" disabled />
_92
</div>
_92
<div>
_92
<label for="username">Username</label>
_92
<input id="username" type="text" v-model="username" />
_92
</div>
_92
<div>
_92
<label for="website">Website</label>
_92
<input id="website" type="url" v-model="website" />
_92
</div>
_92
_92
<div>
_92
<input
_92
type="submit"
_92
class="button primary block"
_92
:value="loading ? 'Loading ...' : 'Update'"
_92
:disabled="loading"
_92
/>
_92
</div>
_92
_92
<div>
_92
<button class="button block" @click="signOut" :disabled="loading">Sign Out</button>
_92
</div>
_92
</form>
_92
</template>

Launch!

Now that we have all the components in place, let's update app.vue:

app.vue

_10
<script setup>
_10
const user = useSupabaseUser()
_10
</script>
_10
_10
<template>
_10
<div class="container" style="padding: 50px 0 100px 0">
_10
<Account v-if="user" />
_10
<Auth v-else />
_10
</div>
_10
</template>

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


_10
npm run dev

And then open the browser to localhost:3000 and you should see the completed app.

Supabase Nuxt 3

Bonus: Profile photos

Every Supabase project is configured with Storage for managing large files like photos and videos.

Create an upload widget

Let's create an avatar for the user so that they can upload a profile photo. We can start by creating a new component:

components/Avatar.vue

_84
<script setup>
_84
const props = defineProps(['path'])
_84
const { path } = toRefs(props)
_84
_84
const emit = defineEmits(['update:path', 'upload'])
_84
_84
const supabase = useSupabaseClient()
_84
_84
const uploading = ref(false)
_84
const src = ref('')
_84
const files = ref()
_84
_84
const downloadImage = async () => {
_84
try {
_84
const { data, error } = await supabase.storage.from('avatars').download(path.value)
_84
if (error) throw error
_84
src.value = URL.createObjectURL(data)
_84
} catch (error) {
_84
console.error('Error downloading image: ', error.message)
_84
}
_84
}
_84
_84
const uploadAvatar = async (evt) => {
_84
files.value = evt.target.files
_84
try {
_84
uploading.value = true
_84
_84
if (!files.value || files.value.length === 0) {
_84
throw new Error('You must select an image to upload.')
_84
}
_84
_84
const file = files.value[0]
_84
const fileExt = file.name.split('.').pop()
_84
const fileName = `${Math.random()}.${fileExt}`
_84
const filePath = `${fileName}`
_84
_84
const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
_84
_84
if (uploadError) throw uploadError
_84
_84
emit('update:path', filePath)
_84
emit('upload')
_84
} catch (error) {
_84
alert(error.message)
_84
} finally {
_84
uploading.value = false
_84
}
_84
}
_84
_84
downloadImage()
_84
_84
watch(path, () => {
_84
if (path.value) {
_84
downloadImage()
_84
}
_84
})
_84
</script>
_84
_84
<template>
_84
<div>
_84
<img
_84
v-if="src"
_84
:src="src"
_84
alt="Avatar"
_84
class="avatar image"
_84
style="width: 10em; height: 10em;"
_84
/>
_84
<div v-else class="avatar no-image" :style="{ height: size, width: size }" />
_84
_84
<div style="width: 10em; position: relative;">
_84
<label class="button primary block" for="single">
_84
{{ uploading ? 'Uploading ...' : 'Upload' }}
_84
</label>
_84
<input
_84
style="position: absolute; visibility: hidden;"
_84
type="file"
_84
id="single"
_84
accept="image/*"
_84
@change="uploadAvatar"
_84
:disabled="uploading"
_84
/>
_84
</div>
_84
</div>
_84
</template>

Add the new widget

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

components/Account.vue

_93
<script setup>
_93
const supabase = useSupabaseClient()
_93
_93
const loading = ref(true)
_93
const username = ref('')
_93
const website = ref('')
_93
const avatar_path = ref('')
_93
_93
loading.value = true
_93
const user = useSupabaseUser()
_93
_93
const { data } = await supabase
_93
.from('profiles')
_93
.select(`username, website, avatar_url`)
_93
.eq('id', user.value.id)
_93
.single()
_93
_93
if (data) {
_93
username.value = data.username
_93
website.value = data.website
_93
avatar_path.value = data.avatar_url
_93
}
_93
_93
loading.value = false
_93
_93
async function updateProfile() {
_93
try {
_93
loading.value = true
_93
const user = useSupabaseUser()
_93
_93
const updates = {
_93
id: user.value.id,
_93
username: username.value,
_93
website: website.value,
_93
avatar_url: avatar_path.value,
_93
updated_at: new Date(),
_93
}
_93
_93
const { error } = await supabase.from('profiles').upsert(updates, {
_93
returning: 'minimal', // Don't return the value after inserting
_93
})
_93
_93
if (error) throw error
_93
} catch (error) {
_93
alert(error.message)
_93
} finally {
_93
loading.value = false
_93
}
_93
}
_93
_93
async function signOut() {
_93
try {
_93
loading.value = true
_93
const { error } = await supabase.auth.signOut()
_93
if (error) throw error
_93
} catch (error) {
_93
alert(error.message)
_93
} finally {
_93
loading.value = false
_93
}
_93
}
_93
</script>
_93
_93
<template>
_93
<form class="form-widget" @submit.prevent="updateProfile">
_93
<Avatar v-model:path="avatar_path" @upload="updateProfile" />
_93
<div>
_93
<label for="email">Email</label>
_93
<input id="email" type="text" :value="user.email" disabled />
_93
</div>
_93
<div>
_93
<label for="username">Name</label>
_93
<input id="username" type="text" v-model="username" />
_93
</div>
_93
<div>
_93
<label for="website">Website</label>
_93
<input id="website" type="url" v-model="website" />
_93
</div>
_93
_93
<div>
_93
<input
_93
type="submit"
_93
class="button primary block"
_93
:value="loading ? 'Loading ...' : 'Update'"
_93
:disabled="loading"
_93
/>
_93
</div>
_93
_93
<div>
_93
<button class="button block" @click="signOut" :disabled="loading">Sign Out</button>
_93
</div>
_93
</form>
_93
</template>

That is it! You should now be able to upload a profile photo to Supabase Storage and you have a fully functional application.