Skip to content

Latest commit

 

History

History
1260 lines (968 loc) · 41.5 KB

File metadata and controls

1260 lines (968 loc) · 41.5 KB
title Remote functions

Available since 2.27

Remote functions are a tool for type-safe communication between client and server. They can be called anywhere in your app, but always run on the server, meaning they can safely access server-only modules containing things like environment variables and database clients.

Combined with Svelte's experimental support for await, it allows you to load and manipulate data directly inside your components.

This feature is currently experimental, meaning it is likely to contain bugs and is subject to change without notice. You must opt in by adding the kit.experimental.remoteFunctions option in your svelte.config.js and optionally, the compilerOptions.experimental.async option to use await in components:

/// file: svelte.config.js
/** @type {import('@sveltejs/kit').Config} */
const config = {
	kit: {
		experimental: {
			+++remoteFunctions: true+++
		}
	},
	compilerOptions: {
		experimental: {
			+++async: true+++
		}
	}
};

export default config;

Overview

Remote functions are exported from a .remote.js or .remote.ts file, and come in four flavours: query, form, command and prerender. On the client, the exported functions are transformed to fetch wrappers that invoke their counterparts on the server via a generated HTTP endpoint. Remote files can be placed anywhere in your src directory (except inside the src/lib/server directory), and third party libraries can provide them, too.

query

The query function allows you to read dynamic data from the server (for static data, consider using prerender instead):

/// file: src/routes/blog/data.remote.js
// @filename: ambient.d.ts
declare module '$lib/server/database' {
	export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
}
// @filename: index.js
// ---cut---
import { query } from '$app/server';
import * as db from '$lib/server/database';

export const getPosts = query(async () => {
	const posts = await db.sql`
		SELECT title, slug
		FROM post
		ORDER BY published_at
		DESC
	`;

	return posts;
});

[!NOTE] Throughout this page, you'll see imports from fictional modules like $lib/server/database and $lib/server/auth. These are purely for illustrative purposes — you can use whatever database client and auth setup you like.

The db.sql function above is a tagged template function that escapes any interpolated values.

The query returned from getPosts works as a Promise that resolves to posts:

<!--- file: src/routes/blog/+page.svelte --->
<script>
	import { getPosts } from './data.remote';
</script>

<h1>Recent posts</h1>

<ul>
	{#each await getPosts() as { title, slug }}
		<li><a href="/blog/{slug}">{title}</a></li>
	{/each}
</ul>

Until the promise resolves — and if it errors — the nearest <svelte:boundary> will be invoked.

While using await is recommended, as an alternative the query also has loading, error and current properties:

<!--- file: src/routes/blog/+page.svelte --->
<script>
	import { getPosts } from './data.remote';

	const query = getPosts();
</script>

<h1>Recent posts</h1>

{#if query.error}
	<p>oops!</p>
{:else if query.loading}
	<p>loading...</p>
{:else}
	<ul>
		{#each query.current as { title, slug }}
			<li><a href="/blog/{slug}">{title}</a></li>
		{/each}
	</ul>
{/if}

[!NOTE] For the rest of this document, we'll use the await form.

Query arguments

Query functions can accept an argument, such as the slug of an individual post:

<!--- file: src/routes/blog/[slug]/+page.svelte --->
<script>
	import { getPost } from '../data.remote';

	let { params } = $props();

	const post = $derived(await getPost(params.slug));
</script>

<h1>{post.title}</h1>
<div>{@html post.content}</div>

Since getPost exposes an HTTP endpoint, it's important to validate this argument to be sure that it's the correct type. For this, we can use any Standard Schema validation library such as Zod or Valibot:

/// file: src/routes/blog/data.remote.js
// @filename: ambient.d.ts
declare module '$lib/server/database' {
	export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
}
// @filename: index.js
// ---cut---
import * as v from 'valibot';
import { error } from '@sveltejs/kit';
import { query } from '$app/server';
import * as db from '$lib/server/database';

export const getPosts = query(async () => { /* ... */ });

export const getPost = query(v.string(), async (slug) => {
	const [post] = await db.sql`
		SELECT * FROM post
		WHERE slug = ${slug}
	`;

	if (!post) error(404, 'Not found');
	return post;
});

Both the argument and the return value are serialized with devalue, which handles types like Date and Map (and custom types defined in your transport hook) in addition to JSON.

[!NOTE] For query and prerender arguments (but not return values), objects, maps, and sets are sorted so that instances with the same members result in the same cache key. For example, getPosts({ limit: 10, offset: 10 }) and getPosts({ offset: 10, limit: 10 }) will result in the same cache key. If order is important to you, you'll have to use an array.

Refreshing queries

Any query can be re-fetched via its refresh method, which retrieves the latest value from the server:

<button onclick={() => getPosts().refresh()}>
	Check for new posts
</button>

[!NOTE] Queries are cached while they're on the page, meaning getPosts() === getPosts(). This means you don't need a reference like const posts = getPosts() in order to update the query.

query.batch

query.batch works like query except that it batches requests that happen within the same macrotask. This solves the so-called n+1 problem: rather than each query resulting in a separate database call (for example), simultaneous queries are grouped together.

On the server, the callback receives an array of the arguments the function was called with. It must return a function of the form (input: Input, index: number) => Output. SvelteKit will then call this with each of the input arguments to resolve the individual calls with their results.

/// file: weather.remote.js
// @filename: ambient.d.ts
declare module '$lib/server/database' {
	export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
}
// @filename: index.js
// ---cut---
import * as v from 'valibot';
import { query } from '$app/server';
import * as db from '$lib/server/database';

export const getWeather = query.batch(v.string(), async (cityIds) => {
	const weather = await db.sql`
		SELECT * FROM weather
		WHERE city_id = ANY(${cityIds})
	`;
	const lookup = new Map(weather.map(w => [w.city_id, w]));

	return (cityId) => lookup.get(cityId);
});
<!--- file: Weather.svelte --->
<script>
	import CityWeather from './CityWeather.svelte';
	import { getWeather } from './weather.remote';

	let { cities } = $props();
	let limit = $state(5);
</script>

<h2>Weather</h2>

{#each cities.slice(0, limit) as city}
	<h3>{city.name}</h3>
	<CityWeather weather={await getWeather(city.id)} />
{/each}

{#if cities.length > limit}
	<button onclick={() => limit += 5}>
		Load more
	</button>
{/if}

form

The form function makes it easy to write data to the server. It takes a callback that receives data constructed from the submitted FormData...

/// file: src/routes/blog/data.remote.js
// @filename: ambient.d.ts
declare module '$lib/server/database' {
	export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
}

declare module '$lib/server/auth' {
	interface User {
		name: string;
	}

	/**
	 * Gets a user's info from their cookies, using `getRequestEvent`
	 */
	export function getUser(): Promise<User | null>;
}
// @filename: index.js
// ---cut---
import * as v from 'valibot';
import { error, redirect } from '@sveltejs/kit';
import { query, form } from '$app/server';
import * as db from '$lib/server/database';
import * as auth from '$lib/server/auth';

export const getPosts = query(async () => { /* ... */ });

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

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}`);
	}
);

...and returns an object that can be spread onto a <form> element. The callback is called whenever the form is submitted.

<!--- file: src/routes/blog/new/+page.svelte --->
<script>
	import { createPost } from '../data.remote';
</script>

<h1>Create a new post</h1>

<form {...createPost}>
	<!-- form content goes here -->

	<button>Publish!</button>
</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 attachment 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 by passing a Standard Schema as the first argument to form.

Fields

A form is composed of a set of fields, which are defined by the schema. In the case of createPost, we have two fields, title and content, which are both strings. To get the attributes for a field, call its .as(...) method, specifying which input type to use. For most input types, you can also pass a second argument — .as(type, value) — to control the rendered value:

<form {...createPost}>
	<label>
		<h2>Title</h2>
		+++<input {...createPost.fields.title.as('text')} />+++
	</label>

	<label>
		<h2>Write your post</h2>
		+++<textarea {...createPost.fields.content.as('text')}></textarea>+++
	</label>

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

These attributes allow SvelteKit to set the correct input type, set a name that is used to construct the data passed to the handler, populate the value of the form (for example following a failed submission, to save the user having to re-enter everything), and set the aria-invalid state.

Passing a second argument to .as(...) is useful when rendering a form from existing data, such as an edit form or multiple instances created with for(...). radio, submit and hidden inputs always need this value, and checkbox inputs need it when they represent one option in an array field. file inputs cannot be populated this way.

[!NOTE] The generated name attribute uses JS object notation (e.g. nested.array[0].value). String keys that require quotes such as object['nested-array'][0].value are not supported. Under the hood, boolean checkbox and number field names are prefixed with b: and n:, respectively, to signal SvelteKit to coerce the values from strings prior to validation.

Fields can be nested in objects and arrays, and their values can be strings, numbers, booleans or File objects. For example, if your schema looked like this...

/// file: data.remote.js
import * as v from 'valibot';
import { form } from '$app/server';
// ---cut---
const datingProfile = v.object({
	name: v.string(),
	photo: v.file(),
	info: v.object({
		height: v.number(),
		likesDogs: v.optional(v.boolean(), false)
	}),
	attributes: v.array(v.string())
});

export const createProfile = form(datingProfile, (data) => { /* ... */ });

...your form could look like this:

<script>
	import { createProfile } from './data.remote';

	const { name, photo, info, attributes } = createProfile.fields;
</script>

<form {...createProfile} enctype="multipart/form-data">
	<label>
		<input {...name.as('text')} /> Name
	</label>

	<label>
		<input {...photo.as('file')} /> Photo
	</label>

	<label>
		<input {...info.height.as('number')} /> Height (cm)
	</label>

	<label>
		<input {...info.likesDogs.as('checkbox')} /> I like dogs
	</label>

	<h2>My best attributes</h2>
	<input {...attributes[0].as('text')} />
	<input {...attributes[1].as('text')} />
	<input {...attributes[2].as('text')} />

	<button>submit</button>
</form>

Because our form contains a file input, we've added an enctype="multipart/form-data" attribute. The values for info.height and info.likesDogs are coerced to a number and a boolean respectively.

[!NOTE] If a checkbox input is unchecked, the value is not included in the FormData object that SvelteKit constructs the data from. As such, we have to make the value optional in our schema. In Valibot that means using v.optional(v.boolean(), false) instead of just v.boolean(), whereas in Zod it would mean using z.coerce.boolean<boolean>().

In the case of radio and checkbox inputs that all belong to the same field, the value must be specified as a second argument to .as(...):

/// file: constants.js
export const operatingSystems = /** @type {const} */ (['windows', 'mac', 'linux']);
export const languages = /** @type {const} */ (['html', 'css', 'js']);
/// file: data.remote.js
// @filename: constants.js
export const operatingSystems = /** @type {const} */ (['windows', 'mac', 'linux']);
export const languages = /** @type {const} */ (['html', 'css', 'js']);
// @filename: index.js
import * as v from 'valibot';
import { form } from '$app/server';
// ---cut---
import { operatingSystems, languages } from './constants';

export const survey = form(
	v.object({
		operatingSystem: v.picklist(operatingSystems),
		languages: v.optional(v.array(v.picklist(languages)), []),
	}),
	(data) => { /* ... */ },
);
<form {...survey}>
	<h2>Which operating system do you use?</h2>

	{#each operatingSystems as os}
		<label>
			<input {...survey.fields.operatingSystem.as('radio', os)}>
			{os}
		</label>
	{/each}

	<h2>Which languages do you write code in?</h2>

	{#each languages as language}
		<label>
			<input {...survey.fields.languages.as('checkbox', language)}>
			{language}
		</label>
	{/each}

	<button>submit</button>
</form>

Alternatively, you could use select and select multiple:

<form {...survey}>
	<h2>Which operating system do you use?</h2>

	<select {...survey.fields.operatingSystem.as('select')}>
		{#each operatingSystems as os}
			<option>{os}</option>
		{/each}
	</select>

	<h2>Which languages do you write code in?</h2>

	<select {...survey.fields.languages.as('select multiple')}>
		{#each languages as language}
			<option>{language}</option>
		{/each}
	</select>

	<button>submit</button>
</form>

[!NOTE] As with unchecked checkbox inputs, if no selections are made then the data will be undefined. For this reason, the languages field uses v.optional(v.array(...), []) rather than just v.array(...).

Programmatic validation

In addition to declarative schema validation, you can programmatically mark fields as invalid inside the form handler using the invalid helper from @sveltejs/kit. This is useful for cases where you can't know if something is valid until you try to perform some action.

  • It throws just like redirect or error
  • It accepts multiple arguments that can be strings (for issues relating to the form as a whole — these will only show up in fields.allIssues()) or standard-schema-compliant issues (for those relating to a specific field). Use the issue parameter for type-safe creation of such issues:
/// file: src/routes/shop/data.remote.js
import * as v from 'valibot';
import { invalid } from '@sveltejs/kit';
import { form } from '$app/server';
import * as db from '$lib/server/database';

export const buyHotcakes = form(
	v.object({
		qty: v.pipe(
			v.number(),
			v.minValue(1, 'you must buy at least one hotcake')
		)
	}),
	async (data, issue) => {
		try {
			await db.buy(data.qty);
		} catch (e) {
			if (e.code === 'OUT_OF_STOCK') {
				invalid(
					issue.qty(`we don't have enough hotcakes`)
				);
			}
		}
	}
);

Validation

If the submitted data doesn't pass the schema, the callback will not run. Instead, each invalid field's issues() method will return an array of { message: string } objects, and the aria-invalid attribute (returned from as(...)) will be set to true:

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

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

		<input {...createPost.fields.title.as('text')} />
	</label>

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

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

		<textarea {...createPost.fields.content.as('text')}></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:

<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:

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

To get a list of all issues, rather than just those belonging to a single field, you can use the fields.allIssues() method:

{#each createPost.fields.allIssues() as issue}
	<p>{issue.message}</p>
{/each}

Getting/setting inputs

Each field has a value() method that reflects its current value. As the user interacts with the form, it is automatically updated:

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

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

Alternatively, createPost.fields.value() would return a { title, content } object.

You can update a field (or a collection of fields) via the set(...) method:

<script>
	import { createPost } from '../data.remote';

	// this...
	createPost.fields.set({
		title: 'My new blog post',
		content: 'Lorem ipsum dolor sit amet...'
	});

	// ...is equivalent to this:
	createPost.fields.title.set('My new blog post');
	createPost.fields.content.set('Lorem ipsum dolor sit amet');
</script>

Handling sensitive data

In the case of a non-progressively-enhanced form submission (i.e. where JavaScript is unavailable, for whatever reason) value() 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:

<form {...register}>
	<label>
		Username
		<input {...register.fields.username.as('text')} />
	</label>

	<label>
		Password
		<input +++{...register.fields._password.as('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.

Returns and redirects

The example above uses redirect(...), which sends the user to the newly created page. Alternatively, the callback could return data, in which case it would be available as createPost.result:

/// file: src/routes/blog/data.remote.js
// @filename: ambient.d.ts
declare module '$lib/server/database' {
	export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
}

declare module '$lib/server/auth' {
	interface User {
		name: string;
	}

	/**
	 * Gets a user's info from their cookies, using `getRequestEvent`
	 */
	export function getUser(): Promise<User | null>;
}
// @filename: index.js
import * as v from 'valibot';
import { error, redirect } from '@sveltejs/kit';
import { query, form } from '$app/server';
import * as db from '$lib/server/database';
import * as auth from '$lib/server/auth';

export const getPosts = query(async () => { /* ... */ });

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

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

		return { success: true };
	}
);
<!--- file: src/routes/blog/new/+page.svelte --->
<script>
	import { createPost } from '../data.remote';
</script>

<h1>Create a new post</h1>

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

{#if createPost.result?.success}
	<p>Successfully published!</p>
{/if}

This value is ephemeral — it will vanish if you resubmit, navigate away, or reload the page.

[!NOTE] The result value need not indicate success — it can also contain validation errors, along with any data that should repopulate the form on page reload.

If an error occurs during submission, the nearest +error.svelte page will be rendered.

enhance

We can customize what happens when the form is submitted with the enhance method:

<!--- file: src/routes/blog/new/+page.svelte --->
<script>
	import { createPost } from '../data.remote';
	import { showToast } from '$lib/toast';
</script>

<h1>Create a new post</h1>

<form {...createPost.enhance(async ({ form, data, submit }) => {
	try {
		if (await submit()) {
			form.reset();

			showToast('Successfully published!');
		} else {
			showToast('Invalid data!');
		}
	} catch (error) {
		showToast('Oh no! Something went wrong');
	}
})}>
	<!-- -->
</form>

When using enhance, the <form> is not automatically reset — you must call form.reset() if you want to clear the inputs.

The callback receives the form element, the data it contains, and a submit function.

Multiple instances of a form

Some forms may be repeated as part of a list. In this case you can create separate instances of a form function via for(id) to achieve isolation.

When each instance should render different values, pass them as the second argument to .as(...):

<!--- file: src/routes/todos/+page.svelte --->
<script>
	import { getTodos, modifyTodo } from '../data.remote';
</script>

<h1>Todos</h1>

{#each await getTodos() as todo}
	{@const modify = modifyTodo.for(todo.id)}
	<form {...modify}>
		<input {...modify.fields.description.as('text', todo.description)} />
		<button disabled={!!modify.pending}>save changes</button>
	</form>
{/each}

Multiple submit buttons

It's possible for a <form> to have multiple submit buttons. For example, you might have a single form that allows you to log in or register depending on which button was clicked.

To accomplish this, add a field to your schema for the button value, and use as('submit', value) to bind it:

<!--- file: src/routes/login/+page.svelte --->
<script>
	import { loginOrRegister } from '$lib/auth';
</script>

<form {...loginOrRegister}>
	<label>
		Your username
		<input {...loginOrRegister.fields.username.as('text')} />
	</label>

	<label>
		Your password
		<input {...loginOrRegister.fields._password.as('password')} />
	</label>

	<button {...loginOrRegister.fields.action.as('submit', 'login')}>login</button>
	<button {...loginOrRegister.fields.action.as('submit', 'register')}>register</button>
</form>

In your form handler, you can check which button was clicked:

/// file: $lib/auth.js
import * as v from 'valibot';
import { form } from '$app/server';

export const loginOrRegister = form(
	v.object({
		username: v.string(),
		_password: v.string(),
		action: v.picklist(['login', 'register'])
	}),
	async ({ username, _password, action }) => {
		if (action === 'login') {
			// handle login
		} else {
			// handle registration
		}
	}
);

command

The command function, like form, allows you to write data to the server. Unlike form, it's not specific to an element and can be called from anywhere.

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

As with query and form, if the function accepts an argument, it should be validated by passing a Standard Schema as the first argument to command.

/// file: likes.remote.js
// @filename: ambient.d.ts
declare module '$lib/server/database' {
	export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
}
// @filename: index.js
// ---cut---
import * as v from 'valibot';
import { query, command } from '$app/server';
import * as db from '$lib/server/database';

export const getLikes = query(v.string(), async (id) => {
	const [row] = await db.sql`
		SELECT likes
		FROM item
		WHERE id = ${id}
	`;

	return row.likes;
});

export const addLike = command(v.string(), async (id) => {
	await db.sql`
		UPDATE item
		SET likes = likes + 1
		WHERE id = ${id}
	`;
});

Now simply call addLike, from (for example) an event handler:

<!--- file: +page.svelte --->
<script>
	import { getLikes, addLike } from './likes.remote';
	import { showToast } from '$lib/toast';

	let { item } = $props();
</script>

<button
	onclick={async () => {
		try {
			await addLike(item.id);
		} catch (error) {
			showToast('Something went wrong!');
		}
	}}
>
	add like
</button>

<p>likes: {await getLikes(item.id)}</p>

[!NOTE] Commands cannot be called during render.

Single-flight mutations

The purpose of both form and command is mutating data. In many cases, mutating data invalidates other data. By default, form deals with this by automatically invalidating all queries and load functions following a successful submission, to emulate what would happen with a traditional full-page reload. command, on the other hand, does nothing. Typically, neither of these options is going to be the ideal solution — invalidating everything is likely wasteful, as it's unlikely a form submission changed everything being displayed on your webpage. In the case of command, doing nothing likely under-invalidates your app, leaving stale data displayed. In both cases, it's common to have to perform two round-trips to the server: One to run the mutation, and another after that completes to re-request the data from any queries you need to refresh.

SvelteKit solves both of these problems with single-flight mutations: Your form submission or command invocation can refresh queries and pass their results back to the client in a single request.

Server-driven refreshes

In most circumstances, the server handler knows what client data needs to be updated based on its arguments:

import * as v from 'valibot';
import { error, redirect } from '@sveltejs/kit';
import { query, form } from '$app/server';
const slug = '';
const post = { id: '' };
/** @type {any} */
const externalApi = '';
// ---cut---
export const getPosts = query(async () => { /* ... */ });

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

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`
		// it's safe to throw away the promise from `refresh`,
		// as the framework awaits it for us before serving the response
		+++void getPosts().refresh();+++

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

export const updatePost = form(
	v.object({ id: v.string() }),
	async (post) => {
		// 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
		+++getPost(post.id).set(result);+++
	}
);

Because queries are keyed based on their arguments, await getPost(post.id).set(result) on the server knows to look up the matching getPost(id) on the client to update it. The same goes for getPosts().refresh() -- it knows to look up getPosts() with no argument on the client.

Client-requested refreshes

Unfortunately, life isn't always as simple as the preceding example. The server always knows which query functions to update, but it may not know which specific query instances to update. For example, if getPosts({ filter: 'author:santa' }) is rendered on the client, calling getPosts().refresh() in the server handler won't update it. You'd need to call getPosts({ filter: 'author:santa' }).refresh() instead — but how could you know which specific combinations of filters are currently rendered on the client, especially if your query argument is more complicated than an object with just one key?

SvelteKit makes this easy by allowing the client to request that the server updates specific data using submit().updates (for form) or myCommand().updates (for command):

import type { RemoteQueryUpdate, RemoteQuery } from '@sveltejs/kit';
interface Post {}
declare function submit(): Promise<any> & {
	updates(...updates: RemoteQueryUpdate[]): Promise<any>;
}

declare function getPosts(args: { filter: string }): RemoteQuery<Post[]>;
declare const newPost: Post;
// ---cut---
await submit().updates(
	// to request all active instances of getPosts
	getPosts,
	// to request a specific instance
	getPosts({ filter: 'author:santa' }),
	// to request a specific instance with an optimistic override
	getPosts({ filter: 'author:santa' }).withOverride((posts) => [newPost, ...posts])
);

It's not enough to just request the updates from the client -- you need to accept them from the server as well:

import * as v from 'valibot';
import { error, redirect } from '@sveltejs/kit';
const slug = '';
const post = { id: '' };
/** @type {any} */
const externalApi = '';
// ---cut---
import { query, form, requested } from '$app/server';

export const getPosts = query(v.object({ filter: v.string() }), async ({ filter }) => { /* ... */ });

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

		+++for (const arg of requested(getPosts, 1)) {+++
		+++	void getPosts(arg).refresh();+++
		+++}+++

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

requested gives you access to the requested query arguments for the supplied query. It returns the parsed arguments for the query -- when these arguments are passed back into the query in getPosts(arg).refresh(), they will not be parsed again. If parsing an argument fails, that query will error, but the entire command will not fail. requested's second parameter, limit, is the maximum number of items it will return. Any refresh requests beyond this limit will fail.

Additionally, requested allows a simple shorthand when all you want to do is refresh the requested query instances:

import type { RemoteQueryFunction } from '@sveltejs/kit';
import { requested } from '$app/server';
declare const getPosts: RemoteQueryFunction<any, any>;
// ---cut---
// this is the same as looping over the result and calling `void getPosts(arg).refresh()`.
await requested(getPosts, 1).refreshAll();

prerender

The prerender function is similar to query, except that it will be invoked at build time to prerender the result. Use this for data that changes at most once per redeployment.

/// file: src/routes/blog/data.remote.js
// @filename: ambient.d.ts
declare module '$lib/server/database' {
	export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
}
// @filename: index.js
// ---cut---
import { prerender } from '$app/server';
import * as db from '$lib/server/database';

export const getPosts = prerender(async () => {
	const posts = await db.sql`
		SELECT title, slug
		FROM post
		ORDER BY published_at
		DESC
	`;

	return posts;
});

You can use prerender functions on pages that are otherwise dynamic, allowing for partial prerendering of your data. This results in very fast navigation, since prerendered data can live on a CDN along with your other static assets.

In the browser, prerendered data is saved using the Cache API. This cache survives page reloads, and will be cleared when the user first visits a new deployment of your app.

[!NOTE] When the entire page has export const prerender = true, you cannot use queries, as they are dynamic.

Prerender arguments

As with queries, prerender functions can accept an argument, which should be validated with a Standard Schema:

/// file: src/routes/blog/data.remote.js
// @filename: ambient.d.ts
declare module '$lib/server/database' {
	export function sql(strings: TemplateStringsArray, ...values: any[]): Promise<any[]>;
}
// @filename: index.js
// ---cut---
import * as v from 'valibot';
import { error } from '@sveltejs/kit';
import { prerender } from '$app/server';
import * as db from '$lib/server/database';

export const getPosts = prerender(async () => { /* ... */ });

export const getPost = prerender(v.string(), async (slug) => {
	const [post] = await db.sql`
		SELECT * FROM post
		WHERE slug = ${slug}
	`;

	if (!post) error(404, 'Not found');
	return post;
});

Any calls to getPost(...) found by SvelteKit's crawler while prerendering pages will be saved automatically, but you can also specify which values it should be called with using the inputs option:

/// file: src/routes/blog/data.remote.js
import * as v from 'valibot';
import { prerender } from '$app/server';
// ---cut---

export const getPost = prerender(
	v.string(),
	async (slug) => { /* ... */ },
	{
		inputs: () => [
			'first-post',
			'second-post',
			'third-post'
		]
	}
);

By default, prerender functions are excluded from your server bundle, which means that you cannot call them with any arguments that were not prerendered. You can set dynamic: true to change this behaviour:

/// file: src/routes/blog/data.remote.js
import * as v from 'valibot';
import { prerender } from '$app/server';
// ---cut---

export const getPost = prerender(
	v.string(),
	async (slug) => { /* ... */ },
	{
		+++dynamic: true+++,
		inputs: () => [
			'first-post',
			'second-post',
			'third-post'
		]
	}
);

Handling validation errors

As long as you're not passing invalid data to your remote functions, there are only two reasons why the argument passed to a command, query or prerender function would fail validation:

  • the function signature changed between deployments, and some users are currently on an older version of your app
  • someone is trying to attack your site by poking your exposed endpoints with bad data

In the second case, we don't want to give the attacker any help, so SvelteKit will generate a generic 400 Bad Request response. You can control the message by implementing the handleValidationError server hook, which, like handleError, must return an App.Error (which defaults to { message: string }):

/// file: src/hooks.server.ts
/** @type {import('@sveltejs/kit').HandleValidationError} */
export function handleValidationError({ event, issues }) {
	return {
		message: 'Nice try, hacker!'
	};
}

If you know what you're doing and want to opt out of validation, you can pass the string 'unchecked' in place of a schema:

/// file: data.remote.ts
import { query } from '$app/server';

export const getStuff = query('unchecked', async ({ id }: { id: string }) => {
	// the shape might not actually be what TypeScript thinks
	// since bad actors might call this function with other arguments
});

Using getRequestEvent

Inside query, form and command you can use getRequestEvent to get the current RequestEvent object. This makes it easy to build abstractions for interacting with cookies, for example:

/// file: user.remote.ts
import { getRequestEvent, query } from '$app/server';
import { findUser } from '$lib/server/database';

export const getProfile = query(async () => {
	const user = await getUser();

	return {
		name: user.name,
		avatar: user.avatar
	};
});

// this query could be called from multiple places, but
// the function will only run once per request
const getUser = query(async () => {
	const { cookies } = getRequestEvent();

	return await findUser(cookies.get('session_id'));
});

Note that some properties of RequestEvent are different inside remote functions:

  • you cannot set headers (other than writing cookies, and then only inside form and command functions)
  • route, params and url relate to the page the remote function was called from, not the URL of the endpoint SvelteKit creates for the remote function. Queries are not re-run when the user navigates (unless the argument to the query changes as a result of navigation), and so you should be mindful of how you use these values. In particular, never use them to determine whether or not a user is authorized to access certain data.

Redirects

Inside query, form and prerender functions it is possible to use the redirect(...) function. It is not possible inside command functions, as you should avoid redirecting here. (If you absolutely have to, you can return a { redirect: location } object and deal with it in the client.)

Caching

By default, remote functions do not cache their results. You can change this by using the cache function from getRequestEvent, which allows you to store the result of a remote function for a certain amount of time.

/// file: src/routes/data.remote.js
// ---cut---
import { query, getRequestEvent } from '$app/server';

export const getFastData = query(async () => {
	const { cache } = getRequestEvent();

	// cache for 100 seconds
	cache('100s');

	return { data: '...' };
});

The cache function accepts either a string representing the time-to-live (TTL), or an object with more detailed configuration:

/// file: src/routes/data.remote.js
import { query, getRequestEvent } from '$app/server';
// ---cut---
export const getFastData = query(async () => {
	const { cache } = getRequestEvent();

	cache({
		// fresh for 1 minute
		ttl: '1m',
		// can serve stale up to 5 minutes
		stale: '5m',
		// shareable across users (CDN caching) or private to user (browser caching); default private
		scope: 'private',
		// used for invalidation, when not given is the URL
		tags: ['my-data'],
	});

	// ...
});

There are two variants of the cache:

  • Private cache (scope: 'private'): Per-user cache implemented using the browser's Cache API.
  • Public cache (scope: 'public'): Shareable across users. The implementation is platform-specific (e.g., using CDN-Cache-Control and Cache-Tag headers on Vercel/Netlify, or a runtime cache on Node).

Invalidating the cache

To invalidate the cache for a specific query, you can call its invalidate method:

/// file: src/routes/data.remote.js
import { query, command } from '$app/server';

export const getFastData = query(async () => {
	const { cache } = getRequestEvent();
	cache('100s');
	return { data: '...' };
});

export const updateData = command(async () => {
	// invalidates getFastData;
	// the next time someone requests it, it will be called again
	getFastData().invalidate();
});

Alternatively, if you used tags when setting up the cache, you can invalidate by tag using cache.invalidate(...):

/// file: src/routes/data.remote.js
import { query, command, getRequestEvent } from '$app/server';

export const getFastData = query(async () => {
	const { cache } = getRequestEvent();
	cache({ ttl: '100s', tags: ['my-data'] });
	return { data: '...' };
});

export const updateData = command(async () => {
	const { cache } = getRequestEvent();
	// invalidate all queries using the my-data tag;
	// the next time someone requests a query which had that tag, it will be called again
	cache.invalidate(['my-data']);
});

[!NOTE] tags are public since they could invalidate the private cache in the browser