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

Pattern: [Generate a scaffold (remote functions)](https://velastack.dev/patterns/generate-scaffold-remote) · Docs: https://docs.velastack.dev/generate/scaffold

If you have read [SvelteKit CRUD in one command](https://velastack.dev/tutorials/sveltekit-crud-scaffold), 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](https://svelte.dev/docs/kit/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](https://velastack.dev/tutorials/sveltekit-login-and-signup) is enabled in this project.

## Run the command

```sh
$ 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**

```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**

```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**

```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](https://velastack.dev/tutorials/sveltekit-crud-scaffold), because they never needed a form action in the first place.

**src/lib/schemas/todo.ts**

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

```sh
$ vela dev
```

Open [localhost:5173/todos](http://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**

```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](https://velastack.dev/patterns/generate-scaffold-remote) always shows what the current generator emits for the current SvelteKit, which is the fastest way to see whether an upgrade changed anything.

## Tests

```sh
$ 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

- [Form actions vs remote functions](https://velastack.dev/tutorials/sveltekit-form-actions-vs-remote-functions) if you are deciding which to standardise on
- [A single remote-function form](https://velastack.dev/tutorials/sveltekit-remote-functions-form) when you do not need a whole resource
- Undo with `vela destroy scaffold todos`
- [Pattern page: CRUD scaffold with remote functions](https://velastack.dev/patterns/generate-scaffold-remote) · [Docs](https://docs.velastack.dev/generate/scaffold)
