Getting Started

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

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

Creating a Supabase client for SSR:

The ssr package configures Supabase to use Cookies, which is required for server-side languages and frameworks.

Install the Supabase packages:


_10
npm install @supabase/ssr @supabase/supabase-js

Creating a Supabase client with the ssr package automatically configures it to use Cookies. This means your user's session is available throughout the entire SvelteKit stack - page, layout, server, hooks.

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

src/hooks.server.ts

_50
// src/hooks.server.ts
_50
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
_50
import { createServerClient } from '@supabase/ssr'
_50
import type { Handle } from '@sveltejs/kit'
_50
_50
export const handle: Handle = async ({ event, resolve }) => {
_50
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
_50
cookies: {
_50
get: (key) => event.cookies.get(key),
_50
/**
_50
* Note: You have to add the `path` variable to the
_50
* set and remove method due to sveltekit's cookie API
_50
* requiring this to be set, setting the path to `/`
_50
* will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
_50
*/
_50
set: (key, value, options) => {
_50
event.cookies.set(key, value, { ...options, path: '/' })
_50
},
_50
remove: (key, options) => {
_50
event.cookies.delete(key, { ...options, path: '/' })
_50
},
_50
},
_50
})
_50
_50
/**
_50
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
_50
* doesn't validate the JWT, this function validates the JWT by first calling
_50
* `getUser` and aborts early if the JWT signature is invalid.
_50
*/
_50
event.locals.safeGetSession = async () => {
_50
const {
_50
data: { user },
_50
error,
_50
} = await event.locals.supabase.auth.getUser()
_50
if (error) {
_50
return { session: null, user: null }
_50
}
_50
_50
const {
_50
data: { session },
_50
} = await event.locals.supabase.auth.getSession()
_50
return { session, user }
_50
}
_50
_50
return resolve(event, {
_50
filterSerializedResponseHeaders(name) {
_50
return name === 'content-range'
_50
},
_50
})
_50
}

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

src/app.d.ts

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

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

src/routes/+layout.server.ts

_11
// src/routes/+layout.server.ts
_11
import type { LayoutServerLoad } from './$types'
_11
_11
export const load = (async ({ locals: { safeGetSession } }) => {
_11
const { session, user } = await safeGetSession()
_11
_11
return {
_11
session,
_11
user,
_11
}
_11
}) satisfies LayoutServerLoad

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

_35
// src/routes/+layout.ts
_35
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
_35
import type { LayoutLoad } from './$types'
_35
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr'
_35
_35
export const load = (async ({ fetch, data, depends }) => {
_35
depends('supabase:auth')
_35
_35
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
_35
global: {
_35
fetch,
_35
},
_35
cookies: {
_35
get(key) {
_35
if (!isBrowser()) {
_35
return JSON.stringify(data.session)
_35
}
_35
_35
const cookie = parse(document.cookie)
_35
return cookie[key]
_35
},
_35
},
_35
})
_35
_35
/**
_35
* It's fine to use `getSession` here, because on the client, `getSession` is
_35
* safe, and on the server, it reads `session` from the `LayoutData`, which
_35
* safely checked the session using `safeGetSession`.
_35
*/
_35
const {
_35
data: { session },
_35
} = await supabase.auth.getSession()
_35
_35
return { supabase, session }
_35
}) satisfies LayoutLoad

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: { safeGetSession } }) => {
_14
const { session } = await safeGetSession()
_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

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


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

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
const { 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>

At this stage you have a fully functional application!