Logo VelaStack

CRUD with SvelteKit remote functions - a scaffold without form actions

The same list, create, view and edit pages as the classic scaffold, but the forms post to typed remote functions instead of form actions. vela generate scaffold --remote writes the routes, the .remote.ts handlers, the schema, the collection and the tests.

3 min read crudscaffoldremote-functionspocketbasegeneratorssveltekit

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

$ vela generate scaffold --remote todos title:text! done:bool
✔ 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.

src/routes/(app)/todos/new/form.remote.ts
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:

src/routes/(app)/todos/new/+page.svelte
<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:

src/routes/(app)/todos/[id]/edit/form.remote.ts
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.

src/lib/schemas/todo.ts
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

$ vela dev

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:

src/routes/(app)/todos/new/form.remote.ts
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.)

Note
Remote functions are experimental in SvelteKit. The pattern page always shows what the current generator emits for the current SvelteKit, which is the fastest way to see whether an upgrade changed anything.

Tests

$ npm run test:server

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

Related tutorials

SvelteKit CRUD in one command - list, create, view and edit pages with a database table

A full CRUD interface in SvelteKit is ten files, a data table, two forms and a collection. vela generate scaffold writes all of it from a field list, tested, so you can spend the afternoon on the parts that are specific to your app.

4 min read

Define a database collection and schema for SvelteKit from the command line

A new model in a SvelteKit app means a database table, a migration, a Zod schema and TypeScript types that agree with each other. vela generate resource creates all of them from one field list, without opening an admin UI.

3 min read

SvelteKit remote functions - a validated form without form actions

Remote functions let a SvelteKit form post to a typed server function instead of a form action. vela generate form --remote scaffolds the form, the remote function, the Zod schema and the config flags so you can try the new model in a minute.

3 min read