This post covers practical tips for using remote queries, commands, and forms in SvelteKit.
Forms
Remote Forms vs Superforms
Superforms is a great library for handling forms in SvelteKit. It can do things that remote forms can’t. When using Superforms’s json mode, it converts the form data into a JSON object that is serialized with the devalue library and sent to the server. Unlike FormData, which only supports string | Blob values, the JSON object can contain any type of value supported by transport hooks.
Superforms also uses JSONSchema adapters to obtain runtime information about your schema. This allows it to provide validation attributes like required, min, max, minlength, maxlength, and pattern. It also allows it to know when a field should be coerced to other types like null, boolean, number, Date, etc.
Remote forms, on the other hand, are a bit more limited. They only send FormData to the server. Aside from number and boolean checkbox inputs, remote forms does not handle any coercion of values. That is why your schema must only include the following input types: string, string[], number, boolean, File, and File[]. Coercion to other output types must be handled by your schema.
undefined is also supported as an input type, because some fields may be excluded from the FormData such as boolean checkbox or fields that are conditionally rendered in the UI. However, adding .optional() or .undefined() to a schema for a field that is always present in the FormData doesn’t actually do anything. It will still be included in the FormData as an empty string. All it does is incorrectly misrepresent the input types.
Remote forms also use StandardSchemaV1, which is just types. It does not provide any runtime information about your schema. So it cannot automatically provide validation attributes. If you have a field that is nullable, remote forms won’t know that until the validation actually runs. The value that the schema receives will always be one of the types mentioned in the previous two paragraphs.
Dates
Dates are a bit tricky to handle. The reason SvelteKit doesn’t automatically coerce date inputs to Date objects, is because the coercion is handled on the server, which could be in a different timezone than the client. This could cause issues with date parsing and validation.
A date input uses the format YYYY-MM-DD and the datetime-local input uses the format YYYY-MM-DDTHH:MM. Since this does not include the client’s timezone information, the date should be converted to a UNIX timestamp in the client instead or the form should include the timezone as a hidden input. Otherwise, the conversion to Date on the server could be incorrect.
For that, this function could be useful:
svelte
<script lang="ts"> import { testForm } from '$lib/remote/forms.remote.ts'; import { appendTimezone } from '$lib/utils.ts'; let date = $state('');</script><form {...testForm}> <!-- The date input does not have a name. --> <!-- Only the hidden input is submitted to the server. --> <input type="date" bind:value={date} /> <input {...testForm.fields.date.as("hidden", appendTimezone(date))} /> <button type="submit">Submit</button></form>
ts
/** * Appends the local timezone offset to a date string * @param {string} dateString - Date string in format YYYY-MM-DD or YYYY-MM-DDThh:mm * @returns {string} Date string with timezone appended (e.g., "2024-03-15T10:30-05:00") */export function appendTimezone(dateString: string): string { if (!dateString) return ''; // Parse the input date string const date = new Date(dateString); // Check if date is valid if (isNaN(date.getTime())) { throw new Error('Invalid date string'); } // Get timezone offset in minutes const offsetMinutes = date.getTimezoneOffset(); // Convert to hours and minutes (offset is negative for timezones ahead of UTC) const offsetHours = Math.floor(Math.abs(offsetMinutes) / 60); const offsetMins = Math.abs(offsetMinutes) % 60; // Determine sign (getTimezoneOffset returns positive for zones behind UTC) const sign = offsetMinutes <= 0 ? '+' : '-'; // Format timezone string (e.g., "+05:00" or "-08:00") const timezone = `${sign}${String(offsetHours).padStart(2, '0')}:${String(offsetMins).padStart(2, '0')}`; // Handle different input formats if (dateString.includes('T')) { // Already has time component return `${dateString}${timezone}`; } else { // Only has date, append midnight time return `${dateString}T00:00:00${timezone}`; }}
Discriminated Unions
Because the fields property of a form instance is a proxy object (with values returned as methods), you can’t use discriminated unions in the usual way. You’ll need to use type assertions to make them work properly.
ts
import * as v from 'valibot';export type TypeA = v.InferInput<typeof typeA>;export const typeA = v.object({ propA: v.string(),});export type TypeB = v.InferInput<typeof typeA>;export const typeB = v.object({ propB: v.number(),});export const schema = v.object({ diff: v.variant('type', [ v.object({ type: v.literal('a'), ...typeA.entries, }), v.object({ type: v.literal('b'), ...typeB.entries, }), ]),});
Sometimes you need to validate a form schema that requires additional data. One way to do this, would be to use an async refinement (zod) or check (valibot) in your schema that fetches the data when the schema is validated.
ts
const userId = z.string().refine(async (id) => { // verify that ID exists in database return true;});export const someForm = form(z.object({ userId }), (output) => { // ...});
Alternatively, you can use the invalid method provided by the remote form to perform additional validation and report the issues to the client.
ts
export const someForm = form(z.object({ userId: z.string() }), (output, invalid) => { const user = await getUser(input.userId); if (!user) throw invalid(invalid.userId("User not found")); // ...});
If you want to wait to validate the entire schema until inside the remote function, because you want to fetch data before the schema is validated, then you can use the standard schema validator and pass the entire issues array to the invalid method.
ts
async function safeParse<I, O>(schema: StandardSchemaV1<I, O>, input: I) { const result = await schema["~standard"].validate(input); if (result.issues) return { success: false, issues: result.issues } as const; else return { success: true, output: result.value } as const;};export const someForm = form("unchecked", async (input: ExpectedInputType, invalid) => { const user = await getUser(input.userId); const result = await safeParse(schema(user), input); if (!result.success) throw invalid(...result.issues);});
Utilities
Guarded Remote Functions
Guarded remote functions ensure that your remote functions are only called by authenticated users. Copy the following code into a new file called $lib/server/remote.ts and import the guardedQuery(), guardedCommand(), and guardedForm() functions into your remote functions code.
The lint rule to enforce guarded remote functions is a simple ESLint rule that checks that all exports in .remote.ts files use guardedQuery(), guardedCommand(), or guardedForm(). Direct exports of query(), command(), or form() are not allowed unless you explicitly opt out for a given remote function using eslint-disable-next-line custom/enforce-guarded-functions.
js
import js from "@eslint/js";import eslintConfigPrettier from "eslint-config-prettier/flat";import svelte from "eslint-plugin-svelte";import globals from "globals";import enforceGuardedExports from "./eslint/enforce-guarded-functions.js";import svelteConfig from "./svelte.config.js";// Use the typescript-eslint aggregator for flat config presets// (requires devDependency: "typescript-eslint")import tseslint from "typescript-eslint";export default [ ... // existing rules { files: ["**/*.remote.ts"], plugins: { custom: { rules: { "enforce-guarded-functions": enforceGuardedExports } } }, rules: { "custom/enforce-guarded-functions": "error" } }];
js
export default { meta: { type: "problem", docs: { description: "Enforce that exports in .remote.ts files use guardedQuery(), guardedCommand(), or guardedForm()", category: "Best Practices", recommended: true }, messages: { unguardedExport: "Exports in .remote.ts files must use guardedQuery(), guardedCommand(), or guardedForm(). Direct exports of query(), command(), or form() are not allowed.", mustBeGuarded: 'Export "{{name}}" must be the return value of guardedQuery(), guardedCommand(), or guardedForm().' }, schema: [] }, create(context) { const filename = context.getFilename(); // Only apply this rule to files ending in .remote.ts if (!filename.endsWith(".remote.ts")) { return {}; } const guardedFunctions = new Set(["guardedQuery", "guardedCommand", "guardedForm"]); const unguardedFunctions = new Set(["query", "command", "form"]); function isGuardedCall(node) { return node.type === "CallExpression" && node.callee.type === "Identifier" && guardedFunctions.has(node.callee.name); } function isUnguardedCall(node) { return node.callee.type === "Identifier" && unguardedFunctions.has(node.callee.name); } function checkExportDeclaration(node) { if (node.type === "ExportNamedDeclaration") { if (node.declaration && node.declaration.type === "VariableDeclaration") { for (const declarator of node.declaration.declarations) { if (declarator.init) { if (declarator.init.type === "CallExpression" && isUnguardedCall(declarator.init)) { context.report({ node: declarator.init, messageId: "unguardedExport" }); } else if (!isGuardedCall(declarator.init)) { context.report({ node: declarator, messageId: "mustBeGuarded", data: { name: declarator.id.name } }); } } } } } } return { ExportNamedDeclaration: checkExportDeclaration }; }};