Home

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 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 - users log in through magic links sent to their email (without having to set up passwords).
  • Supabase Storage - users can upload a profile photo.

Supabase User Management example

note

If you get stuck while working through this guide, refer to the full example on GitHub.

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.

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.

And one 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
let { 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
let { 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
let { 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
let { 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
let { 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
let { 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
let { 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>

Storage management#

If you upload additional profile photos, they'll accumulate in the avatars bucket because of their random names with only the latest being referenced from public.profiles and the older versions getting orphaned.

To automatically remove obsolete storage objects, extend the database triggers. Note that it is not sufficient to delete the objects from the storage.objects table because that would orphan and leak the actual storage objects in the S3 backend. Instead, invoke the storage API within Postgres via the http extension.

Enable the http extension for the extensions schema in the Dashboard. Then, define the following SQL functions in the SQL Editor to delete storage objects via the API:


_34
create or replace function delete_storage_object(bucket text, object text, out status int, out content text)
_34
returns record
_34
language 'plpgsql'
_34
security definer
_34
as $$
_34
declare
_34
project_url text := '<YOURPROJECTURL>';
_34
service_role_key text := '<YOURSERVICEROLEKEY>'; -- full access needed
_34
url text := project_url||'/storage/v1/object/'||bucket||'/'||object;
_34
begin
_34
select
_34
into status, content
_34
result.status::int, result.content::text
_34
FROM extensions.http((
_34
'DELETE',
_34
url,
_34
ARRAY[extensions.http_header('authorization','Bearer '||service_role_key)],
_34
NULL,
_34
NULL)::extensions.http_request) as result;
_34
end;
_34
$$;
_34
_34
create or replace function delete_avatar(avatar_url text, out status int, out content text)
_34
returns record
_34
language 'plpgsql'
_34
security definer
_34
as $$
_34
begin
_34
select
_34
into status, content
_34
result.status, result.content
_34
from public.delete_storage_object('avatars', avatar_url) as result;
_34
end;
_34
$$;

Next, add a trigger that removes any obsolete avatar whenever the profile is updated or deleted:


_29
create or replace function delete_old_avatar()
_29
returns trigger
_29
language 'plpgsql'
_29
security definer
_29
as $$
_29
declare
_29
status int;
_29
content text;
_29
begin
_29
if coalesce(old.avatar_url, '') <> ''
_29
and (tg_op = 'DELETE' or (old.avatar_url <> new.avatar_url)) then
_29
select
_29
into status, content
_29
result.status, result.content
_29
from public.delete_avatar(old.avatar_url) as result;
_29
if status <> 200 then
_29
raise warning 'Could not delete avatar: % %', status, content;
_29
end if;
_29
end if;
_29
if tg_op = 'DELETE' then
_29
return old;
_29
end if;
_29
return new;
_29
end;
_29
$$;
_29
_29
create trigger before_profile_changes
_29
before update of avatar_url or delete on public.profiles
_29
for each row execute function public.delete_old_avatar();

Finally, delete the public.profile row before a user is deleted. If this step is omitted, you won't be able to delete users without first manually deleting their avatar image.


_14
create or replace function delete_old_profile()
_14
returns trigger
_14
language 'plpgsql'
_14
security definer
_14
as $$
_14
begin
_14
delete from public.profiles where id = old.id;
_14
return old;
_14
end;
_14
$$;
_14
_14
create trigger before_delete_user
_14
before delete on auth.users
_14
for each row execute function public.delete_old_profile();

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