SvelteKit Remote Forms: Validation & Schema Support

Link to section: The Problem with Traditional Form HandlingThe Problem with Traditional Form Handling
For years, SvelteKit form handling meant one of two things: you either used server actions with verbose boilerplate, or you wrote fetch calls by hand. Both approaches work, but they're tedious and error-prone. You end up repeating validation logic, manually serializing data, and managing error states in multiple places.
I hit this wall repeatedly over the past few years. A simple contact form would need me to validate on the server, send back structured errors, and wire up client-side state to show them. The whole dance felt like I was fighting the framework instead of working with it.
Enter remote functions, which have evolved significantly since their initial release. The October 2025 updates to SvelteKit (version 2.42+) add real teeth to form handling with built-in validation, schema support, and new error properties that make this workflow feel natural.
Link to section: What Remote Forms DoWhat Remote Forms Do
A remote form function is declared in a .remote.ts file on your server and imported into a component on the client. When you call it, SvelteKit automatically sends the data to the server, runs your handler, and returns the result. On the client, it's type-safe. On the server, it's just a regular function that can access your database, environment variables, and session.
The key improvement in v2.42 is that remote forms now accept a validation schema (using Valibot, Zod, or any Standard Schema), which means errors are caught and returned in a consistent shape before your handler even runs.
Link to section: Setting Up Remote FormsSetting Up Remote Forms
First, enable the experimental feature in svelte.config.js:
export default {
kit: {
experimental: {
remoteFunctions: true
}
},
compilerOptions: {
experimental: {
async: true
}
}
};The async flag lets you use await directly in components, which pairs beautifully with remote forms.
Now create a .remote.ts file. I'll build a simple newsletter signup:
// src/routes/newsletter.remote.ts
import { form } from '$app/server';
import * as v from 'valibot';
import * as db from '$lib/server/database';
const schema = v.object({
email: v.pipe(v.string(), v.email()),
name: v.pipe(v.string(), v.minLength(2))
});
export const subscribe = form(schema, async (data) => {
const existing = await db.subscribers.findOne({ email: data.email });
if (existing) {
throw new Error('Already subscribed');
}
await db.subscribers.insert(data);
return { success: true, message: 'Welcome aboard!' };
});That schema uses Valibot (you could swap in Zod or any Standard Schema). When someone submits, SvelteKit validates automatically. If validation fails, it returns a structured error object. If it passes, your handler runs.
Link to section: Using Remote Forms in ComponentsUsing Remote Forms in Components
Here's where the new input and issues properties shine:
<script>
import { subscribe } from './newsletter.remote';
let email = $state('');
let name = $state('');
let loading = $state(false);
let result = $state(null);
let issues = $state(null);
async function handleSubmit() {
loading = true;
result = null;
issues = null;
try {
const outcome = await subscribe({ email, name });
result = outcome;
email = '';
name = '';
} catch (e) {
// Validation errors land here with structured shape
if (e instanceof Error && e.cause?.issues) {
issues = e.cause.issues;
} else {
result = { error: e.message };
}
} finally {
loading = false;
}
}
</script>
<form {onsubmit}>
<div>
<input
type="text"
placeholder="Your name"
bind:value={name}
disabled={loading}
/>
{#if issues?.find(i => i.path?. === 'name')}
<span class="error">
{issues.find(i => i.path === 'name').message}
</span>
{/if}
</div>
<div>
<input
type="email"
placeholder="Email address"
bind:value={email}
disabled={loading}
/>
{#if issues?.find(i => i.path?. === 'email')}
<span class="error">
{issues.find(i => i.path === 'email').message}
</span>
{/if}
</div>
<button type="submit" disabled={loading}>
{loading ? 'Subscribing...' : 'Subscribe'}
</button>
</form>
{#if result?.success}
<p class="success">{result.message}</p>
{/if}
{#if result?.error}
<p class="error">{result.error}</p>
{/if}The issues array contains validation errors keyed by field path. Each issue has a path, message, and type. This lets you show inline error messages without additional wiring.
Link to section: Batching Multiple Form CallsBatching Multiple Form Calls

If your form triggers multiple remote functions at once, they're automatically batched into one HTTP request thanks to the October updates. This is crucial for performance when you have multiple interdependent operations.
Let me show a real example: imagine a form that creates a post and then fetches updated stats:
// src/routes/posts.remote.ts
import { form, query } from '$app/server';
import * as v from 'valibot';
import * as db from '$lib/server/database';
const createSchema = v.object({
title: v.pipe(v.string(), v.minLength(3)),
content: v.pipe(v.string(), v.minLength(10))
});
export const createPost = form(createSchema, async (data) => {
const post = await db.posts.insert(data);
return post;
});
export const getStats = query(async () => {
const count = await db.posts.count();
const recent = await db.posts.recent(5);
return { totalPosts: count, recentPosts: recent };
});In the component:
<script>
import { createPost, getStats } from './posts.remote';
async function handleCreate(formData) {
const newPost = await createPost(formData);
const stats = await getStats(); // These two calls batch together
updateUI(newPost, stats);
}
</script>Because both calls happen synchronously in the same macrotask, SvelteKit combines them into a single POST request. You save a round trip and reduce latency, especially noticeable on slower networks.
Link to section: Error Boundaries and Async PatternsError Boundaries and Async Patterns
The October 2025 async SSR improvements give you solid error handling for free. Wrap your form in a <svelte:boundary> to catch validation errors and provide fallback UI:
<svelte:boundary>
<NewsletterForm />
{#snippet failed(error, reset)}
<div class="error-box">
<p>Something went wrong: {error.message}</p>
<button onclick={reset}>Try again</button>
</div>
{/snippet}
</svelte:boundary>If the form throws an error (validation, database, anything), it's caught here. The user sees a recovery button instead of a broken form.
Link to section: Progressive Enhancement by DefaultProgressive Enhancement by Default
Remote forms work without JavaScript because SvelteKit still sends a real HTTP POST. The browser receives a response with the same structure your async code would. If JavaScript is disabled, the page refreshes and shows the result naturally.
Try it: disable JavaScript in DevTools, submit the form, and you'll see the same validation errors and success message. That's progressive enhancement handled for you.
Link to section: Comparing With AlternativesComparing With Alternatives
You might ask: why not just use tRPC or a traditional REST API?
tRPC works well if you're already deep in the TypeScript/Node ecosystem, but it adds another abstraction layer. With remote functions, you're using the same request/response cycle SvelteKit already manages. No new concepts, no middleware chain to reason about.
A bare REST API is more flexible but also more work. You write the endpoint, the types, the client hook, the error parsing. Remote functions do that automatically.
The main trade-off is that remote functions couple your data layer tightly to SvelteKit. If you need to query your API from external services or share your backend with multiple frontends, a REST API is cleaner. For a monolithic SvelteKit app, remote functions save boilerplate and keep your types in sync effortlessly.
Link to section: Lazy Discovery and Tree ShakingLazy Discovery and Tree Shaking
The October release added lazy discovery, which matters if you're shipping remote functions inside node_modules (say, in a shared library). Before, SvelteKit scanned all modules at build time. Now it only includes remote functions that are actually imported. This improves build time and bundle size for large monorepos.
If you're using a workspace setup with shared utilities, make sure your remote functions are in a .remote.ts file and import them explicitly. The framework will detect and include only what you use.
Link to section: When to Reach for Remote FormsWhen to Reach for Remote Forms
Use remote forms when you're building a SvelteKit app and the form stays within that app. They shine for user-facing interactions like signups, edits, and submissions where you want type safety and built-in validation.
Skip them if you're building a public API that other clients consume, or if you need to support multiple frontends (web, mobile, desktop). In those cases, a REST API or GraphQL layer is cleaner.
Also, remote functions aren't ideal for large file uploads yet. The experimental form has improved validation, but streaming uploads need more work. For now, handle big files through a traditional endpoint or a third-party service like S3.
Link to section: A Complete ExampleA Complete Example
Here's a minimal but realistic contact form:
// src/routes/contact.remote.ts
import { form } from '$app/server';
import * as v from 'valibot';
import { sendEmail } from '$lib/server/email';
export const submit = form(
v.object({
name: v.pipe(v.string(), v.minLength(2)),
email: v.pipe(v.string(), v.email()),
message: v.pipe(v.string(), v.minLength(10), v.maxLength(5000))
}),
async (data) => {
await sendEmail({
to: 'contact@example.com',
subject: `New message from ${data.name}`,
body: data.message
});
return { sent: true };
}
);<script>
import { submit } from './contact.remote';
let state = $state({ name: '', email: '', message: '' });
let loading = $state(false);
let errors = $state(null);
let sent = $state(false);
async function handleSubmit() {
loading = true;
errors = null;
try {
await submit(state);
sent = true;
state = { name: '', email: '', message: '' };
} catch (e) {
errors = e.cause?.issues || [{ message: 'Unknown error' }];
} finally {
loading = false;
}
}
</script>
<form onsubmit|preventDefault={handleSubmit}>
<input bind:value={state.name} placeholder="Name" disabled={loading} />
{#if errors?.find(e => e.path?. === 'name')}
<span class="error">{errors.find(e => e.path === 'name').message}</span>
{/if}
<input bind:value={state.email} placeholder="Email" type="email" disabled={loading} />
{#if errors?.find(e => e.path?. === 'email')}
<span class="error">{errors.find(e => e.path === 'email').message}</span>
{/if}
<textarea bind:value={state.message} placeholder="Message" disabled={loading}></textarea>
{#if errors?.find(e => e.path?. === 'message')}
<span class="error">{errors.find(e => e.path === 'message').message}</span>
{/if}
<button disabled={loading}>
{loading ? 'Sending...' : 'Send'}
</button>
</form>
{#if sent}
<p class="success">Thanks! We'll be in touch soon.</p>
{/if}No error parsing, no manual serialization, no wiring schemas twice. One schema, shared type safety between client and server.
Link to section: Moving ForwardMoving Forward
The October 2025 enhancements show SvelteKit doubling down on developer ergonomics. Remote forms aren't revolutionary, but they're practical and they eliminate friction. combining query batching with form validation lets you build complex interactions with minimal boilerplate.
If you're still reaching for fetch or copying validation logic, give remote forms a shot. They're experimental, which means things might shift slightly, but the core idea is solid. Start with a small form (a newsletter signup, a comment field) and see how it feels. Chances are you'll be surprised how much noise they cut.

