If you have read SvelteKit CRUD in one command, you know what a scaffold gives you. This is the same scaffold with one difference: the create and edit forms use SvelteKit’s remote functions instead of form actions and Superforms. Fewer files, less state, and the schema does the validation on both sides. It is the quickest way to see what a remote-function codebase looks like at the scale of a whole resource.
Prerequisites
A vela project with a backend (npx vela create my-app, or npx vela bless in an existing project). Routes land under (app) because auth is enabled in this project.
Run the command
✔ Created collection todos ✔ Created 11 files, modified 1 src/lib/schemas/todo.ts src/routes/(app)/todos/+page.svelte src/routes/(app)/todos/+page.server.ts src/routes/(app)/todos/new/+page.svelte src/routes/(app)/todos/new/form.remote.ts src/routes/(app)/todos/[id]/+page.svelte src/routes/(app)/todos/[id]/+page.server.ts src/routes/(app)/todos/[id]/edit/+page.svelte src/routes/(app)/todos/[id]/edit/+page.server.ts src/routes/(app)/todos/[id]/edit/form.remote.ts src/routes/(app)/todos/server.test.ts svelte.config.js
svelte.config.js gains the experimental.remoteFunctions and compilerOptions.experimental.async flags if they were not already set.
What was generated
Create
The create route has no +page.server.ts. Its handler is a form() in form.remote.ts: the schema validates, getRequestEvent() supplies locals, and the function redirects to the new record.
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { todoSchema } from "$lib/schemas/todo";
export const createTodoForm = form(todoSchema, async (data) => {
const { locals } = getRequestEvent();
const todo = await locals.pb.collection("todos").create(data);
redirect(303, `/todos/${todo.id}`);
});
The page spreads the function onto the form and each field’s as() onto its input:
<script lang="ts">
import { createTodoForm } from "./form.remote";
import { Button } from "$lib/components/ui/button";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
import { Input } from "$lib/components/ui/input";
</script>
<section data-role="content">
<div class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold tracking-tight">New todo</h1>
<Button href="/todos" variant="outline" size="sm">
<ArrowLeftIcon class="w-4 h-4" />
Back to list
</Button>
</div>
<div class="bg-card rounded-lg shadow-sm border p-4">
<form {...createTodoForm}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2 col-span-1">
<label for="title" class="text-sm font-medium">Title</label>
<Input
id="title"
{...createTodoForm.fields.title.as("text")}
required
/>
{#each createTodoForm.fields.title.issues() as issue}
<p class="text-destructive text-sm">{issue.message}</p>
{/each}
</div>
<div class="col-span-1 flex items-start space-x-2">
<input id="done" {...createTodoForm.fields.done.as("checkbox")} />
<label for="done" class="text-sm font-medium">Done</label>
{#each createTodoForm.fields.done.issues() as issue}
<p class="text-destructive text-sm">{issue.message}</p>
{/each}
</div>
</div>
<div class="mt-4 flex gap-2 border-t pt-4 -mx-4 px-4">
<Button type="submit" size="sm">Save</Button>
<Button href="/todos" variant="outline" size="sm">Cancel</Button>
</div>
</form>
</div>
</section>
Edit
Edit keeps a small +page.server.ts to load the record into the form’s initial values, and its remote function strips the read-only id and collectionId before calling update:
import { form, getRequestEvent } from "$app/server";
import { error, redirect } from "@sveltejs/kit";
import { todoSchema } from "$lib/schemas/todo";
export const updateTodoForm = form(todoSchema, async (data) => {
const { locals } = getRequestEvent();
const { id, collectionId, ...rest } = data;
if (!id) error(400, "id is required");
await locals.pb.collection("todos").update(id, rest);
redirect(303, `/todos/${id}`);
});
Everything else
The list page, the detail page with its delete action, the schema and the collection are identical to the classic scaffold, because they never needed a form action in the first place.
import { z } from "zod";
import type { Schemas } from "@velastack/pocketbase";
export const todoSchema = z.object({
id: z.string().optional(),
collectionId: z.string().optional(),
title: z.string().nonempty(),
done: z.boolean().default(false).optional(),
}) satisfies Schemas["todos"];
Try it
Open localhost:5173/todos and walk through create, edit and delete. Validation issues appear under the inputs without a page reload.
Make it yours
Anything you would have put in a form action goes in the remote function. To stamp the current user on every todo, read the session from the request event:
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { todoSchema } from "$lib/schemas/todo";
export const createTodoForm = form(todoSchema, async (data) => {
const { locals } = getRequestEvent();
const todo = await locals.pb.collection("todos").create({
...data,
owner: locals.pb.authStore.record?.id,
});
redirect(303, `/todos/${todo.id}`);
});(Add the field first with vela generate migration todos references owner:user, or declare it up front as owner:current_user, which generates this for you.)
Tests
The generated test posts to the remote functions the same way the classic test posts to actions, so both scaffolds are covered the same way.
Going further
- Form actions vs remote functions if you are deciding which to standardise on
- A single remote-function form when you do not need a whole resource
- Undo with
vela destroy scaffold todos - Pattern page: CRUD scaffold with remote functions · Docs


