Sveltekit encourages a progressive enhancement approach to webapp development by leveraging the power of SSR (Server-side rendering). Supabase is an excellent platform that provides storage, realtime DB, DB, edge functions and Auth out of the box and with minimal integration. Implementing Auth correctly is a non-trivial problem.

Most solutions for this tend to focus on letting the front-end pages drive all the functionality and having mechanisms for sharing the session tokens with the backend endpoints so that you can implement SSR with Supabase (this is mainly how the @supabase/auth-helpers library works). However, a more natural pattern for Sveltekit is to handle the load requests in the endpoints, extract the tokens and store as cookies, and store the session in variables available to the endpoints so that we don’t have to worry about JS methods subscribing to auth state changes. This means that we can end up with a solution where our authentication state and data retrieval is totally invisible to the client, except in the form of a cookie.

Supabase setup

The file to create the Supabase client instance based on our variables stored in .env

// lib/db.ts
import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
	import.meta.env.VITE_SUPABASE_URL as string,
	import.meta.env.VITE_SUPABASE_ANON_KEY as string
);

Send a Magic Link

We need to send the request to login or signup with the magic link request.

So a Svelte component that just contains an HTML form with a POST method and its corresponding server endpoint to do the signin and handle the redirect response are required.

// src/routes/login/+page.svelte
<script lang="ts">
	import type { PageData } from './$types';

	export let data: PageData;
	$: ({ sent, error } = data);
</script>

<form method="post">
	<input name="email" type="email"/>
  <input type="submit" value="Sign in" />
	{#if sent}
		<span>An email has been sent, please check your inbox!</span>
	{/if}
	{#if error}
		<span>{error}</span>
	{/if}
</form>
// src/routes/login/+page.server.ts
import { supabase } from '$lib/db';
import type { RequestEvent } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';

export async function load({ locals: { user }, url }: RequestEvent) {
	const sent = url.searchParams.get('sent');
	const error = url.searchParams.get('error');
	if (user) {
		throw redirect(303, '/projects');
	}
	return { sent, error };
}

export async function POST({ request, url }: RequestEvent) {
	const data = await request.formData();
	const email = data.get('email') as string;
	if (!supabase) return { errors: { message: 'NO SUPABASE' } };
	const response = await supabase.auth.signIn(
		{ email },
		{ redirectTo: `${url.origin}/logging-in` }
	);
	if (response.error) {
		throw redirect(303, url.origin + url.pathname + `?error=${response.error.message}`);
	}
	throw redirect(303, url.origin + url.pathname + '?sent=true');
}

The redirectTo option in signIn tells Supabase where to go to when it returns to the app after resolving the authentication (make sure this path is also defined in your Supabase dashboard authentication settings as a redirect URL or it won’t resolve here). We’re taking it to a new page logging-in to handle the login response. The load function here makes sure we redirect to our authenticated directory if there is a user logged in and handles the sent and error cases

Storing the Supabase response as cookies

Unfortunately Supabase currently only allows frontend handling of its authentication as it sends all the token info back in a hash in the response URL, and hashes can only be handled by Javascript in the Frontend. So, we will have to handle the authentication response in 2 places. Firstly we need to make a route logging-in/+page.svelte (our redirected route), and then pass the token data to the backend so we can make all our Supabase calls from our server endpoints.

// src/routes/logging-in/+page.svelte
<script lang="ts">
	import { onMount } from 'svelte';
	import { supabase } from '$lib/db';
	import { setServerSession } from '$lib/session';
	import { goto } from '$app/navigation';
	import { page } from '$app/stores';

	const hash = $page.url.hash.substring(1)
		.split('&')
		.map((a: string) => a.split("="))
		.reduce((a: Record<string, string>, b) => {
			a[b[0]] = b[1]; 
			return a
		} ,{})

	onMount(() => {
		if (hash?.error_description) {
			goto(`/login?error=${hash.error_description}`);
		}
		supabase.auth.onAuthStateChange(async (event, session) => {
			if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
				await setServerSession(session);
				goto('/projects');
			}
		})
	})	
</script>

<div>Logging in ....</div>

This uses a setServerSesssion function in the $lib folder which gets the tokens from the Supabase redirect (our /logging-in route) and POSTs them to an endpoint at /api/auth so that we can set the cookies. Above we are also handling getting an error response from the redirect, which takes us back to the login page.

The setServerSession method called above is stored in our lib folder as below.

// src/lib/session.ts
import type { Session } from '@supabase/gotrue-js';

export const setServerSession = async (session: Session | null) => {
	const payload: { accessToken?: string; refreshToken?: string } = {};
	if (session) {
		payload.accessToken = session.access_token;
		payload.refreshToken = session.refresh_token;
	}
	await fetch('/api/auth', {
		method: 'POST',
		credentials: 'same-origin',
		body: JSON.stringify(payload)
	});
};

So now we need the /api/auth endpoint to store the cookies or delete them if our accessToken has become empty.