Web (SvelteKit)
apps/svelte-web is SvelteKit 2 + Svelte 5 (runes), Tailwind 4, bits-ui, Paraglide JS. The repo-root CLAUDE.md and apps/svelte-web/CLAUDE.md are the agent context; this page is the human-readable summary.
Web mutations are form actions — permanent
Section titled “Web mutations are form actions — permanent”SvelteKit +page.server.ts form actions are locked in for web mutations. Do not migrate web mutations to client-side tRPC mutations.
Reasons:
- Progressive enhancement — forms work with JS disabled.
- No-JS support — important for some users + crawlers.
- superforms validation — server-side Zod validation with typed
form.errorsflowing back to the UI. - CSRF for free — same-origin form-action POSTs are covered by SvelteKit’s built-in
csrf.checkOrigin(Origin validation) plus theSameSite=Laxsession cookie; no token needed.
tRPC on web is for:
- Read queries (typed, cached server-side via
createCaller(locals)). - Tools that need typed access (admin scripts, etc.).
- Mobile (the main tRPC consumer — see Conventions → Mobile (Native)).
Auth check + form action template
Section titled “Auth check + form action template”import { fail, redirect } from '@sveltejs/kit';import { superValidate } from 'sveltekit-superforms';import { zod4 } from 'sveltekit-superforms/adapters';import { schema } from './schema';
export const load = async ({ locals }) => { if (!locals.user) redirect(303, '/'); return { user: locals.user };};
export const actions = { default: async ({ request, locals }) => { if (!locals.user) return fail(401); const form = await superValidate(request, zod4(schema)); if (!form.valid) return fail(400, { form }); // ...db op via shared service in @repo/api/services return { form }; }};Shared business logic lives in packages/api/src/services/ — call it from both the form action and the tRPC procedure.
API endpoint with rate limit
Section titled “API endpoint with rate limit”import { json } from '@sveltejs/kit';import { rateLimitByUser } from '$lib/server/rate-limit';
export const POST: RequestHandler = async ({ request, locals }) => { if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 }); const { allowed } = await rateLimitByUser(locals.user.id, 'action', 20, 60); if (!allowed) return json({ error: 'Rate limited' }, { status: 429 }); // ...op};rateLimitByUser(userId, scope, limit, windowSeconds) is the canonical entry point. Backed by Valkey with in-memory fallback. Source: apps/svelte-web/src/lib/server/rate-limit.ts.
Route layout
Section titled “Route layout”src/routes/├── (public)/ # browse, /properties, /agents├── portal/ # personal user area (auth required)│ └── become-agent/ # action-only — Dialog is mounted at root layout├── dashboard/ # agent workspace (auth + agent role)├── api/ # REST endpoints — uploads, places, chat├── auth/callback/ # OAuth callback└── sitemap.xml/ # dynamicServer-only modules
Section titled “Server-only modules”Files under src/lib/server/ are server-only — never imported from .svelte components or anywhere a client bundle reaches. Vite enforces this with $lib/server/* import restrictions.
Common patterns:
| Module | Purpose |
|---|---|
lib/server/auth.ts | Better Auth configuration |
lib/server/s3.ts | S3 upload / delete (Cloudflare R2) |
lib/server/imgproxy.ts | HMAC image URL generation |
lib/server/email.ts | Resend email service |
lib/server/meilisearch.ts | Meilisearch client |
lib/server/rate-limit.ts | Rate limiter |
lib/server/sanitize.ts | Input sanitization (text, URL, UUID, CUID) |
Form structure
Section titled “Form structure”- Validation — Zod schemas in
@repo/validation(shared with mobile + tRPC). - Wrapper —
sveltekit-superformswithzod4adapter. - Display —
formsnap+ bits-ui primitives. - i18n errors —
makeValidationErrorMapadapter pipes Zod issue codes through Paraglide.
Auth modal
Section titled “Auth modal”The auth modal is mounted globally at the root layout. Three trigger sources route to it via lib/auth-guard.svelte.ts:
- Anonymous user clicks an auth-required affordance (favorite, save, request tour).
- Header / mobile menu sign-in.
- 401 response from a protected action.
Email-OTP signup captures firstName / lastName in the modal form, then calls authClient.updateUser({ name, firstName, lastName }) immediately after signIn.emailOtp succeeds — the OTP signIn body only carries { email, otp }, so we persist the name fields in a follow-up call. Errors from updateUser are logged but do not block the redirect.
Auth errors are localized
Section titled “Auth errors are localized”Use the shared translateAuthError(error) helper in src/lib/auth-client.ts:
import { translateAuthError } from '$lib/auth-client';import { m } from '$lib/paraglide/messages';
if (error) { toast.error(translateAuthError(error)); // ← localized // NOT: toast.error(error.message); // ← Better Auth's raw English}Maps Better Auth error.code (INVALID_OTP, OTP_EXPIRED, INVALID_EMAIL, USER_NOT_FOUND, TOO_MANY_REQUESTS, EMAIL_NOT_VERIFIED) to Paraglide message functions, with a status-429 fallback to auth_error_rateLimited and a catch-all auth_error_unknown.
Design tokens
Section titled “Design tokens”Tokens live in apps/svelte-web/src/routes/layout.css (CSS custom properties, light + dark mode). The docs site reuses the same primary indigo + Source Serif 4 — see apps/docs/src/styles/brand.css.
Use Tailwind utility classes that reference the tokens:
<div class="bg-card text-card-foreground border-border"> <button class="bg-primary text-primary-foreground hover:bg-primary-hover">…</button></div>No hex literals in components. The token system covers light + dark mode and status badges (status-active, status-pending, etc.).
Sentry scrubbing
Section titled “Sentry scrubbing”Both Sentry inits (server + client) install a beforeSend that redacts:
cookie/authorization/csrfheaders- body fields matching
password|token|otp|secret
Don’t remove the scrubber — Sentry defaults capture the full Cookie header on every error.