Error handling in SvelteKit form actions

SvelteKit gives you two small primitives for error reporting — fail() and error() — and the gap between them is where most form-action bugs hide. A fail() returns data to the page so the user can fix their input. An error() throws, triggering the nearest +error.svelte boundary. Using the wrong one at the wrong time produces either a blank 500 page or a form that silently swallows validation errors. This guide covers the patterns shipped in our multi-tenant SvelteKit starter: typed service errors, an errorToFail mapper that preserves redirects, and how rate limiting returns 429 directly from the auth routes.

fail() vs error(): two different contracts

fail(status, data) returns a response that SvelteKit delivers back to the page's form prop. The form stays mounted, the user sees their input, and the error message appears where you put it. This is the correct choice for anything the user can fix: validation errors, duplicate-email warnings, permission denials that are expected business logic.

error(status, message) throws. SvelteKit catches it and renders the nearest +error.svelte. This is correct for genuinely unexpected conditions — a missing org, a broken session — where the form context is meaningless and the user needs to navigate elsewhere.

// In a form action:
return fail(400, { field: 'email', error: 'Email already in use' });
// The user stays on the form, sees the message, can change the email.

// vs.
throw error(404, 'Organization not found');
// SvelteKit renders +error.svelte — the form context is gone.

The rule of thumb: if the user should see their form again, use fail(). If the page itself is wrong, use error().

The errorToFail mapper

Service-layer code throws typed errors (RBAC denial, membership violations, not-found, etc.). Each form action catches them and converts to fail() via a single shared mapper. Writing that catch block in every action is repetitive and error-prone — especially the redirect-preservation edge case. This is the entire shipped mapper, verbatim from src/lib/server/http.ts:

import { fail, type ActionFailure } from '@sveltejs/kit';

/**
 * Single place where domain errors become HTTP responses.
 * RBAC failures are 403; everything else the user can act on is 400.
 *
 * SvelteKit's `redirect()` signals success by throwing a control-flow object
 * (it carries `location`, not `code`). Swallowing it here would turn any
 * successful action that redirects inside a try/catch into a 500 after its
 * side effects already committed — so redirects always propagate.
 */
export function errorToFail(e: unknown): ActionFailure<{ error: string }> {
	if (e && typeof e === 'object' && 'location' in e) throw e;
	if (e && typeof e === 'object' && 'code' in e && 'message' in e) {
		const anyE = e as { code: string; message: string };
		const status =
			anyE.code === 'forbidden' || anyE.code === 'hierarchy_violation' || anyE.code === 'no_membership'
				? 403
				: anyE.code === 'not_found'
					? 404
					: 400;
		return fail(status, { error: anyE.message });
	}
	console.error(e);
	return fail(500, { error: 'Something went wrong.' });
}

The redirect check at the top is the critical detail. SvelteKit's redirect() throws a control-flow object that carries a location, not a code. If the mapper does not re-throw it, a successful action that redirects inside a try/catch becomes a 500 — the side effects already committed but the user seeing an error page instead of the next screen. This exact regression was found and pinned by a test in tests/http.test.ts. The mapper only handles objects with a code and message; anything else is logged and returned as a generic 500 — nothing internal leaks to the client.

Typed service errors: codes, not messages

The services in the starter throw typed error classes with a code string — the mapper reads the code to pick the HTTP status, and tests assert on codes rather than message text. RbacError (in src/lib/server/rbac.ts) is the shape they all follow:

export class RbacError extends Error {
  constructor(
    public code: 'forbidden' | 'no_membership' | 'hierarchy_violation' | 'last_owner' | 'bad_role',
    message: string
  ) { super(message); }
}

The other shipped classes mirror this pattern, each with its own set of codes: AuthError (in auth.ts) with email_taken | invalid_credentials | weak_password | invalid_email; MemberError (in services/members.ts) with no_membership | self_remove | hierarchy_violation | last_owner | bad_role; OrgError (in services/orgs.ts) with bad_name | not_found; InviteError (in services/invites.ts) with invalid_token | expired | revoked | already_accepted | already_member | bad_role; and BillingError (in billing/adapter.ts) with seat_limit | subscription_required. There is no RateLimitError and no ValidationError class in the kit — rate limiting returns fail(429, ...) directly from the login/signup routes (see below), and validation lives inside the services (e.g. password length in auth.ts).

Services never return fail() directly — they throw, and the action catches. This keeps service functions testable without importing SvelteKit's action primitives. A test can call setMemberRole() and assert rejects.toMatchObject({ code: 'hierarchy_violation' }) without any HTTP context.

Auth errors: 401 vs 403

Unauthenticated and unauthorized are different failure modes that need different responses:

// Layout load: no session → redirect to login (302)
if (!locals.user) {
  throw redirect(302, '/login');
}

// Page load: session exists but no org membership → 404
const membership = await getMembership(db, params.id, locals.user.id);
if (!membership) {
  throw error(404, 'Organization not found (or you are not a member).');
}

// Form action: RBAC check fails → fail(403) back to the form
if (!can(actorRole, 'members.invite')) {
  return fail(403, { error: 'You do not have permission to invite members.' });
}

The pattern is: no session = redirect (the user cannot do anything on this page), no membership = 404 (the org does not exist from their perspective), permission denied = 403 on fail() (the form stays visible so they can navigate to a permitted action). Returning a bare 401 from a form action is almost always wrong — SvelteKit does not have a built-in 401 handling convention, and a fail(401) does not trigger a browser auth prompt.

Field-level validation errors

A single error string above the form is the minimum the shipped kit returns (the errorToFail shape is { error: string }). Field-level errors — attached to individual inputs — are better UX and not much more work; the pattern below is a recommended extension, not part of the shipped kit. The idea: a wrapper object carries a fields record keyed by input name:

// Recommended extension — the shipped kit returns { error: string }.
// Service throws:
if (!email.includes('@')) {
  throw new FieldError({ email: 'Enter a valid email address' });
}
if (password.length < 8) {
  throw new FieldError({ password: 'Password must be at least 8 characters' });
}

// Component reads form.data.errors:
{#if form?.errors?.email}
  <p class="text-red-400 text-sm">{form.errors.email}</p>
{/if}
<input name="email" value={form?.data?.email ?? ''} />

The errors object arrives from fail(400, { errors: { email: '...' } }) via SvelteKit's form prop — the same server-side validation principle as the kit's services, but with per-field granularity. Because validation happens server-side, every field is checked against business rules (uniqueness, format, authorization) not just HTML constraints.

Rate limit error handling

In the shipped kit the login and signup routes return fail(429, ...) directly from a uniform response shape — there is no RateLimitError class. The page pattern below (also a recommended extension) shows how a component can surface a retry countdown when an action response carries retryAfterMs:

{#if form?.error && form?.retryAfterMs}
  <p class="text-amber-400 text-sm">
    {form.error} Retry in {Math.ceil(form.retryAfterMs / 1000)}s.
  </p>
{/if}

The retry-after value in the kit's limiter is derived from the oldest surviving attempt in the sliding window — approximate, not exact, but honest. The user sees a real countdown rather than a vague "try again later".

Putting it together: a form action with the full pattern

export const actions = {
  invite: async ({ request, params, locals }) => {
    const form = await request.formData();
    const email = String(form.get('email') ?? '');

    try {
      // Re-fetch role on every action — never trust load-time state.
      const { role } = await requireOrgMembership(db, params.id, locals.user!.id);

      await inviteMember(db, {
        orgId: params.id,
        actorRole: role,
        email,
        grantedRole: 'member'
      });

      // Success → redirect (control flow, not a return value)
      return redirect(303, `/app/org/${params.id}/members`);

    } catch (e) {
      return errorToFail(e);
    }
  }
};

Every path is covered: the service throws typed errors, errorToFail maps them to the right status and shape, redirects pass through untouched, and unknown errors get a safe 500 without leaking internals. The action itself is short because the error logic lives in one reusable function.

Production checklist

  • Always return fail() from form actions, never throw. Throwing in an action produces a generic 500; fail() keeps the form state live and returns data the component can render.
  • Preserve SvelteKit redirects inside error mappers. A successful side-effect followed by redirect() is control flow, not an error — converting it to fail() loses the redirect and double-commits the user.
  • Type your service errors as code strings, not message strings. Codes survive refactoring and test assertions; message text gets edited by copywriters and breaks brittle matchers.
  • Map error codes to HTTP statuses in one place. forbidden/hierarchy_violation/no_membership → 403, not_found → 404, other user-fixable domain codes → 400, unknown → 500. Auth rate limiting returns 429 directly from the login/signup routes, not through the mapper.
  • Re-fetch roles and permissions inside every action, never from data-load state. The role the page rendered with is stale the instant the user submits.
  • Return error information from fail() with a stable shape the component can render. The starter uses a single { error: string } field — extend to per-field errors if you add richer validation, but keep one mapper.
  • Distinguish "not authenticated" (redirect to login) from "not authorized" (403 with a message). Conflating them trains users to treat permission errors as session errors.
  • Include a provenance comment on every guide page so readers and maintainers can trace every claim back to shipped source files and test counts.

Related guides: Multi-tenant authentication · RBAC & role hierarchy · Rate limiting login

Related reading

Get in touch

Questions about the product, team licenses, or anything else? We'll respond within 48 hours.

Max 2000 characters

Stored in our own database — no third party. Deleted on request.