Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
85d627d
add convert_formdata function
Rich-Harris Sep 6, 2025
8d99533
WIP
Rich-Harris Sep 6, 2025
669152a
fix some types
Rich-Harris Sep 7, 2025
36be93b
fix test
Rich-Harris Sep 7, 2025
c025964
fix
Rich-Harris Sep 7, 2025
c1b30ba
fix test
Rich-Harris Sep 7, 2025
4dc8f6a
missed a spot
Rich-Harris Sep 7, 2025
93c9a92
simplify a bit
Rich-Harris Sep 7, 2025
8da4937
add validation
Rich-Harris Sep 7, 2025
079bdaa
maybe this? the type stuff is too complicated for me
Rich-Harris Sep 8, 2025
5cf7c0a
get rid of dot/array notation, it adds too much complexity
Rich-Harris Sep 8, 2025
d20a080
tighten up
Rich-Harris Sep 8, 2025
33ccf28
fix
Rich-Harris Sep 8, 2025
e46ce1c
fix
Rich-Harris Sep 8, 2025
ddf3ca2
oops
Rich-Harris Sep 8, 2025
558d04e
revert
Rich-Harris Sep 8, 2025
0a972cd
sort of working
Rich-Harris Sep 8, 2025
86380a5
implement input/issues
Rich-Harris Sep 8, 2025
65f9d31
make input reactive
Rich-Harris Sep 8, 2025
efe1aa0
enforce 1:1 relationship
Rich-Harris Sep 8, 2025
0d206ee
fix
Rich-Harris Sep 8, 2025
49ceb3d
lint
Rich-Harris Sep 8, 2025
0abbd51
WIP
Rich-Harris Sep 8, 2025
d5901cc
WIP
Rich-Harris Sep 9, 2025
74d1909
tidy up
Rich-Harris Sep 9, 2025
eec4249
lint
Rich-Harris Sep 9, 2025
1c820a7
lint
Rich-Harris Sep 9, 2025
c4ba793
merge main
Rich-Harris Sep 11, 2025
037251c
fix
Rich-Harris Sep 11, 2025
cc5fdb7
only enforce input parity
Rich-Harris Sep 11, 2025
6119e20
use validation output, not input
Rich-Harris Sep 11, 2025
249dec1
preflight should return instance
Rich-Harris Sep 11, 2025
3578408
DRY out
Rich-Harris Sep 11, 2025
48700d6
implement preflight
Rich-Harris Sep 11, 2025
5d3bed1
WIP validate method
Rich-Harris Sep 11, 2025
07ae015
programmatic validation
Rich-Harris Sep 12, 2025
24c099b
fix/tidy
Rich-Harris Sep 12, 2025
aa6b952
generate types
benmccann Sep 12, 2025
4242c1d
docs
Rich-Harris Sep 13, 2025
3a6bf63
add field(...) method
Rich-Harris Sep 13, 2025
9941753
more docs
Rich-Harris Sep 14, 2025
43388f5
oops
Rich-Harris Sep 14, 2025
4c43ad6
fix test
Rich-Harris Sep 14, 2025
c095b55
fix docs
Rich-Harris Sep 14, 2025
a0426fd
Apply suggestions from code review
Rich-Harris Sep 15, 2025
ae9d35d
guard against prototype pollution
Rich-Harris Sep 15, 2025
63b21ba
doc tweaks, fixes
dummdidumm Sep 16, 2025
417731f
add valibot to playground
dummdidumm Sep 16, 2025
1e846d0
changesets
dummdidumm Sep 16, 2025
24174b6
tell people about the breaking change
dummdidumm Sep 16, 2025
ba023cd
remind ourselves to remove the warning
Rich-Harris Sep 16, 2025
278893f
fix changeset
Rich-Harris Sep 16, 2025
53a2096
filter out files
Rich-Harris Sep 16, 2025
60eb1d5
better file handling
Rich-Harris Sep 16, 2025
6cf8b66
widen helper type to also catch unknown/the general type which will a…
dummdidumm Sep 16, 2025
7a4a692
Merge branch 'validated-forms' of https://github.com/sveltejs/kit int…
dummdidumm Sep 16, 2025
7dd2727
mentiond coercion
dummdidumm Sep 16, 2025
b821c0f
only reset after a successful submission
Rich-Harris Sep 16, 2025
81b3163
same for buttonProps
Rich-Harris Sep 16, 2025
753cb1c
Merge branch 'validated-forms' of github.com:sveltejs/kit into valida…
Rich-Harris Sep 16, 2025
3fc41d9
typo
Rich-Harris Sep 16, 2025
39b2e3c
redact sensitive info
Rich-Harris Sep 16, 2025
7010659
document underscore
Rich-Harris Sep 16, 2025
e6aa3ff
regenerate
Rich-Harris Sep 16, 2025
da950e0
rename
Rich-Harris Sep 16, 2025
d3cb536
regenerate
Rich-Harris Sep 16, 2025
37c10c9
guard against array prototype pollution
Rich-Harris Sep 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/khaki-forks-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: enhance remote form functions with schema support, `input` and `issues` properties
5 changes: 5 additions & 0 deletions .changeset/olive-dodos-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

breaking: remote form functions get passed a parsed POJO instead of a `FormData` object now
282 changes: 233 additions & 49 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export const getWeather = query.batch(v.string(), async (cities) => {

## form

The `form` function makes it easy to write data to the server. It takes a callback that receives the current [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)...
The `form` function makes it easy to write data to the server. It takes a callback that receives `data` constructed from the submitted [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)...


```ts
Expand Down Expand Up @@ -259,30 +259,28 @@ export const getPosts = query(async () => { /* ... */ });

export const getPost = query(v.string(), async (slug) => { /* ... */ });

export const createPost = form(async (data) => {
// Check the user is logged in
const user = await auth.getUser();
if (!user) error(401, 'Unauthorized');

const title = data.get('title');
const content = data.get('content');

// Check the data is valid
if (typeof title !== 'string' || typeof content !== 'string') {
error(400, 'Title and content are required');
export const createPost = form(
v.object({
title: v.pipe(v.string(), v.nonEmpty()),
content:v.pipe(v.string(), v.nonEmpty())
}),
async ({ title, content }) => {
// Check the user is logged in
const user = await auth.getUser();
if (!user) error(401, 'Unauthorized');

const slug = title.toLowerCase().replace(/ /g, '-');

// Insert into the database
await db.sql`
INSERT INTO post (slug, title, content)
VALUES (${slug}, ${title}, ${content})
`;

// Redirect to the newly created page
redirect(303, `/blog/${slug}`);
}

const slug = title.toLowerCase().replace(/ /g, '-');

// Insert into the database
await db.sql`
INSERT INTO post (slug, title, content)
VALUES (${slug}, ${title}, ${content})
`;

// Redirect to the newly created page
redirect(303, `/blog/${slug}`);
});
);
```

...and returns an object that can be spread onto a `<form>` element. The callback is called whenever the form is submitted.
Expand Down Expand Up @@ -310,7 +308,184 @@ export const createPost = form(async (data) => {
</form>
```

The form object contains `method` and `action` properties that allow it to work without JavaScript (i.e. it submits data and reloads the page). It also has an `onsubmit` handler that progressively enhances the form when JavaScript is available, submitting data *without* reloading the entire page.
As with `query`, if the callback uses the submitted `data`, it should be [validated](#query-Query-arguments) by passing a [Standard Schema](https://standardschema.dev) as the first argument to `form`. The one difference is to `query` is that the schema inputs must all be of type `string` or `File`, since that's all the original `FormData` provides. You can however coerce the value into a different type — how to do that depends on the validation library you use.

```ts
/// file: src/routes/count.remote.js
import * as v from 'valibot';
import { form } from '$app/server';

export const setCount = form(
v.object({
// Valibot:
count: v.pipe(v.string(), v.transform((s) => Number(s)), v.number()),
// Zod:
// count: v.coerce.number()
}),
async ({ count }) => {
// ...
}
);
```

The `name` attributes on the form controls must correspond to the properties of the schema — `title` and `content` in this case. If you schema contains objects, use object notation:

```svelte
<!--
results in a
{
name: { first: string, last: string },
jobs: Array<{ title: string, company: string }>
}
object
-->
<input name="name.first" />
<input name="name.last" />
{#each jobs as job, idx}
<input name="jobs[{idx}].title">
<input name="jobs[{idx}].company">
{/each}
```

To indicate a repeated field, use a `[]` suffix:

```svelte
<label><input type="checkbox" name="language[]" value="html" /> HTML</label>
<label><input type="checkbox" name="language[]" value="css" /> CSS</label>
<label><input type="checkbox" name="language[]" value="js" /> JS</label>
```

If you'd like type safety and autocomplete when setting `name` attributes, use the form object's `field` method:

```svelte
<label>
<h2>Title</h2>
<input name={+++createPost.field('title')+++} />
</label>
```

This will error during typechecking if `title` does not exist on your schema.

The form object contains `method` and `action` properties that allow it to work without JavaScript (i.e. it submits data and reloads the page). It also has an [attachment](/docs/svelte/@attach) that progressively enhances the form when JavaScript is available, submitting data *without* reloading the entire page.

### Validation

If the submitted data doesn't pass the schema, the callback will not run. Instead, the form object's `issues` object will be populated:

```svelte
<form {...createPost}>
<label>
<h2>Title</h2>

+++ {#if createPost.issues.title}
{#each createPost.issues.title as issue}
<p class="issue">{issue.message}</p>
{/each}
{/if}+++

<input
name="title"
+++aria-invalid={!!createPost.issues.title}+++
/>
</label>

<label>
<h2>Write your post</h2>

+++ {#if createPost.issues.content}
{#each createPost.issues.content as issue}
<p class="issue">{issue.message}</p>
{/each}
{/if}+++

<textarea
name="content"
+++aria-invalid={!!createPost.issues.content}+++
></textarea>
</label>

<button>Publish!</button>
</form>
```

You don't need to wait until the form is submitted to validate the data — you can call `validate()` programmatically, for example in an `oninput` callback (which will validate the data on every keystroke) or an `onchange` callback:

```svelte
<form {...createPost} oninput={() => createPost.validate()}>
<!-- -->
</form>
```

By default, issues will be ignored if they belong to form controls that haven't yet been interacted with. To validate _all_ inputs, call `validate({ includeUntouched: true })`.

For client-side validation, you can specify a _preflight_ schema which will populate `issues` and prevent data being sent to the server if the data doesn't validate:

```svelte
<script>
import * as v from 'valibot';
import { createPost } from '../data.remote';

const schema = v.object({
title: v.pipe(v.string(), v.nonEmpty()),
content:v.pipe(v.string(), v.nonEmpty())
});
</script>

<h1>Create a new post</h1>

<form {...+++createPost.preflight(schema)+++}>
<!-- -->
</form>
```

> [!NOTE] The preflight schema can be the same object as your server-side schema, if appropriate, though it won't be able to do server-side checks like 'this value already exists in the database'. Note that you cannot export a schema from a `.remote.ts` or `.remote.js` file, so the schema must either be exported from a shared module, or from a `<script module>` block in the component containing the `<form>`.

### Live inputs

The form object contains a `input` property which reflects its current value. As the user interacts with the form, `input` is automatically updated:

```svelte
<form {...createPost}>
<!-- -->
</form>

<div class="preview">
<h2>{createPost.input.title}</h2>
<div>{@html render(createPost.input.content)}</div>
</div>
```

### Handling sensitive data

In the case of a non-progressively-enhanced form submission (i.e. where JavaScript is unavailable, for whatever reason) `input` is also populated if the submitted data is invalid, so that the user does not need to fill the entire form out from scratch.

You can prevent sensitive data (such as passwords and credit card numbers) from being sent back to the user by using a name with a leading underscore:

```svelte
<form {...register}>
<label>
Username
<input
name="username"
value={register.input.username}
aria-invalid={!!register.issues.username}
/>
</label>

<label>
Password
<input
type="password"
+++name="_password"+++
+++aria-invalid={!!register.issues._password}+++
/>
</label>

<button>Sign up!</button>
</form>
```

In this example, if the data does not validate, only the first `<input>` will be populated when the page reloads.

### Single-flight mutations

Expand All @@ -331,25 +506,31 @@ export const getPosts = query(async () => { /* ... */ });

export const getPost = query(v.string(), async (slug) => { /* ... */ });

export const createPost = form(async (data) => {
// form logic goes here...
export const createPost = form(
v.object({/* ... */}),
async (data) => {
// form logic goes here...

// Refresh `getPosts()` on the server, and send
// the data back with the result of `createPost`
+++await getPosts().refresh();+++
// Refresh `getPosts()` on the server, and send
// the data back with the result of `createPost`
+++await getPosts().refresh();+++

// Redirect to the newly created page
redirect(303, `/blog/${slug}`);
});
// Redirect to the newly created page
redirect(303, `/blog/${slug}`);
}
);

export const updatePost = form(async (data) => {
// form logic goes here...
const result = externalApi.update(post);
export const updatePost = form(
v.object({/* ... */}),
async (data) => {
// form logic goes here...
const result = externalApi.update(post);

// The API already gives us the updated post,
// no need to refresh it, we can set it directly
+++await getPost(post.id).set(result);+++
});
// The API already gives us the updated post,
// no need to refresh it, we can set it directly
+++await getPost(post.id).set(result);+++
}
);
```

The second is to drive the single-flight mutation from the client, which we'll see in the section on [`enhance`](#form-enhance).
Expand Down Expand Up @@ -387,11 +568,14 @@ export const getPosts = query(async () => { /* ... */ });
export const getPost = query(v.string(), async (slug) => { /* ... */ });

// ---cut---
export const createPost = form(async (data) => {
// ...
export const createPost = form(
v.object({/* ... */}),
async (data) => {
// ...

return { success: true };
});
return { success: true };
}
);
```

```svelte
Expand All @@ -402,7 +586,9 @@ export const createPost = form(async (data) => {

<h1>Create a new post</h1>

<form {...createPost}><!-- ... --></form>
<form {...createPost}>
<!-- -->
</form>

{#if createPost.result?.success}
<p>Successfully published!</p>
Expand Down Expand Up @@ -438,9 +624,7 @@ We can customize what happens when the form is submitted with the `enhance` meth
showToast('Oh no! Something went wrong');
}
})}>
<input name="title" />
<textarea name="content"></textarea>
<button>publish</button>
<!-- -->
</form>
```

Expand Down Expand Up @@ -517,7 +701,7 @@ The `command` function, like `form`, allows you to write data to the server. Unl

> [!NOTE] Prefer `form` where possible, since it gracefully degrades if JavaScript is disabled or fails to load.

As with `query`, if the function accepts an argument, it should be [validated](#query-Query-arguments) by passing a [Standard Schema](https://standardschema.dev) as the first argument to `command`.
As with `query` and `form`, if the function accepts an argument, it should be [validated](#query-Query-arguments) by passing a [Standard Schema](https://standardschema.dev) as the first argument to `command`.

```ts
/// file: likes.remote.js
Expand Down
Loading
Loading