DEV Community

Cover image for Supabase SSR Auth with SvelteKit
kvetoslavnovak
kvetoslavnovak

Posted on • Updated on

Supabase SSR Auth with SvelteKit

EDIT: Having discussion with Supabase concerning best parctices. My actual recomended way can be followd here The SvelteKit SSR Documentation Examples Enhancements #742

EDIT 2: Supabase recently updated their official documentation accordingly.

Supabase recently introduced a @supabase/ssr package instead of their @supabase/supabase-auth-helpers packager. Supabase generally recommends using the new @supabase/ssr package which takes the core concepts of the Auth Helpers package and makes them available to any server framework. The Supabase Auth Helpers will be probably deprecated later on.

@supabase/ssr package is ment to be used easily with any framework wich includes backend such as Next.js, SvelteKit, Astro, Remix or Express. This was the main idea of Supabase to have just one general easier to maintain package which was not the case for Auth Helpers.

This tutorial walks you through the process how to use @supabase/ssr package with Sveltekit. The implementation is very easy, smooth and rather straightforward.

SvelteKit 2 vs SvelteKit 1

This tutorial is rewritten now for SvelteKit 2.x. If you are still using SvelteKit 1 you will need to add throw before redirects and before errors. In SvelteKit 2 redirect and error are no longer thrown by you.

From my own experience using npx svelte-migrate@latest sveltekit-2 for migration to SvelteKit 2 clears all this throw nicely and automaticaly for you.

One significant change is in hooks.server.js, src/routes/+layout.server.js and src/routes/delete_user/+page.server.js files. I show examples for both versions of SvelteKit. The case here is that path is required when setting or deleting cookies.

Create SvelteKit Project

Create the SvelteKit app and name it for example "my-sk-app-with-sb-ssr-auth".

npm create svelte@latest my-sk-app-with-sb-ssr-auth
cd my-sk-app-with-sb-ssr-auth
npm install
Enter fullscreen mode Exit fullscreen mode

Now install relevant Supabase packages:

npm install @supabase/ssr @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

Create Supabase project

If you do not have your Supabase project create the new one. Just follow the instructions on https://supabase.com/ and start the new project. From your Project Settings dashboard in section API details copy SUPABASE_URL and SUPABASE_ANON_KEY keys which are to be used in a front end of your application.

SUPABASE_URL and SUPABASE_ANON_KEY keys

Public Variables

Create a .env.local file in your SvelteKit project root directory. Use your SUPABASE_URL and SUPABASE_ANON_KEY keys whcih you have just copied from your Supabase project's dashboard.

# .env.local
PUBLIC_SUPABASE_URL=your_supabase_project_url
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
Enter fullscreen mode Exit fullscreen mode

Creating Supabase createServerClient in Hooks

In your SvelteKit project root directory create a hooks.server.js file. In this file we are settig up Supabase server client using imported env keys. 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. It just works! API routes, server layout load and form actions can now access the supabase client from the event object due to this hook.

EDIT: Supabase has added important warning concerning SvelteKit: Beware when accessing the session object on the server, because it is not revalidated on every request from the client. That means the sender can tamper with unencoded data in the session object. If you need to verify the integrity of user data for server logic, call auth.getUser instead, which will query the Supabase Auth server for trusted user data.

This risk can be removed if you call getUser() as you can see hereunder.

// SvelteKit v2
// hooks.server.js
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
import { createServerClient } from '@supabase/ssr'

export const handle = async ({ event, resolve }) => {
  event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
    cookies: {
     get: (key) => event.cookies.get(key),
      /**
       * Note: You have to add the `path` variable to the
       * set and remove method due to sveltekit's cookie API
       * requiring this to be set, setting the path to '/'
       * will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
       */
      set: (key, value, options) => {
        event.cookies.set(key, value, { ...options, path: '/' })
      },
      remove: (key, options) => {
        event.cookies.delete(key, { ...options, path: '/' })
      },
    },
  })

  /**
   * a little helper that is written for convenience so that instead
   * of calling `const { data: { session } } = await supabase.auth.getSession()`
   * you just call this `await getSession()`
   */
  event.locals.getSession = async () => {    
  /**
   * getUser will guarantee that the stored session is valid,
   * and calling getSession immediately after 
   * will leave no room for anyone to modify the stored session. 
   */
const { data: getUserData, error: err }  = await event.locals.supabase.auth.getUser()

    let {
      data: { session },
    } = await event.locals.supabase.auth.getSession()

    // solving the case if the user was deleted from the database but the browser still has a cookie/loggedin user
    // +lauout.server.js will delete the cookie if the session is null
    if (getUserData.user == null) {
      session = null
    }

    return session
  }

   return resolve(event, {
    filterSerializedResponseHeaders(name) {
      return name === 'content-range'
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

If you are still using SvelteKit v1 path was not required when setting cookies.

// SvelteKit v1
// hooks.server.js
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
import { createServerClient } from '@supabase/ssr'

export const handle = async ({ event, resolve }) => {
  event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
    cookies: {
      get: (key) => event.cookies.get(key),
      set: (key, value, options) => {
        event.cookies.set(key, value, options)
      },
      remove: (key, options) => {
        event.cookies.delete(key, options)
      },
    },
  })

  /**
   * a little helper that is written for convenience so that instead
   * of calling `const { data: { session } } = await supabase.auth.getSession()`
   * you just call this `await getSession()`
   */
  event.locals.getSession = async () => {
  /**
   * getUser will guarantee that the stored session is valid,
   * and calling getSession immediately after 
   * will leave no room for anyone to modify the stored session. 
   */
const { data: getUserData, error: err }  = await event.locals.supabase.auth.getUser()

    let {
      data: { session },
    } = await event.locals.supabase.auth.getSession()

    // solving the case if the user was deleted from the database but the browser still has a cookie/loggedin user
    // +lauout.server.js will delete the cookie if the session is null
    if (getUserData.user == null) {
      session = null
    }

    return session
  }

   return resolve(event, {
    filterSerializedResponseHeaders(name) {
      return name === 'content-range'
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

Returning Session from Root Server Layout

Create +layout.server.js file in the routes directory. This file will just pass the session with a respective user.

// src/routes/+layout.server.js
export const load = async (event) => {
    let session = await event.locals.getSession();
    return {
        session
    };
};
Enter fullscreen mode Exit fullscreen mode

Creating Supabase createBrowserClient in Root Layout Load

Now we will create +layout.js file in a routes directory. In this file we will set up Supabase browser client. Page components can get access to the Supabase client from the data object due to this load function.

Note that you may not need the Supabase browser client if you do all the stuff server side.

The very important part of the code is to use depends('supabase:auth'). 'supabase:auth' is just an identifier, you can name it however you want. If later on we will need to reload this load function (particularly when user's auth state changes) we can do so using invalidate function after such a change with this identifier 'supabase:auth' as an argument (i.e. invalidate('supabase:auth')). You will see us using this feature in src/routes/+layout.svelte later on.

// src/routes/+layout.js
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
import { combineChunks, createBrowserClient, isBrowser, parse } from '@supabase/ssr'

export const load = async ({ fetch, data, depends }) => {
  depends('supabase:auth')

  const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
    global: {
      fetch,
    },
    cookies: {
      get(key) {
        if (!isBrowser()) {
          return JSON.stringify(data.session)
        }

        const cookie = combineChunks(key, (name) => {
          const cookies = parse(document.cookie)
          return cookies[name]
        })
        return cookie
      },
    },
  })

  const {
    data: { session },
  } = await supabase.auth.getSession()

  return { supabase, session }
}
Enter fullscreen mode Exit fullscreen mode

Routes Layout Page

It seems as a good practice to use SvelteKit layout page as a login/logout header navigation. I have added just a little bit of css to move loginn/logout nav to the top right corner.

The important stuff is to add onMout and listen to the onAuthStateChange of Supabase (mainly to listen to the events when user logs in or out).

If such changes happen we will invalidate all relevant load functions.

One way is to put into such load function depends function. In this depends functio we may specify on which invalidation it dependes. So in our case it depends on "supabase:auth" (i.e. depends('supabase:auth'). We have already done so in load function in a root layout.js file as you can see above. Then in svelte file where we need invalidation in matter to run we can call named invalidation (i.e. invalidate('supabase:auth')). You should carefully decide what is linked to what. Root +layout.js and +layout.svelte are probably quite top level enough.

The other more general and bulletproof attitude is to use invalidateAll(). You put it in svelte file when some change may happen and thus reload all load functions of your application. Without bothering to guess to which load function you should add that it depends on such a change.

As auth is quite sensitive I would rather recommend to call more general invalidateAll() function instead, just to be sure all load functions are rerun.

In example code bellow I am evem using both invalidations.

In any case invalidation will sync server Supabase client and the browser Supabase client as the load function(s) will rerun. So you will update/sync the session state as well all browser tabs where you may run the app.

The layout has the logout form for logged in user as well and calls the repsective logout route/action. The relevant endpoint in /logout route is descibed in a secton Route with Logout Logic.

I am also adding client logout function for enhance.

// src/routes/+layout.svelte
<script>
    import { enhance } from '$app/forms';
    import { invalidate, invalidateAll, goto } from '$app/navigation';
    import { onMount } from 'svelte';

    export let data;

    $: ({ supabase } = data);

    onMount(async () => {
    const { data: { subscription } } = supabase.auth.onAuthStateChange((event, _session) => {
// If you want to fain grain which routes should rerun their load function 
// when onAuthStateChange changges
// use invalidate('supabase:auth') 
// which is linked to +layout.js depends('supabase:auth'). 
// This should mainly concern all routes 
//that should be accesible only for logged in user. 
// Otherwise use invalidateAll() 
// which will rerun every load function of you app.
            invalidate('supabase:auth');
            invalidateAll();
        });
        return () => subscription.unsubscribe();
    });

    const submitLogout = async ({ cancel }) => {
        const { error } = await data.supabase.auth.signOut();
        if (error) {
            console.log(error);
        }
        cancel();
        await goto('/');
    };
</script>

<a href="/">Home</a>
<a href="/subscription">Subscriptions</a>

<span id="auth_header">
    {#if !data.session}
        <a href="/login">login</a> / <a href="/register">signup</a>
    {:else}
        <a href="/user_profile">User profile</a>
        <form action="/logout?/logout" method="POST" use:enhance={submitLogout}>
            <button type="submit">Logout</button>
        </form>
    {/if}
</span>
<slot />

<style>
    #auth_header {
        float: right;
    }
    form {
        display: inline;
    }
</style>

Enter fullscreen mode Exit fullscreen mode

Email Auth with PKCE flow for SSR

We will use email authentication. In order to use the updated email links we will need to setup an endpoint for verifying the token_hash along with the type to exchange token_hash for the user's session which is set as a cookie for future requests made to Supabase. This endpoint will be used mainly for auth emails connfirmations.

Create a new file at src/routes/auth/confirm/+server.js and populate with the following:

// src/routes/auth/confirm/+server.js
import { redirect } from '@sveltejs/kit';

export const GET = async (event) => {
    const {
        url,
        locals: { supabase }
    } = event;
    const token_hash = url.searchParams.get('token_hash');
    const type = url.searchParams.get('type');
    const next = url.searchParams.get('next') ?? '/';

  if (token_hash && type) {
    const { error } = await supabase.auth.verifyOtp({ token_hash, type });
    if (!error) {
      redirect(303, `/${next.slice(1)}`);
    }
  }

  // return the user to an error page with some instructions
  redirect(303, '/auth/auth-code-error');
};

Enter fullscreen mode Exit fullscreen mode

And for convenience we may provide the herebaove mentioned auth-code-error error page.

// src/routes/auth/auth-code-error/+page.svelte
There was some logging error. 
Enter fullscreen mode Exit fullscreen mode

Simple Home Page

Create simple home lannding page.

// src/routes/+page.svelte
<h1>  Welcome to this website ...</h1>
Enter fullscreen mode Exit fullscreen mode

Register Route

Finally we will do some logging logic. First of all we will create register page as well as relevant page server file. Notice that we are also conditionaly dsiplaying error message in case the form submission was invalid and errored with 4xx client errors HTTP response status.

// src/routes/register/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form;
  </script>

  <h2>Sign Up</h2>
  <form action="?/register" method="POST" use:enhance>
    <label for="email">email</label>
    <input name="email" type="email" value={form?.email ?? ''} required/>
    <label for="password">password</label>
    <input name="password" required/>       
    <button type="submit">Sign up</button>
  </form>
  {#if form?.invalid}<mark>{form?.message}!</mark>{/if}
Enter fullscreen mode Exit fullscreen mode

And the corresponding +page.server.js file logic is as follows. In a nutshell here we are just calling supabase.auth.signUp() providing it with the email and password, handling possible errors and redirecting user eventually to check email page.

// src/routes/register/+page.server.js
import { fail, redirect } from "@sveltejs/kit"
import { AuthApiError } from '@supabase/supabase-js'

export const actions = {
    register: async (event) => {
        const { request, locals } = event
            const formData = await request.formData()
            const email = formData.get('email')
            const password = formData.get('password')

            const { data, error: err } = await locals.supabase.auth.signUp({
                email: email,
                password: password
            })

            if (err) {
                if (err instanceof AuthApiError && err.status >= 400 && err.status < 500) {
                    return fail(400, {
                        error: "invalidCredentials", email: email, invalid: true, message: err.message
                    })
                }
                return fail(500, {
                    error: "Server error. Please try again later.",
                })
            }
            // signup for existing user returns an obfuscated/fake user object without identities https://supabase.com/docs/reference/javascript/auth-signup
            if (!err && !!data.user && !data.user.identities.length ) {
                return fail(409, {
                    error: "User already exists", email: email, invalid: true, message: "User already exists"
                })
            }
            redirect(303, "/check_email");
    }
}

export async function load({locals: { getSession }}) {
    const session = await getSession();
    // if the user is already logged in return him to the home page
    if (session) {
        redirect(303, '/');
    }
  }
Enter fullscreen mode Exit fullscreen mode

You also need to update Supabase auth email templates.

Supabase auth email templates

Go to your Supabase project dashboard website and in Authentication secition update Confirm signup email template like this.

<h2>Confirm your signup</h2>

<p>Follow this link to confirm your user:</p>
<p>
  <a href="http://localhost:5173/auth/confirm?token_hash={{ .TokenHash }}&type=email"
    >Confirm your email</a>
</p>
Enter fullscreen mode Exit fullscreen mode

N.B. Do not forget to change "http://localhost:5173" to your app website address in all Supabase email templates when you host your app eventually.

Check Email Route

Create check_email route with simple +page.svelte file.

// src/routes/check_email/+page.svelte
<p>Check your email to confirm.</p>
Enter fullscreen mode Exit fullscreen mode

Route with Login Logic

Create login route which will enable user to login.

// src/routes/login/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form;
</script>

<h2>Log in</h2>
<form action="?/login" method="POST" use:enhance>
    <label for="email">email</label>
    <input name="email" type="email" value={form?.email ?? ''} required />
    <label for="password">password</label>
    <input name="password" required />
    <button type="submit">Login</button>
</form>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}

<p>Forgot your password? <a href="/reset_password">Reset password</a></p>
Enter fullscreen mode Exit fullscreen mode

The respective +page.server.js file contains action for login. Simply speaking in case of login we are just calling supabase.auth.signInWithPassword() providing it with email and password, handling possible errors and redirecting user eventually.

// src/routes/login/+page.server.js
import { fail, redirect } from '@sveltejs/kit';
import { AuthApiError } from '@supabase/supabase-js';

export const actions = {
    login: async (event) => {
        const { request, url, locals } = event;
        const formData = await request.formData();
        const email = formData.get('email');
        const password = formData.get('password');

        const { data, error: err } = await locals.supabase.auth.signInWithPassword({
            email: email,
            password: password
        });

        if (err) {
            if (err instanceof AuthApiError && err.status === 400) {
                return fail(400, {
                    error: 'Invalid credentials',
                    email: email,
                    invalid: true,
                    message: err.message
                });
            }
            return fail(500, {
                message: 'Server error. Try again later.'
            });
        }

        redirect(307, '/');
    },
}

export async function load({ locals: { getSession } }) {
    const session = await getSession();
    // if the user is already logged in return him to the home page
    if (session) {
        redirect(303, '/');
    }
}
Enter fullscreen mode Exit fullscreen mode

Route with Logout Logic

In case of logout we are calling supabase.auth.signOut(), which also automatically deletes user cookie, and redirects to home page. There is just the +page.server.js file, this is why I am adding uncoditional redirect and no +page.svelte file. The client form for logout is already in +layout.svelte as mentioned hereabove.

// src/routes/logout/+page.server.js
import { redirect } from '@sveltejs/kit';

export const actions = {
    logout: async ({ locals }) => {
        await locals.supabase.auth.signOut()    
        redirect(303, '/');
    }
}

// we only use this endpoint for the api
// and don't need to see the page
export async function load() {
        redirect(303, '/');
}
Enter fullscreen mode Exit fullscreen mode

Reset Password

Make route for password reset called reset_password, Once again one +page.svelte file and one +page.server.js file.

// src/routes/reset_password/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form
</script>

<h2>Where should we send you a link for password reset?</h2>
<form action="?/reset_password" method="POST" use:enhance>
    <label for="email">email</label>
    <input type="email" name="email" placeholder="name@domain.com" required />
    <button type="submit">Get password</button>
</form>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}
Enter fullscreen mode Exit fullscreen mode

In a nutshell in reset_password/+page.server.js we are just calling supabase.auth.resetPasswordForEmail() providing it with email and route of a password update page, handling possible errors and redirecting user eventually.

// src/routes/reset_password/+page.server.js
import { fail, redirect } from "@sveltejs/kit"
import { AuthApiError } from "@supabase/supabase-js"

export const actions = {
    reset_password: async ({ request, locals }) => {
        const formData = await request.formData()
        const email = formData.get('email')

        const { data, error: err } = await locals.supabase.auth.resetPasswordForEmail(
            email, 
            {redirectTo: '/update_password'}
        )

        if (err) {
            if (err instanceof AuthApiError && err.status === 400) {
                return fail(400, {
                    error: "invalidCredentials", email: email, invalid: true, message: err.message
                })
            }
            return fail(500, {
                error: "Server error. Please try again later.",
            })
        }

        redirect(303, "/check_email");
    },
}

export async function load({locals: { getSession } }) {
    const session = await getSession();
    // if the user is already logged in return him to the home page
    if (session) {
        redirect(303, '/');
    }
  }
Enter fullscreen mode Exit fullscreen mode

The Supabase email template for Reset password looks like this. The link will send the user to /update_password of our application.

<h2>Reset Password</h2>

<p>Follow this link to reset the password for your user:</p>
<p>
  <a
    href="http://localhost:5173/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next=/update_password"
    >Reset Password</a
  >
</p>
Enter fullscreen mode Exit fullscreen mode

Update Password Route

As already mentioned resetting password needs the route update_password where the user may insert her/his new password. Lets create this update_password route.

// src/routes/update_password/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form
</script>

<h2>Change your password</h2>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}
<form action="?/update_password" method="POST" use:enhance>
    <label for="new_password"> New password </label>
    <input name="new_password" required/>   
    <label for="password_confirm">Confirm new password</label>
    <input name="password_confirm" required/>       
    <button>Update password</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Simply speaking here we are just calling supabase.auth.updateUser() providing it with new password, handling possible errors and redirecting user eventually to her/his profile page.

// src/routes/update_password/+page.server.js
import { AuthApiError } from "@supabase/supabase-js"
import { fail, redirect } from "@sveltejs/kit"

export const actions = {
    update_password: async ({ request, locals }) => {
        const formData = await request.formData()
        const password = formData.get('new_password')

        const { data, error: err } = await locals.supabase.auth.updateUser({
            password
        })

        if (err) {
            if (err instanceof AuthApiError && err.status >= 400 && err.status < 500) {
                return fail(400, {
                    error: "invalidCredentials", invalid: true, message: err.message
                })
            }
            return fail(500, {
                error: "Server error. Please try again later.",
            })
        }

        redirect(303, "/user_profile");
    },
}

export async function load({locals: { getSession } }) {
    const session = await getSession();
    // if the user is not logged in redirect back to the home page
    if (!session) {
        redirect(303, '/');
    }
  }
Enter fullscreen mode Exit fullscreen mode

Update Email Route

User may wish to update her/his emial so here is the update_email route to do this. Remeber the confirmaton from both emails (the old one as well as the new one) has to be provided.

// src/routes/update_email/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form
</script>

<h2>Change your email</h2>
<form action="?/update_email" method="POST" use:enhance>
    <label for="email"> new email </label>
    <input type="email" name="email" required />
    <button>Change email</button>
</form>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}
Enter fullscreen mode Exit fullscreen mode

In a nutshell here we are just calling supabase.auth.updateUser() this time providing it with the new email, handling possible errors and redirecting user to her/his profile page eventually.

// src/routes/update_email/+page.server.js
import { AuthApiError } from "@supabase/supabase-js"
import { fail, redirect } from "@sveltejs/kit"

export const actions = {
    update_email: async ({ request, locals }) => {
        const formData = await request.formData()
        const email = formData.get('email')

        const { data, error: err } = await locals.supabase.auth.updateUser({
            email
         })

          if (err) {
            if (err instanceof AuthApiError && err.status >= 400 && err.status < 500) {
                return fail(400, {
                    error: "invalidCredentials", invalid: true, message: err.message
                })
            }
            return fail(500, {
                error: "Server error. Please try again later.",
            })
        }

        redirect(303, "/check_email");
    },
}

export async function load({locals: { getSession } }) {
    const session = await getSession();
    // if the user is not logged in redirect back to the home page
    if (!session) {
        redirect(303, '/');
    }
  }
Enter fullscreen mode Exit fullscreen mode

And the Supabase email template for Change Email Address looks like this.

<h2>Confirm Change of Email</h2>

<p>Follow this link to confirm the update of your email from {{ .Email }} to {{ .NewEmail }}:</p>
<p>
  <a href="http://localhost:5173/auth/confirm?token_hash={{ .TokenHash }}&type=email_change">
    Change Email
  </a>
</p>
Enter fullscreen mode Exit fullscreen mode

User Profile Route

We are still missing user profile route where user can manage the account. Let create user_profile route with respective files.

// src/routes/user_profile/+page.svelte
<script>
    export let data
</script>

<h2>User profile</h2>
{data.session.user.email}
<p><a href="/update_email">Change your email</a></p>
<p><a href="/update_password">Change password</a></p>
<p><a href="/delete_user">Delete my account</a></p>
Enter fullscreen mode Exit fullscreen mode

The page should be accesible only to logged in user I guess.

// src/routes/user_profile/+page.server.js
import { redirect } from "@sveltejs/kit"

export async function load({locals: { getSession } }) {
    const session = await getSession();
    // if the user is not logged in redirect back to the home page
    if (!session) {
        redirect(303, '/');
    }
  }
Enter fullscreen mode Exit fullscreen mode

Delete User Account Route

The opinions may differ wheter we should enable user to delete her/his account. But as this may seem tricky in Supabase here is the way. Lets make delete_user route.

// src/routes/delete_user/+page.svelte
<script>
    import { enhance } from '$app/forms';
    export let form
</script>

<h2>Delete your user account</h2>
<form action="?/delete_user" method="POST" use:enhance>
    <button type="submit">Delete my user account</button>
</form>
{#if form?.invalid}<mark>{form?.message}!</mark>{/if}
Enter fullscreen mode Exit fullscreen mode

Supbase uses special auth client created with secrete service role key to delete user. But the client created with the service role key has really high superuser/admin priviledges. If you do not want to deal with this mighty key here is a trick.

In Supabase dashboard go to SQL Editor.

Supabase SQL Editor

In the SQL Editor paste in and run this function.

    CREATE or replace function delete_user()
    returns void
    LANGUAGE SQL SECURITY DEFINER
    AS $$
    --delete from public.profiles where id = auth.uid();
    delete from auth.users where id = auth.uid();
    $$;
Enter fullscreen mode Exit fullscreen mode

Now you can use this Supabase database delete_user() function from server using supabase.rpc method with the name of this delete_user database function as an argument like this.

// src/routes/delete_user/+page.server.js
import { redirect } from "@sveltejs/kit"

export const actions = {
    delete_user: async ({ locals, request,  cookies }) => {
    const storageKey = locals.supabase.storageKey

    await locals.supabase.rpc('delete_user');
cookies.delete(storageKey, { path: '/' });
    redirect(303, "/");
    }
}

export async function load({locals: { getSession } }) {
    const session = await getSession();
    // if the user is not logged in redirect back to the home page
    if (!session) {
        redirect(303, '/');
    }
  }
Enter fullscreen mode Exit fullscreen mode

It is also important to delete user cookie. The name of the cookie can be found in data.supabase.storageKey. We have sent its name from client through a hidden input hereabove. Because of this cookie deletion application kicks user out from all pages where session is requested.

Project Structure Overview

Here goes project structure of all the +page.svelte and +page.server.js files tree printscreen. (There are also routes for Stripe subscription so don't get confused)

SvelteKit Supabase SSR project structure

Thank You for Reading

So this is it. Feel free to comment if something does not work for you. And if you find this tutorial useful at least a little please give it a like.

You may move protected routes logic to one place (probably into hooks) for example not to repeate it in relevant +page.server.js files.

I hope to post something soon, been busy this year with a SvelteKit project but now it is nearly done so more time for blogging.

EDIT December 11, 2023:
Added usecase for signup/registration of already exising user.
Added usecase to clear cookie if the user was deleted in the database.

EDIT December 12, 2023:
As some of you have noticed there was addUserprofileToUser() helper function. I deleted this part of the code to avoid any confusion. This addUserprofileToUser() utility function is used to enrich user data in her/his session. The reason is that Supabase does not allow you to add any data to Authetication table. For that you have to create your new table (for example using a name "user_profile") with its primary key (id) referencing Authetication table id.

My addUserprofileToUser() function looks like this:

export default async function addUserprofileToUser (session, supabase) {
    if (session) {
      let { data, error } = await supabase
        .from('user_profile')
        .select("*")
        .eq('id', session.user.id)
        .single()
      session.user.user_profile = data
    }
  }
Enter fullscreen mode Exit fullscreen mode

The addUserprofileToUser function simply gets the data from the user_profile table for the particular user and enriches her/his session. I call this function await addUserprofileToUser(session,event.locals.supabase) in hooks.server.js and then again in +layout.server.js because for some reason Supbase SSR Auth seems to somehow strip the profile data from the sesson after hooks file runs.

In the profile table there is for example Stripe id of the user, her/his subpscription plan etc. But this Stripe SaaS features would need the whole new tutorial :-) Maybe next time.

EDIT February 7, 2024:
Update to help migrating to SvelteKit v2.

EDIT February 11, 2024:
I have updated the tutorial to fully implement invalidate('supabase:auth');. Added separate logout route as well. So client part of the applicatoin refreshes relevant load functions correctly when needed and all is in sync including app opened in more browser tabs.

Top comments (25)

Collapse
 
maxkozlowski profile image
Maksymilian Kozlowski

Awesome article, thank you!

Have you tried doing OAuth? Any chance you could add this to the article too?

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Thank you for your interest.
I definitely plan to look into OAuth as well.

Collapse
 
uw profile image
uwork

Something I noticed when duplicating a logged-in session on 2 browser tabs. If I log out on the second tab and click around on the first tab, the behavior is 'interesting'.

  • I observed the cookie is cleared so the user session is gone as expected in both tabs
  • On the first tab: If I refresh the page from the profile page or one of its sub-pages, it redirects back to the home page and updates the nav bar properly as expected.
  • On the first tab: if I am on the profile page and click a sub-page (eg: change password) the body of the page is updated (redirected to home) but the nav bar is not updated and still shows the 'Logout' button. Any idea what's causing this difference in behavior?
Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
uw profile image
uwork

I am still seeing the same behavior on the browser (logout button is still visible). I added a console.log on onMount and noticed it doesn't get printed when following this sequence of actions.

Not sure if related, I am using TS, and the IDE warns me on the line onMount(async () => {
that:
Svelte: Argument of type  () => Promise<() => void>  is not assignable to parameter of type  () => (() => any) | Promise<never> 

Thread Thread
 
Sloan, the sloth mascot
Comment deleted
 
Sloan, the sloth mascot
Comment deleted
 
uw profile image
uwork

Thank you for your efforts. I'll take a look!

Thread Thread
 
Sloan, the sloth mascot
Comment deleted
 
uw profile image
uwork • Edited

Hey, this video explains this problem well and suggests a solution:
Hope it helps anyone else as well!

To reload the layout view after clicking on a navigation item do this:

<a href="/sub-page" class:text-muted-foreground={$page.url.pathname !== '/sub-page'}>your sub page</a>
Enter fullscreen mode Exit fullscreen mode

This appends a CSS class if the URL does not match the path name.

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

I have updated the tutorial. Now everything works correctly and in sync. The point was to properly implement invalidation listeninng to supabase.auth.onAuthStateChange.

Please test the app and let me know if it works for you as well.

Collapse
 
kennek profile image
Kennedy

Thanks again for the article. I am still not all the way there though. Everything works as expected in development but when I try to deploy on vercel, I get the error:

RollupError: "PUBLIC_SUPABASE_URL" is not exported by "virtual:$env/static/public", imported by "src/routes/+layout.ts".

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Hi, thank you for yout thank you.
Concernig Vercel did you set up all your environment varaibles in Vercel? vercel.com/docs/projects/environme...

Collapse
 
asahelk profile image
Asahel

Great article! I'm new with sveltekit, Is there a form to avoid duplicate load function for session validation?

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Hi, thank you.
I am not sure which duplicate load function do you mean? Could you be more specific, please?

Collapse
 
asahelk profile image
Asahel • Edited
export async function load({locals: { getSession }}) {
    const session = await getSession();
    // if the user is already logged in return him to the home page
    if (session) {
        redirect(303, '/');
    }
  }
Enter fullscreen mode Exit fullscreen mode

Each +page.server.js is checking if user is already looged in. Is there a way to check it in the +layout.server.js?

Thread Thread
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

I would not recommend using +layout.server.js for auth checking. See more details in this discussion
github.com/sveltejs/kit/issues/6315 or this video https://www.youtube.com/watch?v=UbhhJWV3bmI&ab_channel=Huntabyte

Conrening protected routes and redirects in one place you may follow this advice https://www.youtube.com/watch?v=K1Tya6ovVOI&ab_channel=Huntabyte and move the logic to src/hooks.server.js

Collapse
 
uw profile image
uwork

Awesome article, you have helped me so much as I am new to the JS world. Please do an OAuth one with Supabase+SK as well! Thanks!!

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Glad to hear you did like the tutorial.

Collapse
 
kennek profile image
Kennedy

Thanks for the tutorial. What would the app.d.ts file look like for those of us in love with typescript?

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

I am just discussing the update of the official documentaton with Supanase. This documentaton uses TS and can be found here: supabase.com/docs/guides/auth/serv...

In previous documentation for Supabase Auth Helpers there is src/app.d.ts supabase.com/docs/guides/auth/auth...

// src/app.d.ts

import { SupabaseClient, Session } from '@supabase/supabase-js'
import { Database } from './DatabaseDefinitions'

declare global {
  namespace App {
    interface Locals {
      supabase: SupabaseClient<Database>
      getSession(): Promise<Session | null>
    }
    interface PageData {
      session: Session | null
    }
    // interface Error {}
    // interface Platform {}
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
dylanb101 profile image
dylanb-101

just started a new project and decide to use these two; great article!

but I was wondering what the addUserprofileToUser function did?

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak

Thank you, I have added the explanaton as a second edit in the bottom of the article.

Collapse
 
jacouys profile image
Jaco

Thanks for the great article!
Pls share the ./utils/addUserprofileToUser.js file

Collapse
 
kvetoslavnovak profile image
kvetoslavnovak • Edited

Thank you, I am sharing this function in a second edit in the bottom of the article.