Adding Supabase authentication into Sveltekit

2023-01-25

For some reason I can’t seem to wrap my head around authentication.

For now, this is what works with my sveltekit app. Open to suggestions from anyone who see’s a flaw or issue with it. I tried my best to structure this in a way so I can take and drop into all my other sveltekit projects as quickly as possible.

Setup

Created a Supabase project, get the Supabase project url and anon key . These will be needed to prepare the Supabase client. I set up two environment variables to assign these keys to.

VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=

Installed the Supabase packages

> pnpm install @supabase/supabse-js @supabase/auth-helpers-sveltekit

Created a Supabase utility called supabase.ts

//supabase.ts
import { createClient } from '@supabase/auth-helpers-sveltekit'

export const supabaseClient = createClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY)

export const signOut = () => {
  supabaseClient.auth.signOut()
}

Finally start by setting up the folder structure. I want all the routes that deal with authentication grouped so its easy to just copy so I put them all in an auth folder. To handle the layout for all the pages that need to be authenticated routes, I nested them in a layout group folder names (auth) .

// Examples folder structure
/routes
|- /(auth)
|  |- +layout.server.ts
|  |- +layout.svelte
|  |- /dashboard
|     |- +page.svelte
|- /auth
   |- /logout
   |- /login
   |- /register

Configuring +layouts

I decided I’m going to check for a session on the server at the root +layout.server.ts . I plan to have some sort of header in the main layout that will need access to whether the user is logged in or not.

// /routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'
import { getServerSession } from '@supabase/auth-helpers-sveltekit'

export const load: LayoutServerLoad = async (event) => {
  const session =  await getServerSession(event)

  return {
    session,
  }
}

And in the +layout.svelte , I export the data variable which is required to receive the data from the server. I then passed the session data to the header component where I can conditionally render the login or register/login links. I also added the Supabase code to invalidate Supabase auth cache when user logs in and out.

// /routes/+layout.svelte
<script lang="ts">
import { supabaseClient } from '$utils/supabase';
import { invalidate } from '$app/navigation';
import { onMount } from 'svelte';

export let data = {};

onMount(() => {
		// Invalidate the supabase:auth cache when the user logs in or out
		const {
			data: { subscription }
		} = supabaseClient.auth.onAuthStateChange(() => {
			invalidate('supabase:auth');
		});

		return () => {
			subscription.unsubscribe();
		};
	});
</script>

<Header session={data.session}/>

If there was any other specific page route that needed to be checked for authentication as well, it could be done on the client side by using the global page store. Session will live under $page.data.session

<script>
	import { page } from '$app/stores';
</script>

{#if $page.data.session}{/if}

I don’t really want to do this everywhere if I don’t have to. For the most part I know that most routes outside of the landing and marketing pages will need to be authenticated routes. So all those pages are nested under (auth) and inherit the following layout code.

// /routes/(auth)/+layout.server.ts
import type { LayoutServerLoad } from './$types'
import { getServerSession } from '@supabase/auth-helpers-sveltekit'
import { redirect } from '@sveltejs/kit'

export const load: LayoutServerLoad = async (event) => {
  const session =  await getServerSession(event)

  if (session) {
    return {
      session,
    }
  }

  throw redirect(303, '/auth/login?redirect=' + event.url.pathname + '&error=unauthenticated')
}

The intent here is to redirect the user so that they are taken to the login page, but I pass a url param so that they could be redirected back to the route they were trying to reach after logging in.

Setup registration

For REALLY basic registration, I just use a simple form, and will use the Sveltekit actions to handle the form data on the server.

// routes/auth/register/+page.svelte
<script lang="ts">
	import type { ActionData } from './$types';

	export let form: ActionData;
</script>

<div class="p-4">
	<h1>Register</h1>
	<form method="POST">
		<label for="email">Email</label>
		<input type="email" name="email" id="email" />

		<label for="password">Password</label>
		<input type="password" name="password" id="password" />

		<button type="submit">Register</button>
	</form>
</div>

{#if form?.error}
	<p class="text-red-700 text-sm">{form.error}</p>
{/if}

To handle the form, I used zod to validate the inputs.

import { getSupabase } from '@supabase/auth-helpers-sveltekit';
import { AuthApiError } from '@supabase/supabase-js';
import { redirect } from '@sveltejs/kit';
import { z } from 'zod';

// Schema for form data
const RegisterSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters long'),
  password_confirm: z.string().min(8, 'Password must be at least 8 characters long'),
}).superRefine((data)=>{
  if (data.password !== data.password_confirm) {
    throw new z.ZodError(['Passwords do not match.'])
  }
  return data;
})

export const actions = {
  default: async (event) => {
    const { request } = event;
    const { session, supabaseClient } = await getSupabase(event);
    const formData = await request.formData();

    const registrationData = {
      email: formData.get('email'),
      password: formData.get('password'),
      password_confirm: formData.get('password_confirm'),
    }
    
    // Validate inputs
    try {
      RegisterSchema.parse(registrationData);
    } catch(error) {
      return {
        success: false,
        error: error.errors,
      }
    }
    
    const { data, error } = await supabaseClient.auth.signUp({
      email: registrationData.email,
      password: registrationData.password,
    })

    if (error) {
      if (error instanceof AuthApiError && error.status === 400) {
        return {
          success: false,
          error: error.message,
        }
      }
      return {
        success: false,
        error: 'There was a connection problem, try again later.'
      }
    } 
    
    throw redirect(303, '/auth/login?success=registered')
  }
}

Basically, I used zod for form data validation. If there is an error, I return it back to the form. Afterwards I attempt to register to Supabase, and again, if there is an error, I return to the form that success is false . Finally, if it is all good, then redirect to the login page with a param noting that it was a successful registration so that there is a success message on the login screen.

Login

The login page is pretty straight forward

// routes/auth/logout/+page.svelte
<script lang="ts">
	import { supabaseClient } from '$utils/supabase';
	import type { PageData, ActionData } from '../../auth/register/$types';

	export let data: PageData;
	export let form: ActionData;
</script>

<!-- Header error reference -->
{#if data?.error}
	<div
		class="mx-auto w-10/12 flex justify-center items-center bg-red-200 rounded-md p-4 text-black text-sm"
	>
		{data?.error}
	</div>
{/if}

<!-- Header success reference -->
{#if data?.success}
	<div
		class="mx-auto w-10/12 flex justify-center items-center bg-green-200 rounded-md p-4 text-black text-sm"
	>
		{data?.success}
	</div>
{/if}

<div class="min-w-full flex flex-col justify-center items-center">
	<div class="sm-mx-auto sm:w-full sm:max-w-md">
		<h2>Sign in to your account</h2>

		<form method="POST">
			<label for="email">Email</label>
			<input type="email" name="email" id="email" />

			<label for="password">Password</label>
			<input type="password" name="password" id="password" />

			<button type="submit">Login</button>
		</form>
	</div>

	{#if form?.error}
		<p class="text-red-700 text-sm">{form.error}</p>
	{/if}
</div>

And this is +page.server.ts for the login route.

// routes/auth/login/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { AuthApiError } from '@supabase/supabase-js';
import { getSupabase } from '@supabase/auth-helpers-sveltekit';

export const load = async (event) => {
  let error = null;
  let success = null;

  if (event.url.searchParams.get('error')) {
    switch (event.url.searchParams.get('error')){
      case 'unauthenticated': 
        error = 'You must be logged in to view this page.'
      break;
      case 'timeout':
        error = "Your session has timed out. Please log in again."
      break;
      default: 
        error = 'Unknown error, please try again.'
      break;
    }
  }

  if (event.url.searchParams.get('success')) {
    switch(event.url.searchParams.get('success')) {
      case 'registered':
        success = 'You have successfully registered. You may log in.'
      break;
      default: 
        success = ''
      break;
    }
  }

  return {
    error,
    success
  }
}

export const actions = {
  default: async (event) => {
    const { request, url } = event;
    const { supabaseClient } = await getSupabase(event);
    const formData = await request.formData();
    
    const email = formData.get('email');
    const password = formData.get('password');
    
    const { data, error: signInError } = await supabaseClient.auth.signInWithPassword({
      email,
      password,
    })

    if (signInError) {
      if (signInError instanceof AuthApiError && signInError.status === 400) {
        return {
          success: false,
          error: signInError.message
        }
      }

      return {
        success: false,
        error: 'There was a connection problem, try again later.'
      }
    } 

    if (url.searchParams.get('redirect')) {
      throw redirect(303, url.searchParams.get('redirect'))
    }
    throw redirect(303, '/dashboard');
  }
}

In the load function I added some switch statements to generate different error and success messages so that they can be unique to specific use cases. These messages are then passed on to the data prop.

The default actions will handle the sign in form for the login route. The route does look for a redirect url parameter so that after a successful login, it can redirect the user to that page.

Logout

For logout, I created a route for auth/logout so that a logout link can be used to log a user out. To keep it consistent with all the rest of the auth flow I prepared this as a server action. There isn’t really a need for an actual client side page to render to the user, so in the routes/auth/logout folder I only have a +page.server.ts .

// routes/auth/logoug/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { getSupabase } from '@supabase/auth-helpers-sveltekit';

export const actions = {
  default: async (event) => {
    const { supabaseClient } = await getSupabase(event);
    const { error } = await supabaseClient.auth.signOut();
    
    if (error) {
      throw error(500, 'There was an issue logging out.');
    } 

    throw redirect(303, '/');
  }
}

In the header I created a Log out button, I wrap it with a small form tag targeting the logout route.

<form action="/auth/logout" method="post">
		<button type="submit">Logout</button>
</form>

I’m on the fence still if I really like this, and have a feeling I will change this. What I do like about this is that it requires no client side JavaScript to work.