Skip to content

SvelteKit Routing Architecture

This document details the SvelteKit routing patterns, data loading strategies, and form action conventions used in show.fm.

Route Group Structure

show.fm uses SvelteKit route groups to organize routes by subdomain and authentication requirements:

src/routes/
├── +layout.svelte           # Root layout (theme, providers)
├── +layout.server.ts        # Root session loading
├── (app)/                   # Authenticated dashboard (app.podcasterplus.com)
│   ├── +layout.server.ts    # Auth check, user/subscription data, isGuestOnlyUser
│   ├── +layout.svelte       # Sidebar, nav, mobile menu (guest-aware)
│   ├── dashboard/           # User dashboard with stats
│   ├── settings/            # User profile settings
│   │   └── my-episodes/     # Guest episode dashboard (see below)
│   ├── episodes/            # Global episodes view
│   ├── guests/              # Global guests view
│   ├── guest-network/       # Guest Network discovery platform
│   │   ├── [profileId]/     # Guest profile view + invite form
│   │   └── invitations/     # Received & sent invitation management
│   └── podcasts/            # Podcast management
│       ├── +page.server.ts  # Podcast list with redirect logic
│       ├── new/             # Create new podcast
│       └── [slug]/          # Podcast-scoped routes
│           ├── +layout.svelte
│           ├── episodes/
│           ├── booking-links/
│           ├── templates/
│           ├── automations/
│           └── settings/
├── (auth)/                  # Public authentication (app.podcasterplus.com)
│   ├── login/
│   ├── signup/
│   ├── reset-password/
│   └── auth/
│       └── callback/        # OAuth & email verification callback
├── (book)/                  # Public booking (book.podcasterplus.com)
│   └── [host]/[slug]/       # Guest booking pages
├── (guest)/                 # Guest portal (app.podcasterplus.com)
│   ├── +layout.server.ts    # Guest auth context, episode/podcast loading
│   ├── +layout.svelte       # Guest-specific UI shell
│   └── guest/
│       ├── create-account/  # Guest → full account upgrade (see below)
│       └── e/[episodeId]/   # Episode collaboration portal
│           ├── +page.svelte # Main collaboration interface
│           ├── auth/        # Verification code validation
│           ├── verify/      # Email verification form
│           └── chat/        # Pop-out chat window
├── invite/
│   └── [token]/             # Team invitation acceptance page
├── checkout/                # Stripe checkout flow
└── pricing/                 # Public pricing page

Route Group Purposes

GroupSubdomainAuthPurpose
(app)app.Supabase Auth (required)Dashboard, podcast management
(auth)app.NoneLogin, signup, password reset
(book)book.NonePublic booking pages
(guest)app.Magic link (cookie)Guest episode collaboration portal
invite/app.OptionalTeam invitation acceptance

Password reset: verify-at-submit (scanner-proof)

The recovery email links straight to this route; the Supabase Reset Password email template pairs with this page and they change together. The template's link href is:

{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=recovery

The page load passes token_hash through untouched; the one-time token is redeemed only inside the form action (verifyOtp({ type: 'recovery', token_hash }), then updateUser). That ordering is the point: email security scanners GET every link in a message, and a GET here consumes nothing, so the human's later click still works. (Before 2026-08-28 the flow ran through gotrue's /verify hop, whose GET WAS the redemption — a scanner or a shared/second click burned the link, and the page then masked the dead session as "couldn't reach the sign-in service".)

Two further behaviours the route owns: gotrue error bounces (?error_code=otp_expired…) render a dedicated expired-link state with a request-a-new-link path instead of a doomed password form, and an AuthSessionMissingError from updateUser (legacy ?code= links whose client-side exchange failed) reports the link as dead rather than as an outage. Legacy ?code= links keep working via the browser client's auto-exchange while they age out.

Bot protection on the auth forms (Cloudflare Turnstile)

/signup, /login, /forgot-password, /guest/create-account and the "Resend code" step inside OtpVerify render a Turnstile widget ($lib/components/auth/TurnstileWidget.svelte) whenever (auth)/+layout.server.ts (or the guest route's own load) supplies a site key. The forms wait for the widget's token before enabling submit, pass it to the auth call as captchaToken, and reset the widget after every attempt because tokens are single-use. The app never verifies the token: Supabase Auth's captcha protection runs siteverify inside gotrue for /signup, /token?grant_type=password, /recover and /resend, which is what stops a script that skips the form and posts to the API with the anon key. verifyOtp, updateUser and the reset-password redemption are not gated and carry no token. Keys, pairing rules and the rollout order: Bot protection.

The Episode Shell

An episode is the busiest object in the product and it carries two roles at once: a production job and a booking. It used to be five screens with four different sets of page chrome, and the live chat that hosts and producers use throughout preparation was mounted on exactly one of them. It is now one shell with six surfaces.

src/routes/(app)/p/[slug]/e/[episodeSlug]/
├── +layout.server.ts     # Access check, episode row, roster, credit count, chat seed, grants
├── +layout.svelte        # Breadcrumb, identity header, ONE tab row, collaboration panel
├── +page.*               # Overview       (also redirects the retired ?tab= links)
├── details/              # Details        action: updateDetails
├── show-notes/           # Show notes     (research folded in)
├── research/             # 308 → show-notes#research
├── guests/               # Guests
├── media/                # Media          actions: updateAudio, removeAudio,
│                         #                         updateCoverImage, removeCoverImage
├── transcript/           # sub-surface of Media
├── publish/              # Publish        actions: updateScheduling, updateContentDisclosure,
│                         #                         publish, schedule, cancelSchedule, unpublish,
│                         #                         archive, republish, resetToDraft, delete
├── analytics/            # sub-surface of Publish
└── publish-handoff/      # sub-surface of Publish

The tab model is src/lib/episode-shell/tabs.ts: tab ids double as route segments, so hrefs and active-tab detection derive from the same list. episodeSurfaceFromPathname() maps a sub-surface back to the tab that keeps its highlight, which is why Transcript, Analytics and the handoff render under their parent tab with a local back link rather than chrome of their own.

show-notes, transcript, analytics and publish-handoff kept their established paths so existing links — including the ones already sent in notification emails — keep working.

Why no load in the episode workspace calls parent()

Nothing under p/[slug]/e/[episodeSlug]/ calls await parent() — not the shell layout, not the six tab surfaces, not the sub-surfaces. A server-side parent() in a node that reruns re-executes every ancestor layout load on each navigation (their results come back as skip but the queries still run). Without it, the shell load reruns only when the episode params change — which is exactly what keeps the collaboration panel's realtime channel alive as the user moves between surfaces.

Every load in that subtree, and how each one is gated:

Loadparent()Gate
+layout.server.tsnorequirePodcastAccessForUser + requireEpisodeRosterForMember
+page (Overview)norequireWorkspaceEpisode
guests/, media/, publish/norequireWorkspaceEpisode
show-notes/, transcript/norequireWorkspaceEpisode
analytics/norequireWorkspaceEpisode
publish-handoff/norequirePodcastAccess(…, 'admin') + external-only + publish_handoff
details/n/ano load; actions only
research/n/a308 redirect to show-notes#research

Regenerate that table rather than trusting it, since a new sub-surface will not announce itself:

bash
find "src/routes/(app)/p/[slug]/e" -name '+page.server.ts' -o -name '+layout.server.ts' \
  | sort | while read -r f; do
      calls=$(grep -nE '(await )?(event\.)?parent\(\)' "$f" | grep -vE '^[0-9]+:\s*(\*|//|/\*)')
      printf '%s %s\n' "$f" "${calls:+CALLS parent()}"
    done

The surface loads were the expensive half of this, and it was measured rather than assumed. Same episode, same request pipeline, WebKit at 390px against staging:

Tab changeMedianIts load
to Show notes264msalready self-gated, no parent()
to Media1080mscalled parent()

Replaying each load's real query sequence put the shell load at five sequential round trips, 876ms, against three or four for a surface's own data. Reading the podcast and episode from parent() was therefore costing more than everything the surface actually needed.

Every surface now calls requireWorkspaceEpisode() instead, which is one round trip for the membership gate, the episode and the podcast together (plus a roster check for Co-hosts only). Analytics gained the most: on top of parent() it was calling requirePodcastAccess, whose auth.getUser() is an unconditional, never-cached round trip, then chaining its own episode read, roster check, account resolve, feature check and retention read one after another. The shell load kept its own five-hop shape until the same pass flattened it: the membership check, the podcast read, the episode read and the billing-account resolve all key off the route slug, so they share one round trip rather than queueing behind each other, and the AI feature grants join the batch below instead of trailing it.

Do not reintroduce parent() here

src/routes/(app)/p/[slug]/e/[episodeSlug]/__tests__/surface-authz.test.ts asserts that no surface load calls parent() and that each one propagates the gate's 401/403/404. A surface that needs something new from the shell should take it from requireWorkspaceEpisode(), widening that one query, rather than reaching for the ancestor.

Dropping parent() also drops the ancestor authorization

A load that does not await parent() must gate itself. parent() is what forces an ancestor layout load to execute; without it SvelteKit skips any node the request's x-sveltekit-invalidated mask leaves unmarked, and never runs its load (runtime/server/data/index.js). That mask is a plain query param on __data.json, so a crafted leaf-only request would otherwise reach a page load with neither the (app) layout's slug guard nor an access check having run.

Episode RLS is not a substitute for the membership check. episodes carries a second SELECT policy, "Guests can view assigned episodes", matching episode_guests.user_id = auth.uid() for status invited/active/completed — and episode_guests.user_id is auto-linked to auth.users by email. A guest who has an app account therefore passes the episode read while holding no podcast_members row. The podcasts!inner embed does not compensate: "Public can view podcasts for booking" exposes any podcast with an active booking link, which is how a guest reached the episode in the first place.

The shell load therefore runs requirePodcastAccessForUser itself, then requireEpisodeRosterForMember (#293: a Co-host reaches an episode only when rostered).

Every surface load gates itself too, via requireWorkspaceEpisode() (src/lib/server/workspace-episode.ts), which performs exactly those two checks alongside the episode read. Since no surface calls parent(), none of them can lean on the shell's gate: each one is reachable on its own by a leaf-only __data.json request.

The collaboration panel

CollaborationPanel mounts EpisodeChatSidebar exactly once, in the layout, and moves it with CSS between three presentations: a docked 344px column from xl, a 54px rail with an unread badge from lg, and a floating button with a bottom sheet below that. Rendering it per breakpoint — or inside a Sheet, which portals and unmounts on close — would tear the conversation down every time the viewport or the surface changed, which is the exact failure the shell exists to fix. The docked/collapsed choice persists in localStorage; the overlay and the mobile sheet always start closed.

Keeping the header live

The identity header owns the ready chip and the save pill, but the fields they describe live on child routes, and auto-save posts straight to a form action without invalidating. Surfaces therefore publish their live readiness values and their save status to src/lib/episode-shell/shell-store.svelte.ts, keyed by episode id, and the layout merges them over the server's values. Without it the chip would go stale the moment anyone typed a title.

Readiness

src/lib/episode-shell/readiness.ts builds the six-item checklist rendered twice: in the header chip's popover and as a card on Overview (the permanent right rail it used to occupy now belongs to the chat). Credited in the feed points at Publish, not Guests — Guests is who can WORK on the episode, RSS credits is who is NAMED in the feed, and the two are deliberately separate.

Hooks Pipeline

Authentication and routing are handled in hooks.server.ts via a four-stage pipeline:

typescript
export const handle: Handle = sequence(
	subdomainHandle, // 1. Detect subdomain (book/app)
	honoHandle, // 2. Route /api/* to Hono (early return)
	supabaseHandle, // 3. Initialize Supabase client
	authGuardHandle // 4. Protect routes, set user
);

Slug guard (D-5 dual enforcement, Epic 16)

Podcast slugs become {slug}.show.fm HOSTNAMES after the rebrand flip, so slug availability is guarded server-side in src/lib/server/slug-guard/: RESERVED_SUBDOMAINS (the D-5 infrastructure labels) plus BLOCKED_SLUG_TERMS (generated from the epic-16 seed CSV by scripts/generate-slug-blocklist.mjs; the lockstep test re-derives it from the CSV, so drift fails CI). Both sets reject with ONE generic message — the reason a word is unavailable is never echoed — and matching is exact-label, lowercase, never substring.

Enforcement point one is the create action in p/new/+page.server.ts (the single site where a podcast slug is born; imports attach to existing podcasts and no rename path exists). Enforcement point two is routing, and it is LIVE code: subdomainHandle calls rejectsWildcardLabel(host) on every request and 404s a guarded first-level label with no explicit surface on any root in WILDCARD_ROOT_DOMAINS. That list is EMPTY today (the wildcard does not exist), so the check is inert via data, and its behaviour is already pinned by tests that inject the future root: guarded labels 404, the explicit surfaces (my, book, admin, www) keep resolving, deeper hosts and look-alike domains never match. A tripwire test pairs the two lists — show.fm cannot join ROOT_DOMAINS without also joining WILDCARD_ROOT_DOMAINS. The module is server-only by directory: the blocked list never reaches a client bundle.

Cross-surface bounces (Epic 16 dual-brand)

During the show.fm transition the app answers on two brands at once, and show.fm sessions are host-only cookies (D-4) — so any server redirect that jumps brand domains strands the session. Two helpers in src/lib/constants/root-domains.ts encode the contract; both validate the host against the ROOT_DOMAINS allowlist and fall back to the configured PUBLIC_APP_URL for anything unrecognised:

  • appReturnOrigin(hostname, fallback) — for ROUND-TRIPS that leave and come back (Stripe checkout/portal, Google OAuth, password reset). Echoes the request origin only when it is exactly the app surface of a known root (app. legacy, my. show.fm), so an off-allowlist Host header can never become a return target.
  • appSurfaceOrigin(hostname, fallback) — for BOUNCES that push a visitor to the app surface (the (book) layout's non-booking-host escape — which unknown paths reach via the [podcastSlug] catch-all — the booking-apex redirect in src/routes/+page.server.ts, and the (admin) layout's appBase). Maps ANY host on a known root to that root's app surface via APP_SURFACE_LABELS: book.show.fm, admin.show.fm and the show.fm apex all bounce to https://my.show.fm; the legacy hosts to https://app.podcasterplus.com; the .dev roots to their staging twins.

The rule when adding a redirect: a server redirect must never change the visitor's brand domain mid-journey. Use appReturnOrigin when the visitor is coming back to where they started, appSurfaceOrigin when you are sending them to the app surface, and a hardcoded PUBLIC_APP_URL only for content generated without a request context (emails), which flips wholesale at the GATE-11 rebrand flip.

Customer show hosts (Epic 16 GATE-4)

{slug}.show.fm (staging: {slug}.showfm.dev) serves the SAME (listen) route tree as listen.podcasterplus.com/{slug} — the tree is untouched; the mapping happens in two coordinated places:

  1. src/hooks.ts reroute (universal): a customer host's pathname is rerouted to /{label}{pathname}, so {slug}.show.fm/e/{ep} matches (listen)/[podcastSlug]/e/[episodeSlug]. Parsing is the client-safe wildcardHostLabel from $lib/constants/root-domains (explicit surfaces my/book/admin/www and non-wildcard hosts are untouched).
  2. subdomainHandle: sets isPublicListen + locals.podcastSlugFromHost for customer hosts. The D-5 guard (rejectsWildcardLabel) runs FIRST and 404s reserved/blocked labels, so a guarded label never reaches the tree.

Consequences to preserve: internal listen links are pathBase-relative (page loads return pathBase'' on a customer host, /{slug} on the path-style host) so a host-mode page never doubles the slug; emitted listen URLs (getListenUrl/getListenEpisodeUrl) obey PUBLIC_LISTEN_URL_MODE (path until the GATE-11 flip, so canonicals cannot split SEO during dual serving); /api/* on customer hosts is served normally by honoHandle (listen pages fetch relatively — required, not an oversight).

Subdomain Detection

typescript
const subdomainHandle: Handle = async ({ event, resolve }) => {
	const host = event.request.headers.get('host') || '';
	let subdomain: string | null = null;

	// Production: book.podcasterplus.com → 'book'
	if (host.includes('podcasterplus.com')) {
		const parts = host.split('.podcasterplus.com')[0].split('.');
		subdomain = parts[parts.length - 1] || null;
	}
	// Development: ?subdomain=book or default 'app'
	else if (host.includes('localhost')) {
		subdomain = event.url.searchParams.get('subdomain') || 'app';
	}

	event.locals.subdomain = subdomain;
	event.locals.isPublicBooking = subdomain === 'book';

	return resolve(event);
};

Auth Guard

typescript
const authGuardHandle: Handle = async ({ event, resolve }) => {
	// Skip for API routes (Hono middleware handles auth)
	if (event.url.pathname.startsWith('/api')) {
		return resolve(event);
	}

	// Skip for public booking pages (soft auth only)
	if (event.locals.isPublicBooking) {
		const { session, user } = await event.locals.safeGetSession();
		event.locals.user = user;
		event.locals.session = session;
		return resolve(event);
	}

	// Protect dashboard routes
	const protectedRoutes = ['/dashboard', '/settings', '/p'];
	const isProtectedRoute = protectedRoutes.some((route) => event.url.pathname.startsWith(route));

	const { session, user } = await event.locals.safeGetSession();

	if (isProtectedRoute && !session) {
		throw redirect(303, '/login');
	}

	event.locals.user = user;
	event.locals.session = session;

	return resolve(event);
};

Route prefixes here need their trailing slash

authGuardHandle early-returns for locals.isGuestRoute before it assigns locals.user / locals.session. So whatever claims a path as a guest route decides, for every other handler, whether the user exists in locals at all.

guestAuthHandle classified with pathname.startsWith('/guest'), and /guest-network starts with /guest. Every Guest Network request was therefore treated as a guest-portal request and ran with locals.user === null. It stayed invisible for as long as those pages read the user through await parent() (the (app) layout does its own auth.getUser()); the moment one of them read locals.user instead, the whole route redirected to /login, which bounced to /dashboard, which redirects to /p/{slug}. A page that simply would not open.

It is now startsWith('/guest/'). Every guest-portal route lives under /guest/ (/guest/e/[episodeId] and children, /guest/create-account) and there is no bare /guest page. Guarded by src/lib/server/__tests__/guest-auth-handle.test.ts ("guestAuthHandle: which paths it claims").

The same trap is why the guard itself uses '/p/' and not '/p': otherwise /pricing and /privacy become protected routes. Any new prefix check in this pipeline needs the trailing slash and a test naming the sibling it must not swallow.

Data Loading Patterns

Pattern 1: Parent Layout Chain

The (app) layout loads authenticated user data that all child pages inherit:

typescript
// src/routes/(app)/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals }) => {
	const {
		data: { user }
	} = await locals.supabase.auth.getUser();

	if (!user) {
		throw redirect(303, '/login');
	}

	// Load profile and subscription
	const { data: profile } = await locals.supabase
		.from('user_profiles')
		.select('full_name, subscription_tier, subscription_status, stripe_customer_id')
		.eq('id', user.id)
		.single();

	// Get default podcast for Command Palette context
	const { data: firstPodcast } = await locals.supabase
		.from('podcast_members')
		.select('podcasts(slug)')
		.eq('user_id', user.id)
		.order('joined_at', { ascending: true })
		.limit(1)
		.single();

	return {
		user: {
			id: user.id,
			email: user.email!,
			fullName: profile?.full_name ?? null
		},
		subscription: {
			tier: profile?.subscription_tier ?? 'free',
			status: profile?.subscription_status ?? 'active',
			stripeCustomerId: profile?.stripe_customer_id ?? null
		},
		defaultPodcastSlug: firstPodcast?.podcasts?.slug ?? null
	};
};

Pattern 2: Inheriting Parent Data

Child pages can call await parent() to access parent layout data:

typescript
export const load: PageServerLoad = async ({ locals, parent }) => {
	const { user } = await parent(); // ← Gets parent layout data
	// ...
};

parent() in a server load makes the ancestor re-run

On a client-side navigation the server is stateless: it has none of the layout data the browser already holds. A child load that calls parent() therefore forces SvelteKit to execute the ancestor load again for that request, even though the response marks the ancestor node "skip" and sends none of it back.

Verified by controlled A/B on /guest-network/[profileId] (2026-08-15), logging each layout execution against event.request.url:

Routecalls parent()(app) layout runs for the leaf __data.json?
/guest-network/[profileId]yesyes
/guest-network/[profileId]removedno
/p/[slug]/guests/[guestId]never had itno

The (app) layout is not cheap: an unconditional auth.getUser() (never cached), the user profile, the unread-notification count, every podcast membership, the billing context and the onboarding checklist. On staging that made a click from the Guest Network listing into a profile 817ms against 493ms for the equivalent click on a podcast's guest list, despite the two leaf loads issuing the same number of queries.

If you only need the user, read locals.user. The hook already resolved it through safeGetSession(), which validates with auth.getUser(), so it is the same value at no round trip.

Dropping parent() drops the ancestor's authorization with it

authGuardHandle only redirects for protectedRoutes = /dashboard, /settings, /p/. /guest-network is not in that list, so the (app) layout's if (!user) throw redirect(303, '/login') was that route's entire authentication gate. parent() was what forced it to run.

SvelteKit will not run an unmarked ancestor for a crafted leaf-only __data.json request, so both /guest-network loads now restate the gate themselves:

typescript
const user = locals.user;
if (!user) throw redirect(303, '/login');

Before removing parent() from any load, check whether its route is inside protectedRoutes. If it is not, the layout is the gate and it must be carried across. Asserted in src/routes/(app)/guest-network/[profileId]/__tests__/page.server.test.ts ("authentication gate"), which also fails if the load ever calls parent() again, because the test passes no parent.

Still calling parent() in a server load (each needs the same gate-vs-cost check before it changes): dashboard, episodes, notifications, p/[slug], p/new, settings/notifications. The /settings and /p/ ones are inside protectedRoutes, so for those the hook already redirects and only the cost is at stake. episodes and notifications are NOT, so those two carry the same gate risk /guest-network did. Tracked as issue #421.

The { ...parentData } spread was always redundant

Three settings loads used to end return { ...parentData, ... }. SvelteKit merges every ancestor layout's data into a page's data prop on its own, which settings/my-episodes/+page.svelte proves: it has always read data.user and data.currentPodcast with no parent() and no spread in its loader. So the spread copied data the page would have had anyway, and the parent() call that fed it was pure cost. Verified and removed from settings, settings/ai-connections and settings/reputation on 2026-08-15.

The account shell's shared load

settings/+layout.server.ts is the counter-example to "just remove parent()": four screens needed the SAME four tab counts, and loading them per page would have made each tab show its own number and blank the other three.

It reads locals.user directly and restates the gate, which is safe here because /settings IS in protectedRoutes. Because SvelteKit does not re-run a layout load when you navigate between its own children, the counts are computed once per entry into the settings subtree rather than once per tab.

Two routes sit under that layout without being part of the tab bar, and both pay for its queries: settings/notifications (reached from the sidebar) and settings/ai-connections/authorize (an OAuth consent screen). That was accepted in preference to moving four route directories into a group, which would have dragged authorize under the account shell with them.

Pattern 3: Podcast Membership Verification

Podcast-scoped routes verify membership with RLS-enforced queries:

typescript
// src/routes/(app)/p/[slug]/+page.server.ts
export const load: PageServerLoad = async ({ locals, params, parent }) => {
	const { user } = await parent();
	const { slug } = params;

	// Query podcast through membership table (RLS enforced)
	const { data: membership } = await locals.supabase
		.from('podcast_members')
		.select(
			`
      role,
      podcasts!inner(
        id, title, slug, description, cover_image_url,
        author, language, category, explicit, website_url
      )
    `
		)
		.eq('user_id', user.id)
		.eq('podcasts.slug', slug)
		.single();

	if (!membership?.podcasts) {
		throw error(404, 'Podcast not found');
	}

	// Extract typed podcast data
	const podcast = membership.podcasts as {
		id: string;
		title: string;
		slug: string;
		// ... other fields
	};

	return {
		podcast,
		userRole: membership.role
	};
};

MVP Redirect Logic

For MVP simplicity, users with a single podcast are auto-redirected:

typescript
// src/routes/(app)/p/+page.server.ts
export const load: PageServerLoad = async ({ locals, parent }) => {
	const { user } = await parent();

	const { data: memberships } = await locals.supabase
		.from('podcast_members')
		.select('podcasts(slug)')
		.eq('user_id', user.id);

	const podcasts = memberships?.map((m) => m.podcasts).filter(Boolean) ?? [];

	// No podcasts → force creation
	if (podcasts.length === 0) {
		throw redirect(303, '/p/new');
	}

	// Single podcast → auto-select
	if (podcasts.length === 1) {
		throw redirect(303, `/p/${podcasts[0].slug}`);
	}

	// Multiple podcasts → show picker
	return { podcasts };
};

Form Actions

Named Actions Pattern

Multiple actions in a single page using named actions:

typescript
// src/routes/(app)/p/[slug]/e/[episodeSlug]/+page.server.ts
export const actions: Actions = {
	// Auto-save actions (don't invalidate RSS)
	updateDetails: async ({ request, params, locals }) => {
		const formData = await request.formData();
		const title = formData.get('title') as string;

		if (!title) return fail(400, { error: 'Title required' });
		if (title.length > 200) return fail(400, { error: 'Title too long' });

		const { error } = await locals.supabase
			.from('episodes')
			.update({ title, updated_at: new Date().toISOString() })
			.eq('id', episodeId);

		if (error) return fail(500, { error: error.message });
		return { success: true };
	},

	// State transition actions (invalidate RSS + emit events)
	publish: async ({ params, locals, fetch }) => {
		// 1. Permission check
		if (!hasAdminAccess) {
			return fail(403, { error: 'Insufficient permissions' });
		}

		// 2. State validation
		if (!episode.audio_url) {
			return fail(400, { error: 'Cannot publish without audio' });
		}

		// 3. Update database
		await locals.supabase
			.from('episodes')
			.update({
				status: 'published',
				published_at: new Date().toISOString()
			})
			.eq('id', episode.id);

		// 4. Invalidate RSS cache
		await invalidateAfterEpisodeUpdate(fetch, podcast, episode.id, 'episode.published');

		// 5. Emit automation events
		await emitEpisodeEvent(locals.supabase, 'episode.published', episode.id, podcast.id);

		// 6. Schedule time-based automation jobs
		await scheduleTimeBasedJobsForEpisode(locals.supabase, episode.id, podcast.id, new Date());

		return { success: true, action: 'publish' };
	},

	delete: async ({ params, locals, fetch }) => {
		// Only owner can delete
		if (membership.role !== 'owner') {
			return fail(403, { error: 'Only owners can delete' });
		}

		await locals.supabase.from('episodes').delete().eq('id', episode.id);

		// Cleanup
		if (wasPublished) {
			await invalidateAfterEpisodeUpdate(fetch, podcast, episode.id, 'episode.deleted');
		}

		throw redirect(303, `/p/${slug}/e`);
	}
};

Action Summary by Route

RouteActions
episodes/[episodeSlug]updateDetails, updateScheduling, updateAudio, removeAudio, delete, publish, schedule, cancelSchedule, unpublish, archive, republish, resetToDraft
settingsupdateProfile, updatePassword
episodes/newdefault (create episode)
booking-links/newdefault (create booking link)
automations/newdefault (create automation rule)

Error Handling

Route-Level Errors

typescript
// 404 - Resource not found
if (!podcast) {
	throw error(404, 'Podcast not found');
}

// 403 - Permission denied
if (userRole !== 'admin' && userRole !== 'owner') {
	throw error(403, 'Insufficient permissions');
}

// Redirect on condition
if (podcasts.length === 0) {
	throw redirect(303, '/p/new');
}

Form Action Errors

typescript
// 400 - Validation errors
if (!title) return fail(400, { error: 'Title required' });

// 401 - Authentication errors
if (!user) return fail(401, { error: 'Not authenticated' });

// 403 - Authorization errors
if (role !== 'owner') return fail(403, { error: 'Owner required' });

// 500 - Server errors
if (dbError) return fail(500, { error: 'Database error' });

// Return form values for re-population
return fail(400, {
	error: 'Validation failed',
	values: { title, description }
});

Type Safety

Generated Types

typescript
import type { PageServerLoad, Actions } from './$types';
import type { Database } from '$lib/types/database.types';

// Enums from database
type EpisodeStatus = Database['public']['Enums']['episode_status'];

// Table insert types
import type { TablesInsert } from '$lib/types/database.types';
const episodeData: TablesInsert<'episodes'> = { ... };

Relationship Queries

typescript
// Nested select with full relationships
const { data: episode } = await locals.supabase
	.from('episodes')
	.select(
		`
    id,
    title,
    status,
    booking_id,
    bookings(
      id,
      guest_name,
      guest_email,
      custom_field_responses
    )
  `
	)
	.single();

Page Requirements

Every Page MUST Have

svelte
<svelte:head>
	<title>Page Title | show.fm</title>
</svelte:head>

Svelte 5 Syntax (Required)

svelte
<script lang="ts">
	// Props (NOT export let)
	let { data } = $props();

	// State (NOT let x = value)
	let count = $state(0);

	// Derived (NOT $: derived = ...)
	let doubled = $derived(count * 2);

	// Effects (NEVER async)
	$effect(() => {
		console.log('Count changed:', count);
	});
</script>

<!-- Events (NOT on:click) -->
<button onclick={() => count++}>Increment</button>

<!-- Content (NOT <slot />) -->
{@render children()}

Critical Rules

DO

  • Use getUser() for auth validation on server
  • Use await parent() to inherit layout data
  • Use named form actions for mutations
  • Verify podcast membership through podcast_members
  • Include <title> on every page

DON'T

  • NEVER create +server.ts for API endpoints (use Hono)
  • NEVER use getSession() for auth (cookies spoofable)
  • NEVER use on:click syntax (use onclick)
  • NEVER use $: reactive statements (use $derived)
  • NEVER use <slot /> (use snippets with {@render})

Team Invitation Route

The /invite/[token] route handles team invitation acceptance with a multi-state UI. Unlike most routes, it works for both authenticated and unauthenticated users.

typescript
// src/routes/invite/[token]/+page.server.ts
export const load: PageServerLoad = async ({ params, locals }) => {
	// Fetch invitation via SECURITY DEFINER RPC (no auth required)
	const { data: invitation } = await locals.supabase.rpc('get_invitation_by_token', {
		p_token: params.token
	});

	// Determine state based on auth + invitation status
	const user = locals.user;
	if (!user) {
		return { invitation, canAccept: false, needsAuth: true };
	}
	if (user.email !== invitation.email) {
		return { invitation, canAccept: false, emailMismatch: true };
	}
	return { invitation, canAccept: true };
};

The accept action calls accept_team_invitation RPC, which atomically updates the invitation status and creates the podcast_members row. On success, the user is redirected to /p/{podcast_slug}.

See Team Invitation Flow for the complete lifecycle.

Auth Callback

The /auth/callback route at src/routes/(auth)/auth/callback/+server.ts handles OAuth redirects and email verification links.

typescript
// GET /auth/callback?code=xxx&next=/invite/abc123
export const GET: RequestHandler = async ({ url, locals }) => {
	const code = url.searchParams.get('code');
	const next = getSafeRedirectTarget(url.searchParams.get('next'));

	if (code) {
		await locals.supabase.auth.exchangeCodeForSession(code);
		// Optionally add user to Resend audience (non-blocking)
	}

	throw redirect(303, next);
};

The next parameter preserves the user's intended destination (e.g., an invite page) across the auth flow.

Redirect Safety

All user-provided redirect targets are validated by getSafeRedirectTarget() from src/lib/auth/redirect.ts to prevent open redirect vulnerabilities.

typescript
import { getSafeRedirectTarget } from '$lib/auth/redirect';

// Only allows internal absolute paths starting with /
const target = getSafeRedirectTarget(url.searchParams.get('redirect'));
// '/invite/abc' → '/invite/abc'
// 'https://evil.com' → '/dashboard' (fallback)
// '//evil.com' → '/dashboard' (fallback)

This function is used in the login page, signup page, and auth callback to safely redirect users after authentication while preserving their original destination.

Guest Portal Routes

The (guest) route group handles guest collaboration via magic link authentication (NOT Supabase Auth). See src/routes/(guest)/CLAUDE.md for full details.

Guest Account Creation

The /guest/create-account route enables guests to upgrade from magic-link-only access to a full Supabase Auth account.

Source: src/routes/(guest)/guest/create-account/

Load function:

  • Redirects already-authenticated users to /dashboard
  • Pre-fills form from guest_token cookie (validates via validate_guest_access_token RPC)
  • Supports ?email= URL param for invitation-driven signups

Form action:

  1. Validates email format and password (8+ chars)
  2. Calls supabase.auth.signUp() with emailRedirectTo for verification callback
  3. Creates user_profiles record via upsert
  4. Waits for account linking trigger to fire (500ms), then queries linked episodes
  5. Returns success state with linked episode previews (up to 5)
  6. Clears guest_token cookie on successful login

The UI shows benefits of creating an account, handles email confirmation flow, and displays a notification showing any auto-linked episodes found.

See Guest Account Flow for the complete lifecycle.

My Episodes Dashboard

The /settings/my-episodes route provides authenticated users a view of all episodes where they are a guest or collaborator.

Source: src/routes/(app)/settings/my-episodes/

Data loading strategy (dual-query for completeness):

  1. Query episode_guests where user_id = current_user.id (already linked)
  2. Query episode_guests where email = user.email AND user_id IS NULL (orphaned records not yet linked by trigger)
  3. Deduplicate by episode_id, join to episodes and podcasts
  4. Fetch role_label from episode_credits for display

UI features:

  • Status filter: All / Invited / Active / Completed / Expired
  • Podcast filter: dropdown when episodes span multiple podcasts
  • Episodes grouped by podcast with cover art and count badges
  • Each card shows: title, status badge, role label, recording date, last access time
  • "Open Episode Portal" button with access token for active/invited episodes

Guest-Aware Sidebar

The (app) layout (+layout.svelte) detects isGuestOnlyUser from the layout server data:

  • Guest-only users: Sidebar shows "Guest Dashboard" section with "My Episodes" as the primary nav, plus a CTA to create their own podcast
  • Regular users: "My Episodes" appears under Account Settings with a headphones icon

Internal documentation - Not for public distribution