Home

Build a User Management App with SvelteKit

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 Svelte app from scratch.

Initialize a Svelte app#

We can use the SvelteKit Skeleton Project to initialize an app called supabase-sveltekit (for this tutorial we will be using TypeScript):


_10
npm create svelte@latest supabase-sveltekit
_10
cd supabase-sveltekit
_10
npm install

Then install the Supabase client library: supabase-js


_10
npm install @supabase/supabase-js

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

.env

_10
PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"
_10
PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_KEY"

Optionally, add src/styles.css with the CSS from the example.

Supabase Auth Helpers#

SvelteKit is a highly versatile framework offering pre-rendering at build time (SSG), server-side rendering at request time (SSR), API routes, and more.

It can be challenging to authenticate your users in all these different environments, that's why we've created the Supabase Auth Helpers to make user management and data fetching within SvelteKit as easy as possible.

Install the auth helpers for SvelteKit:


_10
npm install @supabase/auth-helpers-sveltekit

Add the code below to your src/hooks.server.ts to initialize the client on the server:

src/hooks.server.ts

_28
// src/hooks.server.ts
_28
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
_28
import { createSupabaseServerClient } from '@supabase/auth-helpers-sveltekit'
_28
import type { Handle } from '@sveltejs/kit'
_28
_28
export const handle: Handle = async ({ event, resolve }) => {
_28
event.locals.supabase = createSupabaseServerClient({
_28
supabaseUrl: PUBLIC_SUPABASE_URL,
_28
supabaseKey: PUBLIC_SUPABASE_ANON_KEY,
_28
event,
_28
})
_28
_28
/**
_28
* A convenience helper so we can just call await getSession() instead const { data: { session } } = await supabase.auth.getSession()
_28
*/
_28
event.locals.getSession = async () => {
_28
const {
_28
data: { session },
_28
} = await event.locals.supabase.auth.getSession()
_28
return session
_28
}
_28
_28
return resolve(event, {
_28
filterSerializedResponseHeaders(name) {
_28
return name === 'content-range'
_28
},
_28
})
_28
}

If you are using TypeScript the compiler might complain about event.locals.supabase and event.locals.getSession, this can be fixed by updating your src/app.d.ts with the content below:

src/app.d.ts

_17
// src/app.d.ts
_17
_17
import { SupabaseClient, Session } from '@supabase/supabase-js'
_17
_17
declare global {
_17
namespace App {
_17
interface Locals {
_17
supabase: SupabaseClient
_17
getSession(): Promise<Session | null>
_17
}
_17
interface PageData {
_17
session: Session | null
_17
}
_17
// interface Error {}
_17
// interface Platform {}
_17
}
_17
}

Create a new src/routes/+layout.server.ts file to handle the session on the server-side.

src/routes/+layout.server.ts

_10
// src/routes/+layout.server.ts
_10
export const load = async ({ locals: { getSession } }) => {
_10
return {
_10
session: await getSession(),
_10
}
_10
}

Start your dev server (npm run dev) in order to generate the ./$types files we are referencing in our project.

Create a new src/routes/+layout.ts file to handle the session and the supabase object on the client-side.

src/routes/+layout.ts

_21
// src/routes/+layout.ts
_21
import { invalidate } from '$app/navigation'
_21
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
_21
import { createSupabaseLoadClient } from '@supabase/auth-helpers-sveltekit'
_21
_21
export const load = async ({ fetch, data, depends }) => {
_21
depends('supabase:auth')
_21
_21
const supabase = createSupabaseLoadClient({
_21
supabaseUrl: PUBLIC_SUPABASE_URL,
_21
supabaseKey: PUBLIC_SUPABASE_ANON_KEY,
_21
event: { fetch },
_21
serverSession: data.session,
_21
})
_21
_21
const {
_21
data: { session },
_21
} = await supabase.auth.getSession()
_21
_21
return { supabase, session }
_21
}

Update your src/routes/+layout.svelte:

src/routes/+layout.svelte

_29
<!-- src/routes/+layout.svelte -->
_29
<script lang="ts">
_29
import '../styles.css'
_29
import { invalidate } from '$app/navigation'
_29
import { onMount } from 'svelte'
_29
_29
export let data
_29
_29
let { supabase, session } = data
_29
$: ({ supabase, session } = data)
_29
_29
onMount(() => {
_29
const { data } = supabase.auth.onAuthStateChange((event, _session) => {
_29
if (_session?.expires_at !== session?.expires_at) {
_29
invalidate('supabase:auth')
_29
}
_29
})
_29
_29
return () => data.subscription.unsubscribe()
_29
})
_29
</script>
_29
_29
<svelte:head>
_29
<title>User Management</title>
_29
</svelte:head>
_29
_29
<div class="container" style="padding: 50px 0 100px 0">
_29
<slot />
_29
</div>

Set up a Login page#

Supabase Auth UI

We can use the Supabase Auth UI, a pre-built Svelte component, for authenticating users via OAuth, email, and magic links.

Install the Supabase Auth UI for Svelte


_10
npm install @supabase/auth-ui-svelte @supabase/auth-ui-shared

Add the Auth component to your home page

src/routes/+page.svelte

_23
<!-- src/routes/+page.svelte -->
_23
<script lang="ts">
_23
import { Auth } from '@supabase/auth-ui-svelte'
_23
import { ThemeSupa } from '@supabase/auth-ui-shared'
_23
_23
export let data
_23
</script>
_23
_23
<svelte:head>
_23
<title>User Management</title>
_23
</svelte:head>
_23
_23
<div class="row flex-center flex">
_23
<div class="col-6 form-widget">
_23
<Auth
_23
supabaseClient={data.supabase}
_23
view="magic_link"
_23
redirectTo={`${data.url}/auth/callback`}
_23
showLinks={false}
_23
appearance={{ theme: ThemeSupa, style: { input: 'color: #fff' } }}
_23
/>
_23
</div>
_23
</div>

Create a src/routes/+page.server.ts file that will return our website url to be used in our redirectTo above.


_14
// src/routes/+page.server.ts
_14
import { redirect } from '@sveltejs/kit'
_14
import type { PageServerLoad } from './$types'
_14
_14
export const load: PageServerLoad = async ({ url, locals: { getSession } }) => {
_14
const session = await getSession()
_14
_14
// if the user is already logged in return them to the account page
_14
if (session) {
_14
throw redirect(303, '/account')
_14
}
_14
_14
return { url: url.origin }
_14
}

Proof Key for Code Exchange (PKCE)#

As we are employing Proof Key for Code Exchange (PKCE) in our authentication flow, it is necessary to create a server endpoint responsible for exchanging the code for a session.

In the following code snippet, we perform the following steps:

  • Retrieve the code sent back from the Supabase Auth server using the code query parameter.
  • Exchange this code for a session, which we store in our chosen storage mechanism (in this case, cookies).
  • Finally, we redirect the user to the account page.

_12
// src/routes/auth/callback/+server.js
_12
import { redirect } from '@sveltejs/kit'
_12
_12
export const GET = async ({ url, locals: { supabase } }) => {
_12
const code = url.searchParams.get('code')
_12
_12
if (code) {
_12
await supabase.auth.exchangeCodeForSession(code)
_12
}
_12
_12
throw redirect(303, '/account')
_12
}

Account page#

After a user is signed in, they need to be able to edit their profile details and manage their account. Create a new src/routes/account/+page.svelte file with the content below.

src/routes/account/+page.svelte

_77
<!-- src/routes/account/+page.svelte -->
_77
<script lang="ts">
_77
import { enhance, type SubmitFunction } from '$app/forms'
_77
_77
export let data
_77
export let form
_77
_77
let { session, supabase, profile } = data
_77
$: ({ session, supabase, profile } = data)
_77
_77
let profileForm: HTMLFormElement
_77
let loading = false
_77
let fullName: string = profile?.full_name ?? ''
_77
let username: string = profile?.username ?? ''
_77
let website: string = profile?.website ?? ''
_77
let avatarUrl: string = profile?.avatar_url ?? ''
_77
_77
const handleSubmit: SubmitFunction = () => {
_77
loading = true
_77
return async () => {
_77
loading = false
_77
}
_77
}
_77
_77
const handleSignOut: SubmitFunction = () => {
_77
loading = true
_77
return async ({ update }) => {
_77
loading = false
_77
update()
_77
}
_77
}
_77
</script>
_77
_77
<div class="form-widget">
_77
<form
_77
class="form-widget"
_77
method="post"
_77
action="?/update"
_77
use:enhance={handleSubmit}
_77
bind:this={profileForm}
_77
>
_77
<div>
_77
<label for="email">Email</label>
_77
<input id="email" type="text" value={session.user.email} disabled />
_77
</div>
_77
_77
<div>
_77
<label for="fullName">Full Name</label>
_77
<input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />
_77
</div>
_77
_77
<div>
_77
<label for="username">Username</label>
_77
<input id="username" name="username" type="text" value={form?.username ?? username} />
_77
</div>
_77
_77
<div>
_77
<label for="website">Website</label>
_77
<input id="website" name="website" type="url" value={form?.website ?? website} />
_77
</div>
_77
_77
<div>
_77
<input
_77
type="submit"
_77
class="button block primary"
_77
value={loading ? 'Loading...' : 'Update'}
_77
disabled={loading}
_77
/>
_77
</div>
_77
</form>
_77
_77
<form method="post" action="?/signout" use:enhance={handleSignOut}>
_77
<div>
_77
<button class="button block" disabled={loading}>Sign Out</button>
_77
</div>
_77
</form>
_77
</div>

Now create the associated src/routes/account/+page.server.ts file that will handle loading our data from the server through the load function and handle all our form actions through the actions object.


_61
import { fail, redirect } from '@sveltejs/kit'
_61
_61
export const load = async ({ locals: { supabase, getSession } }) => {
_61
const session = await getSession()
_61
_61
if (!session) {
_61
throw redirect(303, '/')
_61
}
_61
_61
const { data: profile } = await supabase
_61
.from('profiles')
_61
.select(`username, full_name, website, avatar_url`)
_61
.eq('id', session.user.id)
_61
.single()
_61
_61
return { session, profile }
_61
}
_61
_61
export const actions = {
_61
update: async ({ request, locals: { supabase, getSession } }) => {
_61
const formData = await request.formData()
_61
const fullName = formData.get('fullName') as string
_61
const username = formData.get('username') as string
_61
const website = formData.get('website') as string
_61
const avatarUrl = formData.get('avatarUrl') as string
_61
_61
const session = await getSession()
_61
_61
const { error } = await supabase.from('profiles').upsert({
_61
id: session?.user.id,
_61
full_name: fullName,
_61
username,
_61
website,
_61
avatar_url: avatarUrl,
_61
updated_at: new Date(),
_61
})
_61
_61
if (error) {
_61
return fail(500, {
_61
fullName,
_61
username,
_61
website,
_61
avatarUrl,
_61
})
_61
}
_61
_61
return {
_61
fullName,
_61
username,
_61
website,
_61
avatarUrl,
_61
}
_61
},
_61
signout: async ({ locals: { supabase, getSession } }) => {
_61
const session = await getSession()
_61
if (session) {
_61
await supabase.auth.signOut()
_61
throw redirect(303, '/')
_61
}
_61
},
_61
}

Launch!#

Now that we have all the pages in place, run this in a terminal window:


_10
npm run dev

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

Supabase Svelte

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 called Avatar.svelte in the src/routes/account directory:

src/routes/account/Avatar.svelte

_94
<!-- src/routes/account/Avatar.svelte -->
_94
<script lang="ts">
_94
import type { SupabaseClient } from '@supabase/supabase-js'
_94
import { createEventDispatcher } from 'svelte'
_94
_94
export let size = 10
_94
export let url: string
_94
export let supabase: SupabaseClient
_94
_94
let avatarUrl: string | null = null
_94
let uploading = false
_94
let files: FileList
_94
_94
const dispatch = createEventDispatcher()
_94
_94
const downloadImage = async (path: string) => {
_94
try {
_94
const { data, error } = await supabase.storage.from('avatars').download(path)
_94
_94
if (error) {
_94
throw error
_94
}
_94
_94
const url = URL.createObjectURL(data)
_94
avatarUrl = url
_94
} catch (error) {
_94
if (error instanceof Error) {
_94
console.log('Error downloading image: ', error.message)
_94
}
_94
}
_94
}
_94
_94
const uploadAvatar = async () => {
_94
try {
_94
uploading = true
_94
_94
if (!files || files.length === 0) {
_94
throw new Error('You must select an image to upload.')
_94
}
_94
_94
const file = files[0]
_94
const fileExt = file.name.split('.').pop()
_94
const filePath = `${Math.random()}.${fileExt}`
_94
_94
let { error } = await supabase.storage.from('avatars').upload(filePath, file)
_94
_94
if (error) {
_94
throw error
_94
}
_94
_94
url = filePath
_94
setTimeout(() => {
_94
dispatch('upload')
_94
}, 100)
_94
} catch (error) {
_94
if (error instanceof Error) {
_94
alert(error.message)
_94
}
_94
} finally {
_94
uploading = false
_94
}
_94
}
_94
_94
$: if (url) downloadImage(url)
_94
</script>
_94
_94
<div>
_94
{#if avatarUrl}
_94
<img
_94
src={avatarUrl}
_94
alt={avatarUrl ? 'Avatar' : 'No image'}
_94
class="avatar image"
_94
style="height: {size}em; width: {size}em;"
_94
/>
_94
{:else}
_94
<div class="avatar no-image" style="height: {size}em; width: {size}em;" />
_94
{/if}
_94
<input type="hidden" name="avatarUrl" value={url} />
_94
_94
<div style="width: {size}em;">
_94
<label class="button primary block" for="single">
_94
{uploading ? 'Uploading ...' : 'Upload'}
_94
</label>
_94
<input
_94
style="visibility: hidden; position:absolute;"
_94
type="file"
_94
id="single"
_94
accept="image/*"
_94
bind:files
_94
on:change={uploadAvatar}
_94
disabled={uploading}
_94
/>
_94
</div>
_94
</div>

Add the new widget#

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

src/routes/account/+page.svelte

_27
<!-- src/routes/account/+page.svelte -->
_27
<script lang="ts">
_27
// Import the new component
_27
import Avatar from './Avatar.svelte'
_27
</script>
_27
_27
<div class="form-widget">
_27
<form
_27
class="form-widget"
_27
method="post"
_27
action="?/update"
_27
use:enhance={handleSubmit}
_27
bind:this={profileForm}
_27
>
_27
<!-- Add to body -->
_27
<Avatar
_27
{supabase}
_27
bind:url={avatarUrl}
_27
size={10}
_27
on:upload={() => {
_27
profileForm.requestSubmit();
_27
}}
_27
/>
_27
_27
<!-- Other form elements -->
_27
</form>
_27
</div>

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();

At this stage you have a fully functional application!