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.
Client initialization
Section titled “Client initialization”There are two places where Supabase clients are created, forming a chain that keeps the auth cookie in sync across SSR and the browser.
1. Server hook (hooks.server.ts)
Section titled “1. Server hook (hooks.server.ts)”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:
safeGetSessioncallsgetUser()first (which verifies the JWT with Supabase Auth) before falling back togetSession(). This prevents session spoofing.filterSerializedResponseHeadersallowlists headers that Supabase sends so SvelteKit will forward them to the client.
2. Universal layout (+layout.ts)
Section titled “2. Universal layout (+layout.ts)”The root layout creates a browser client (or a lightweight server client during SSR) and exposes it via $page.data:
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 };};Data fetching patterns
Section titled “Data fetching patterns”Server-side fetching (recommended for initial loads)
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.tsexport 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 };};Client-side fetching
Section titled “Client-side fetching”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>Row-Level Security (RLS)
Section titled “Row-Level Security (RLS)”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_idmean 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 joinedappsrows are also filtered through theappstable’s RLS policies.
Real-time subscriptions
Section titled “Real-time subscriptions”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 channel
Section titled “Notification channel”// 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, };}Chat channel with optimistic updates
Section titled “Chat channel with optimistic updates”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();Subscription lifecycle
Section titled “Subscription lifecycle”Every real-time store follows the same lifecycle:
- Create — call the factory function with the Supabase client and identifiers.
- Init — pass initial data (from server load) and subscribe to the channel.
- Destroy — call
supabase.removeChannel(channel)to unsubscribe when the component unmounts.
Route guards
Section titled “Route guards”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:
- Skip fully public routes (webhooks, auth callbacks).
- Redirect unauthenticated users away from protected routes.
- Redirect authenticated users away from auth pages and the landing page.
- Check admin role for admin routes.
- Check onboarding completion for routes that require it.