Skip to content

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.errors flowing back to the UI.
  • CSRF for free — same-origin form-action POSTs are covered by SvelteKit’s built-in csrf.checkOrigin (Origin validation) plus the SameSite=Lax session 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)).
+page.server.ts
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.

+server.ts
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.

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/ # dynamic

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:

ModulePurpose
lib/server/auth.tsBetter Auth configuration
lib/server/s3.tsS3 upload / delete (Cloudflare R2)
lib/server/imgproxy.tsHMAC image URL generation
lib/server/email.tsResend email service
lib/server/meilisearch.tsMeilisearch client
lib/server/rate-limit.tsRate limiter
lib/server/sanitize.tsInput sanitization (text, URL, UUID, CUID)
  • Validation — Zod schemas in @repo/validation (shared with mobile + tRPC).
  • Wrappersveltekit-superforms with zod4 adapter.
  • Displayformsnap + bits-ui primitives.
  • i18n errorsmakeValidationErrorMap adapter pipes Zod issue codes through Paraglide.

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.

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.

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.).

Both Sentry inits (server + client) install a beforeSend that redacts:

  • cookie / authorization / csrf headers
  • body fields matching password|token|otp|secret

Don’t remove the scrubber — Sentry defaults capture the full Cookie header on every error.