Skip to content

Data Layer

Honeycomb uses Supabase as its backend-as-a-service layer. The SvelteKit integration relies on @supabase/ssr to create both server-side and browser-side clients that share the same auth session.

There are two places where Supabase clients are created, forming a chain that keeps the auth cookie in sync across SSR and the browser.

The supabase handle creates a server client on every request and attaches it to event.locals:

// hooks.server.ts (simplified)
const supabase: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(
PUBLIC_SUPABASE_URL,
PUBLIC_SUPABASE_PUBLISHABLE_KEY,
{
cookies: {
getAll: () => event.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
});
},
},
},
);
event.locals.safeGetSession = async () => {
const { data: { user }, error } = await event.locals.supabase.auth.getUser();
if (error) return { session: null, user: null };
const { data: { session } } = await event.locals.supabase.auth.getSession();
return { session, user };
};
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range' || name === 'x-supabase-api-version';
},
});
};

Key details:

  • safeGetSession calls getUser() first (which verifies the JWT with Supabase Auth) before falling back to getSession(). This prevents session spoofing.
  • filterSerializedResponseHeaders allowlists headers that Supabase sends so SvelteKit will forward them to the client.

The root layout creates a browser client (or a lightweight server client during SSR) and exposes it via $page.data:

src/routes/+layout.ts
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
depends('supabase:auth');
const supabase = isBrowser()
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {
global: { fetch },
})
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, {
global: { fetch },
cookies: {
getAll() {
return data.cookies;
},
},
});
const { data: { session } } = await supabase.auth.getSession();
return { supabase, session };
};
Section titled “Server-side fetching (recommended for initial loads)”

Layout and page server load functions use locals.supabase to fetch data before the page renders. This runs through RLS using the current user’s JWT.

// src/routes/(app)/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals }) => {
const { user } = await locals.safeGetSession();
const { data: profile } = await locals.supabase
.from('profiles')
.select('id, username, first_name, last_name, avatar_url, verified, role, type')
.eq('id', user!.id)
.single();
const { data: unreadRows } = await locals.supabase
.from('notifications')
.select('id')
.eq('notifiable_id', user!.id)
.is('read_at', null);
const { data: installedApps } = await locals.supabase
.from('user_apps')
.select('app_id, is_active, apps(slug, name, icon)')
.eq('user_id', user!.id)
.eq('is_active', true);
return { user, profile, unreadNotifications: unreadRows?.length ?? 0, installedApps };
};

Components that need data after the initial render use the browser Supabase client from page data:

<script lang="ts">
import { page } from '$app/stores';
const supabase = $page.data.supabase;
async function loadMore() {
const { data } = await supabase
.from('posts')
.select('*')
.range(offset, offset + 20);
// ...
}
</script>

Both the server client and browser client use the publishable (anon) key, not the service role key. Every query passes through Postgres RLS policies.

Practical implications:

  • No service-role bypass — the frontend never has elevated privileges. Admin operations that need to bypass RLS must go through Edge Functions or a backend API.
  • Automatic user scoping — policies like auth.uid() = user_id mean most queries do not need explicit user ID filters, but Honeycomb adds them anyway for clarity and to support compound indexes.
  • Joins inherit policies — when using .select('*, apps(slug, name)'), the joined apps rows are also filtered through the apps table’s RLS policies.

Honeycomb uses Supabase Realtime postgres_changes channels for live updates. The pattern is consistent: a store factory receives the Supabase client, creates a channel, and cleans it up on destroy.

// notification-store.svelte.ts (simplified)
export function createNotificationStore(
supabase: SupabaseClient<Database>,
userId: string,
) {
let unreadCount = $state(0);
let channel: ReturnType<typeof supabase.channel> | null = null;
function init(initialCount: number) {
unreadCount = initialCount;
channel = supabase
.channel(`notifications:${userId}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `notifiable_id=eq.${userId}`,
}, () => { unreadCount++; })
.subscribe();
}
function destroy() {
if (channel) {
supabase.removeChannel(channel);
channel = null;
}
}
return {
get unreadCount() { return unreadCount; },
init, destroy,
};
}

The chat store subscribes to both INSERT and UPDATE events and supports optimistic message sending:

// chat-store.svelte.ts (simplified)
channel = supabase
.channel(`chat:${chatId}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `chat_id=eq.${chatId}`,
}, (payload) => {
const newMsg = payload.new as Database['public']['Tables']['messages']['Row'];
// Replace optimistic message or append
})
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'messages',
filter: `chat_id=eq.${chatId}`,
}, (payload) => {
// Patch existing message in place
})
.subscribe();

Every real-time store follows the same lifecycle:

  1. Create — call the factory function with the Supabase client and identifiers.
  2. Init — pass initial data (from server load) and subscribe to the channel.
  3. Destroy — call supabase.removeChannel(channel) to unsubscribe when the component unmounts.

Auth, role, and onboarding checks are centralized in hooks.server.ts via a guard handle composed with sequence(). Individual page or layout load functions do not perform redirect logic for auth — they can assume the user is authenticated if they are inside the (app) route group.

export const handle = sequence(supabase, guard);

The guard checks, in order:

  1. Skip fully public routes (webhooks, auth callbacks).
  2. Redirect unauthenticated users away from protected routes.
  3. Redirect authenticated users away from auth pages and the landing page.
  4. Check admin role for admin routes.
  5. Check onboarding completion for routes that require it.