Files
supabase/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx
T
Charis 2709fa4a3e feat: pre-compile-time partials (#34028)
Partials are currently defined via MDX includes. This PR switches to pre-compile-time partials, which have a new syntax:

```
<$Partial path="path/to/file.mdx" />
```

## Rationale

This produces two improvements:

1. Partial substitution can occur in pipelines that don't use MDX compilation. For example, we can now do partial substitution before building the search index, so partial content will also be indexed.
2. After the App Router migration, the MDXProviders should've been deprecated, but were kept around for the sole reason of making partials work, and leading to us shipping unnecessary client-side code. We get a minor decrease in overall client bundle size (5.74 MB to 5.6 MB) by getting rid of the Providers.

## Breaking changes

Besides the change to partial syntax, the arguments are also less powerful than before because we are doing string substitution and don't have the full power of JS. Defining string variables is still possible (documented in the Contributing guide), and since that's all we actually do in practice, this shouldn't be too cumbersome. There is always the escape hatch of making a custom component for more complex content reuse cases.
2025-03-18 10:37:39 -04:00

729 lines
19 KiB
Plaintext

---
title: 'Build a User Management App with SvelteKit'
description: 'Learn how to use Supabase in your SvelteKit App.'
---
<$Partial path="quickstart_intro.mdx" />
![Supabase User Management example](/docs/img/user-management-demo.png)
<Admonition type="note">
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/sveltekit-user-management).
</Admonition>
<$Partial path="project_setup.mdx" />
## Building the app
Let's start building the Svelte app from scratch.
### Initialize a Svelte app
We can use the [SvelteKit Skeleton Project](https://kit.svelte.dev/docs) to initialize an app called `supabase-sveltekit` (for this tutorial we will be using TypeScript):
```bash
npm create svelte@latest supabase-sveltekit
cd supabase-sveltekit
npm install
```
Then install the Supabase client library: [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 `SUPABASE_URL` and the `SUPABASE_KEY` key that you copied [earlier](#get-the-api-keys).
```bash .env
PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"
PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_KEY"
```
Optionally, add `src/styles.css` with the [CSS from the example](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/sveltekit-user-management/src/styles.css).
### 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:
```bash
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:
```ts src/hooks.server.ts
// src/hooks.server.ts
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
import { createServerClient } from '@supabase/ssr'
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll: () => event.cookies.getAll(),
/**
* SvelteKit's cookies API requires `path` to be explicitly set in
* the cookie options. Setting `path` to `/` replicates previous/
* standard behavior.
*/
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' })
})
},
},
})
/**
* Unlike `supabase.auth.getSession()`, which returns the session _without_
* validating the JWT, this function also calls `getUser()` to validate the
* JWT before returning the session.
*/
event.locals.safeGetSession = async () => {
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
if (!session) {
return { session: null, user: null }
}
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser()
if (error) {
// JWT validation has failed
return { session: null, user: null }
}
return { session, user }
}
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range' || name === 'x-supabase-api-version'
},
})
}
```
<$Partial path="get_session_warning.mdx" />
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:
```ts src/app.d.ts
// src/app.d.ts
import { SupabaseClient, Session } from '@supabase/supabase-js'
declare global {
namespace App {
interface Locals {
supabase: SupabaseClient
safeGetSession(): Promise<{ session: Session | null; user: User | null }>
}
interface PageData {
session: Session | null
user: User | null
}
// interface Error {}
// interface Platform {}
}
}
```
Create a new `src/routes/+layout.server.ts` file to handle the session on the server-side.
```ts src/routes/+layout.server.ts
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
const { session, user } = await safeGetSession()
return {
session,
user,
cookies: cookies.getAll(),
}
}
```
<Admonition type="tip">
Start your dev server (`npm run dev`) in order to generate the `./$types` files we are referencing in our project.
</Admonition>
Create a new `src/routes/+layout.ts` file to handle the session and the `supabase` object on the client-side.
```ts src/routes/+layout.ts
// src/routes/+layout.ts
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
import type { LayoutLoad } from './$types'
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
depends('supabase:auth')
const supabase = isBrowser()
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch,
},
})
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch,
},
cookies: {
getAll() {
return data.cookies
},
},
})
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
* safe, and on the server, it reads `session` from the `LayoutData`, which
* safely checked the session using `safeGetSession`.
*/
const {
data: { session },
} = await supabase.auth.getSession()
return { supabase, session }
}
```
Update your `src/routes/+layout.svelte`:
```svelte src/routes/+layout.svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import '../styles.css'
import { invalidate } from '$app/navigation'
import { onMount } from 'svelte'
export let data
let { supabase, session } = data
$: ({ supabase, session } = data)
onMount(() => {
const { data } = supabase.auth.onAuthStateChange((event, newSession) => {
if (newSession?.expires_at !== session?.expires_at) {
invalidate('supabase:auth')
}
})
return () => data.subscription.unsubscribe()
})
</script>
<svelte:head>
<title>User Management</title>
</svelte:head>
<div class="container" style="padding: 50px 0 100px 0">
<slot />
</div>
```
## Set up a login page
Create a magic link login/signup page for your application:
```svelte src/routes/+page.svelte
<!-- src/routes/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms'
import type { ActionData, SubmitFunction } from './$types.js'
export let form: ActionData;
let loading = false
const handleSubmit: SubmitFunction = () => {
loading = true
return async ({ update }) => {
update()
loading = false
}
}
</script>
<svelte:head>
<title>User Management</title>
</svelte:head>
<form class="row flex flex-center" method="POST" use:enhance={handleSubmit}>
<div class="col-6 form-widget">
<h1 class="header">Supabase + SvelteKit</h1>
<p class="description">Sign in via magic link with your email below</p>
{#if form?.message !== undefined}
<div class="success {form?.success ? '' : 'fail'}">
{form?.message}
</div>
{/if}
<div>
<label for="email">Email address</label>
<input
id="email"
name="email"
class="inputField"
type="email"
placeholder="Your email"
value={form?.email ?? ''}
/>
</div>
{#if form?.errors?.email}
<span class="flex items-center text-sm error">
{form?.errors?.email}
</span>
{/if}
<div>
<button class="button primary block">
{ loading ? 'Loading' : 'Send magic link' }
</button>
</div>
</div>
</form>
```
Create a `src/routes/+page.server.ts` file that will handle our magic link form when submitted.
```ts src/routes/+page.server.ts
// src/routes/+page.server.ts
import { fail, redirect } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ url, locals: { safeGetSession } }) => {
const { session } = await safeGetSession()
// if the user is already logged in return them to the account page
if (session) {
redirect(303, '/account')
}
return { url: url.origin }
}
export const actions: Actions = {
default: async (event) => {
const {
url,
request,
locals: { supabase },
} = event
const formData = await request.formData()
const email = formData.get('email') as string
const validEmail = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/.test(email)
if (!validEmail) {
return fail(400, { errors: { email: 'Please enter a valid email address' }, email })
}
const { error } = await supabase.auth.signInWithOtp({ email })
if (error) {
return fail(400, {
success: false,
email,
message: `There was an issue, Please contact support.`,
})
}
return {
success: true,
message: 'Please check your email for a magic link to log into the website.',
}
},
}
```
### Email template
Change the email template to support a server-side authentication flow.
Before we proceed, let's change the email template to support sending a token hash:
- Go to the [Auth templates](/dashboard/project/_/auth/templates) page in your dashboard.
- Select `Confirm signup` template.
- Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`.
- Repeat the previous step for `Magic link` template.
<Admonition type="tip">
Did you know? You could also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates).
</Admonition>
### Confirmation endpoint
As we are working in a server-side rendering (SSR) environment, it is necessary to create a server endpoint responsible for exchanging the `token_hash` for a session.
In the following code snippet, we perform the following steps:
- Retrieve the `token_hash` sent back from the Supabase Auth server using the `token_hash` query parameter.
- Exchange this `token_hash` for a session, which we store in storage (in this case, cookies).
- Finally, the user is redirected to the `account` page or the `error` page.
```ts src/routes/auth/confirm/+server.ts
// src/routes/auth/confirm/+server.ts
import type { EmailOtpType } from '@supabase/supabase-js'
import { redirect } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async ({ url, locals: { supabase } }) => {
const token_hash = url.searchParams.get('token_hash')
const type = url.searchParams.get('type') as EmailOtpType | null
const next = url.searchParams.get('next') ?? '/account'
/**
* Clean up the redirect URL by deleting the Auth flow parameters.
*
* `next` is preserved for now, because it's needed in the error case.
*/
const redirectTo = new URL(url)
redirectTo.pathname = next
redirectTo.searchParams.delete('token_hash')
redirectTo.searchParams.delete('type')
if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({ type, token_hash })
if (!error) {
redirectTo.searchParams.delete('next')
redirect(303, redirectTo)
}
}
redirectTo.pathname = '/auth/error'
redirect(303, redirectTo)
}
```
### Authentication error page
If there is an error with confirming the token you will be redirect to this error page.
```svelte src/routes/auth/error/+page.svelte
<p>Login error</p>
```
### 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.
```svelte src/routes/account/+page.svelte
<!-- src/routes/account/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
export let data
export let form
let { session, supabase, profile } = data
$: ({ session, supabase, profile } = data)
let profileForm: HTMLFormElement
let loading = false
let fullName: string = profile?.full_name ?? ''
let username: string = profile?.username ?? ''
let website: string = profile?.website ?? ''
let avatarUrl: string = profile?.avatar_url ?? ''
const handleSubmit: SubmitFunction = () => {
loading = true
return async () => {
loading = false
}
}
const handleSignOut: SubmitFunction = () => {
loading = true
return async ({ update }) => {
loading = false
update()
}
}
</script>
<div class="form-widget">
<form
class="form-widget"
method="post"
action="?/update"
use:enhance={handleSubmit}
bind:this={profileForm}
>
<div>
<label for="email">Email</label>
<input id="email" type="text" value={session.user.email} disabled />
</div>
<div>
<label for="fullName">Full Name</label>
<input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />
</div>
<div>
<label for="username">Username</label>
<input id="username" name="username" type="text" value={form?.username ?? username} />
</div>
<div>
<label for="website">Website</label>
<input id="website" name="website" type="url" value={form?.website ?? website} />
</div>
<div>
<input
type="submit"
class="button block primary"
value={loading ? 'Loading...' : 'Update'}
disabled={loading}
/>
</div>
</form>
<form method="post" action="?/signout" use:enhance={handleSignOut}>
<div>
<button class="button block" disabled={loading}>Sign Out</button>
</div>
</form>
</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.
```ts
import { fail, redirect } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession()
if (!session) {
redirect(303, '/')
}
const { data: profile } = await supabase
.from('profiles')
.select(`username, full_name, website, avatar_url`)
.eq('id', session.user.id)
.single()
return { session, profile }
}
export const actions: Actions = {
update: async ({ request, locals: { supabase, safeGetSession } }) => {
const formData = await request.formData()
const fullName = formData.get('fullName') as string
const username = formData.get('username') as string
const website = formData.get('website') as string
const avatarUrl = formData.get('avatarUrl') as string
const { session } = await safeGetSession()
const { error } = await supabase.from('profiles').upsert({
id: session?.user.id,
full_name: fullName,
username,
website,
avatar_url: avatarUrl,
updated_at: new Date(),
})
if (error) {
return fail(500, {
fullName,
username,
website,
avatarUrl,
})
}
return {
fullName,
username,
website,
avatarUrl,
}
},
signout: async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession()
if (session) {
await supabase.auth.signOut()
redirect(303, '/')
}
},
}
```
### Launch!
Now that we have all the pages in place, run this in a terminal window:
```bash
npm run dev
```
And then open the browser to [localhost:5173](http://localhost:5173) and you should see the completed app.
![Supabase Svelte](/docs/img/supabase-svelte-demo.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
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:
```svelte src/routes/account/Avatar.svelte
<!-- src/routes/account/Avatar.svelte -->
<script lang="ts">
import type { SupabaseClient } from '@supabase/supabase-js'
import { createEventDispatcher } from 'svelte'
export let size = 10
export let url: string
export let supabase: SupabaseClient
let avatarUrl: string | null = null
let uploading = false
let files: FileList
const dispatch = createEventDispatcher()
const downloadImage = async (path: string) => {
try {
const { data, error } = await supabase.storage.from('avatars').download(path)
if (error) {
throw error
}
const url = URL.createObjectURL(data)
avatarUrl = url
} catch (error) {
if (error instanceof Error) {
console.log('Error downloading image: ', error.message)
}
}
}
const uploadAvatar = async () => {
try {
uploading = true
if (!files || files.length === 0) {
throw new Error('You must select an image to upload.')
}
const file = files[0]
const fileExt = file.name.split('.').pop()
const filePath = `${Math.random()}.${fileExt}`
const { error } = await supabase.storage.from('avatars').upload(filePath, file)
if (error) {
throw error
}
url = filePath
setTimeout(() => {
dispatch('upload')
}, 100)
} catch (error) {
if (error instanceof Error) {
alert(error.message)
}
} finally {
uploading = false
}
}
$: if (url) downloadImage(url)
</script>
<div>
{#if avatarUrl}
<img
src={avatarUrl}
alt={avatarUrl ? 'Avatar' : 'No image'}
class="avatar image"
style="height: {size}em; width: {size}em;"
/>
{:else}
<div class="avatar no-image" style="height: {size}em; width: {size}em;" />
{/if}
<input type="hidden" name="avatarUrl" value={url} />
<div style="width: {size}em;">
<label class="button primary block" for="single">
{uploading ? 'Uploading ...' : 'Upload'}
</label>
<input
style="visibility: hidden; position:absolute;"
type="file"
id="single"
accept="image/*"
bind:files
on:change={uploadAvatar}
disabled={uploading}
/>
</div>
</div>
```
### Add the new widget
And then we can add the widget to the Account page:
```svelte src/routes/account/+page.svelte
<!-- src/routes/account/+page.svelte -->
<script lang="ts">
// Import the new component
import Avatar from './Avatar.svelte'
</script>
<div class="form-widget">
<form
class="form-widget"
method="post"
action="?/update"
use:enhance={handleSubmit}
bind:this={profileForm}
>
<!-- Add to body -->
<Avatar
{supabase}
bind:url={avatarUrl}
size={10}
on:upload={() => {
profileForm.requestSubmit();
}}
/>
<!-- Other form elements -->
</form>
</div>
```
At this stage you have a fully functional application!