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

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

Every app has a few of these: a list of things, a form to add one, a page to look at one, a form to change it, a button to delete it. In SvelteKit that is an index route with a data table, a `new` route and an `[id]/edit` route with Superforms, an `[id]` route with a delete action, a Zod schema, a database collection and a migration. Then tests. `vela generate scaffold` produces the whole set from one line, in the same shape every time, so a model you add in month six looks like the one you added on day one.

## Prerequisites

A vela project with a backend: `npx vela create my-app`, or `npx vela bless` inside an existing SvelteKit project ([details](https://docs.velastack.dev/bless)). The routes below land under `(app)` because [auth](https://velastack.dev/tutorials/sveltekit-login-and-signup) is enabled; without it they land under `(public)`.

## Run the command

```sh
$ vela generate scaffold todos title:text! done:bool
✔ Created collection todos
✔ Created 10 files
  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/+page.server.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/server.test.ts
```

The `todos` collection is created in PocketBase with a migration, types are synced, and the shadcn-svelte components the pages use (`data-table`, `form`, `checkbox`, `pagination` and friends) are installed if missing.

## What was generated

### The schema

Same idea as [the resource generator](https://velastack.dev/tutorials/sveltekit-database-model-from-cli): one Zod schema, checked against the synced collection type.

**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"];
```

### The list

The index `load` is one line, because access control lives in the collection rules rather than in every query:

**src/routes/(app)/todos/+page.server.ts**

```ts
export const load = async ({ locals }) => {
  const todos = await locals.pb.collection("todos").getFullList();
  return { todos };
};
```

The page renders those rows with a [TanStack Table](https://tanstack.com/table) wired to shadcn-svelte components: row selection, sortable column headers, a cell renderer per field type, pagination, and a row-actions menu with view, edit and delete. The column definitions are the part you will edit most:

**src/routes/(app)/todos/+page.svelte**

```svelte
<script lang="ts">
  import {
    type ColumnFiltersState,
    type PaginationState,
    type RowSelectionState,
    type SortingState,
    type VisibilityState,
    getCoreRowModel,
    getFacetedRowModel,
    getFacetedUniqueValues,
    getFilteredRowModel,
    getPaginationRowModel,
    getSortedRowModel,
    createColumnHelper,
  } from "@tanstack/table-core";
  import {
    createSvelteTable,
    FlexRender,
    renderComponent,
  } from "$lib/components/ui/data-table";
  import * as Table from "$lib/components/ui/table";
  import { Checkbox } from "$lib/components/ui/checkbox";
  import { ColumnHeader } from "$lib/components/ui/column-header";
  import { Pagination } from "$lib/components/ui/pagination";
  import { RowActions } from "$lib/components/ui/row-actions";
  import * as Cells from "$lib/components/ui/cells";
  import { Button } from "$lib/components/ui/button";
  import { Input } from "$lib/components/ui/input";

  import XIcon from "@lucide/svelte/icons/x";
  import PlusIcon from "@lucide/svelte/icons/plus";
  import type { Models } from "@velastack/pocketbase";

  let { data } = $props();
  let rowSelection = $state<RowSelectionState>({});
  let columnVisibility = $state<VisibilityState>({});
  let columnFilters = $state<ColumnFiltersState>([]);
  let sorting = $state<SortingState>([]);
  let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 10 });

  const columnHelper = createColumnHelper<Models["todos"]>();
  const columns = [
    columnHelper.display({
      id: "select",
      header: ({ table }) =>
        renderComponent(Checkbox, {
          checked: table.getIsAllPageRowsSelected(),
          onCheckedChange: (value) => table.toggleAllPageRowsSelected(value),
          indeterminate:
            table.getIsSomePageRowsSelected() &&
            !table.getIsAllPageRowsSelected(),
          "aria-label": "Select all",
        }),
      cell: ({ row }) =>
        renderComponent(Checkbox, {
          checked: row.getIsSelected(),
          onCheckedChange: (value) => row.toggleSelected(value),
          "aria-label": "Select row",
        }),
      enableSorting: false,
      enableHiding: false,
      meta: { class: "w-0" },
    }),
    columnHelper.accessor("title", {
      header: ({ column }) =>
        renderComponent(ColumnHeader, { column, title: "Title" }),
      cell: ({ getValue }) =>
        renderComponent(Cells.TextCell, { value: getValue() }),
    }),
    columnHelper.accessor("done", {
      header: ({ column }) =>
        renderComponent(ColumnHeader, { column, title: "Done" }),
      cell: ({ getValue }) =>
        renderComponent(Cells.BoolCell, { value: getValue() }),
    }),
    columnHelper.display({
      id: "actions",
      cell: ({ row }) =>
        renderComponent(RowActions, {
          viewPath: `/todos/${row.original.id}`,
          editPath: `/todos/${row.original.id}/edit`,
          deletePath: `/todos/${row.original.id}`,
        }),
      meta: { class: "w-0 text-right" },
    }),
  ];

  const table = createSvelteTable({
    get data() {
      return data.todos;
    },
    state: {
      get sorting() {
        return sorting;
      },
      get columnVisibility() {
        return columnVisibility;
      },
      get rowSelection() {
        return rowSelection;
      },
      get columnFilters() {
        return columnFilters;
      },
      get pagination() {
        return pagination;
      },
    },
    columns,
    enableRowSelection: true,
    onRowSelectionChange: (updater) => {
      rowSelection =
        typeof updater === "function" ? updater(rowSelection) : updater;
    },
    onSortingChange: (updater) => {
      sorting = typeof updater === "function" ? updater(sorting) : updater;
    },
    onColumnFiltersChange: (updater) => {
      columnFilters =
        typeof updater === "function" ? updater(columnFilters) : updater;
    },
    onColumnVisibilityChange: (updater) => {
      columnVisibility =
        typeof updater === "function" ? updater(columnVisibility) : updater;
    },
    onPaginationChange: (updater) => {
      pagination =
        typeof updater === "function" ? updater(pagination) : updater;
    },
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: getFacetedUniqueValues(),
  });
</script>

<section data-role="content">
  <div class="flex justify-between items-center mb-4">
    <h1 class="text-3xl font-bold tracking-tight">Todos</h1>
  </div>

  <div class="space-y-4">
    <div class="flex items-center justify-between">
      <div class="flex flex-1 items-center space-x-2">
        <Input
          placeholder="Filter todos..."
          value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
          oninput={(e) => {
            table.getColumn("name")?.setFilterValue(e.currentTarget.value);
          }}
          onchange={(e) => {
            table.getColumn("name")?.setFilterValue(e.currentTarget.value);
          }}
          class="h-8 w-[150px] lg:w-[250px]"
        />

        {#if table.getState().columnFilters.length > 0}
          <Button
            variant="ghost"
            onclick={() => table.resetColumnFilters()}
            class="h-8 px-2 lg:px-3"
          >
            Reset
            <XIcon />
          </Button>
        {/if}
      </div>

      <Button href="/todos/new" variant="outline" size="sm">
        <PlusIcon class="w-4 h-4" />
        New todo
      </Button>
    </div>

    <div class="rounded-md border overflow-hidden">
      <Table.Root>
        <Table.Header>
          {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
            <Table.Row>
              {#each headerGroup.headers as header (header.id)}
                <Table.Head
                  colspan={header.colSpan}
                  class={header.column.columnDef.meta?.class}
                >
                  {#if !header.isPlaceholder}
                    <FlexRender
                      content={header.column.columnDef.header}
                      context={header.getContext()}
                    />
                  {/if}
                </Table.Head>
              {/each}
            </Table.Row>
          {/each}
        </Table.Header>
        <Table.Body>
          {#each table.getRowModel().rows as row (row.id)}
            <Table.Row data-state={row.getIsSelected() && "selected"}>
              {#each row.getVisibleCells() as cell (cell.id)}
                <Table.Cell class={cell.column.columnDef.meta?.class}>
                  <FlexRender
                    content={cell.column.columnDef.cell}
                    context={cell.getContext()}
                  />
                </Table.Cell>
              {/each}
            </Table.Row>
          {:else}
            <Table.Row>
              <Table.Cell colspan={columns.length} class="h-24 text-center"
                >No results.</Table.Cell
              >
            </Table.Row>
          {/each}
        </Table.Body>
      </Table.Root>
    </div>

    <Pagination {table} />
  </div>
</section>
```

### Create and edit

Both forms use Superforms with the schema. The create action inserts the record and redirects to it; the interesting lines are the try/catch, which turns PocketBase validation errors (a unique constraint, say) into field errors on the form:

**src/routes/(app)/todos/new/+page.server.ts**

```ts
import { fail, redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setPocketbaseErrors } from "@velastack/pocketbase/form";
import { todoSchema } from "$lib/schemas/todo";

export const load = async ({ locals }) => {
  return { form: await superValidate(zod4(todoSchema)) };
};

export const actions = {
  default: async ({ locals, request }) => {
    const form = await superValidate(request, zod4(todoSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    let todo;

    try {
      todo = await locals.pb.collection("todos").create(form.data);
    } catch (error) {
      setPocketbaseErrors(form, error);
      return fail(400, { form });
    }

    return redirect(303, `/todos/${todo.id}`);
  },
};
```

The edit action does the same with `update`, and repopulates the form with the stored record when validation fails so the user never loses their place:

**src/routes/(app)/todos/[id]/edit/+page.server.ts**

```ts
import { error, fail, redirect } from "@sveltejs/kit";
import {
  setPocketbaseErrors,
  setDefaultData,
} from "@velastack/pocketbase/form";
import { superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { todoSchema } from "$lib/schemas/todo";

export const load = async ({ locals, params }) => {
  let todo;
  try {
    todo = await locals.pb.collection("todos").getOne(params.id);
  } catch {
    throw error(404, "Not found");
  }

  return { form: await superValidate(todo, zod4(todoSchema)) };
};

export const actions = {
  default: async ({ locals, params, request }) => {
    const todo = await locals.pb.collection("todos").getOne(params.id);
    const form = await superValidate(request, zod4(todoSchema));

    if (!form.valid) {
      setDefaultData(form, todo);
      return fail(400, { form });
    }

    try {
      await locals.pb.collection("todos").update(params.id, form.data);
    } catch (error) {
      setPocketbaseErrors(form, error);
      setDefaultData(form, todo);
      return fail(400, { form });
    }

    // Outside the try: redirect() throws, and the catch would swallow it.
    return redirect(303, `/todos/${params.id}`);
  },
};
```

### View and delete

The detail route loads one record (404 if it is missing) and its only action deletes it:

**src/routes/(app)/todos/[id]/+page.server.ts**

```ts
import { error, redirect } from "@sveltejs/kit";

export const load = async ({ locals, params }) => {
  try {
    const todo = await locals.pb.collection("todos").getOne(params.id);
    return { todo };
  } catch {
    throw error(404, "Not found");
  }
};

export const actions = {
  default: async ({ locals, params }) => {
    await locals.pb.collection("todos").delete(params.id);
    throw redirect(303, "/todos");
  },
};
```

### The tests

Eight tests cover every route and action: the pages render, a create redirects to the new record, an edit updates it, a delete makes it 404.

**src/routes/(app)/todos/server.test.ts**

```ts
import { beforeEach, describe, expect, it } from "vitest";

describe("todos", () => {
  beforeEach(async (context) => {
    await context.agent.authenticateUser();
  });

  describe("GET /todos", () => {
    it("should return a 200 status code", async (context) => {
      const response = await context.agent.get("/todos");
      expect(response.status).toBe(200);
    });
  });

  describe("GET /todos/new", () => {
    it("should return a 200 status code", async (context) => {
      const response = await context.agent.get("/todos/new");
      expect(response.status).toBe(200);
    });
  });

  describe("POST /todos/new", () => {
    it("should create a new todo", async (context) => {
      const response = await context.agent
        .post("/todos/new")
        .type("form")
        .send({ title: "title value", done: true });
      expect(response.body.status).toBe(303);

      const response2 = await context.agent.get(response.body.location);
      expect(response2.status).toBe(200);
    });
  });

  describe("GET /todos/[id]", () => {
    it("should return a 200 status code", async (context) => {
      const { id } = await context.admin
        .collection("todos")
        .create({ title: "title value", done: true });
      const response = await context.agent.get(`/todos/${id}`);
      expect(response.status).toBe(200);
    });

    it("should return a 404 status code", async (context) => {
      const response = await context.agent.get("/todos/non-existent");
      expect(response.status).toBe(404);
    });
  });

  describe("POST /todos/[id]", () => {
    it("should delete a todo", async (context) => {
      const { id } = await context.admin
        .collection("todos")
        .create({ title: "title value", done: true });
      const response = await context.agent.post(`/todos/${id}`).type("form");
      expect(response.body.status).toBe(303);

      const response2 = await context.agent.get(`/todos/${id}`);
      expect(response2.status).toBe(404);
    });
  });

  describe("GET /todos/[id]/edit", () => {
    it("should return a 200 status code", async (context) => {
      const { id } = await context.admin
        .collection("todos")
        .create({ title: "title value", done: true });
      const response = await context.agent.get(`/todos/${id}/edit`);
      expect(response.status).toBe(200);
    });
  });

  describe("POST /todos/[id]/edit", () => {
    it("should update a todo", async (context) => {
      const { id } = await context.admin
        .collection("todos")
        .create({ title: "title value", done: true });
      const response = await context.agent
        .post(`/todos/${id}/edit`)
        .type("form")
        .send({ title: "title value", done: true });
      expect(response.body.status).toBe(303);
      expect(response.body.location).toBe(`/todos/${id}`);
    });
  });
});
```

## Try it

```sh
$ vela dev
```

Sign in, open [localhost:5173/todos](http://localhost:5173/todos), add a few, sort by title, edit one, delete one.

## A second model: enums and ownership

Field types go well beyond text and booleans. This one has a select with labelled options and an `owner` bound to the signed-in user:

```sh
$ vela generate scaffold pets name:text! type:select(dog:Dog,cat:Cat) owner:current_user
```

The select becomes a Zod enum, and `owner` is a relation to `users`:

**src/lib/schemas/pet.ts**

```ts
import { z } from "zod";
import type { Schemas } from "@velastack/pocketbase";

export const petSchema = z.object({
  id: z.string().optional(),
  collectionId: z.string().optional(),
  name: z.string().nonempty(),
  type: z.enum(["dog", "cat"]).optional(),
  owner: z.string(),
}) satisfies Schemas["pets"];
```

`current_user` changes the generated create action: the field is left out of the form and filled in from the session, and the collection's rules only let users see their own pets.

**src/routes/(app)/pets/new/+page.server.ts**

```ts
import { fail, redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setPocketbaseErrors } from "@velastack/pocketbase/form";
import { petSchema } from "$lib/schemas/pet";

export const load = async ({ locals }) => {
  return { form: await superValidate(zod4(petSchema)) };
};

export const actions = {
  default: async ({ locals, request }) => {
    const form = await superValidate(request, zod4(petSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    let pet;

    try {
      pet = await locals.pb.collection("pets").create({
        ...form.data,
        owner: locals.pb.authStore.record?.id,
      });
    } catch (error) {
      setPocketbaseErrors(form, error);
      return fail(400, { form });
    }

    return redirect(303, `/pets/${pet.id}`);
  },
};
```

## Make it yours

The generated files are ordinary SvelteKit code. A common first tweak: sort the list by title out of the box. The state is already there; give it an initial value.

**src/routes/(app)/todos/+page.svelte (script)**

```ts
let rowSelection = $state<RowSelectionState>({});
let columnVisibility = $state<VisibilityState>({});
let columnFilters = $state<ColumnFiltersState>([]);
let sorting = $state<SortingState>([]);
sorting = [{ id: "title", desc: false }];
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 10 });
```

> **Tip:**
> Nested models generate nested routes and collections: `vela generate scaffold users/pets name:text!` puts pets under `/users/[user_id]/pets`. And `--route "(app)/[team_id]/projects"` places a scaffold inside any existing dynamic route, with the param threaded through every link and redirect.

## Tests

```sh
$ npm run test:server
```

Each scaffold ships its own `server.test.ts`, so the suite grows with the app. See [server tests in SvelteKit](https://docs.velastack.dev/test) for what the harness does.

## Going further

- [The field syntax](https://velastack.dev/tutorials/sveltekit-generator-field-syntax) in full: relations, files, dates, JSON, nested models
- [Add or rename fields later](https://velastack.dev/patterns/generate-migration) with `vela generate migration`
- [The same scaffold on remote functions](https://velastack.dev/tutorials/sveltekit-crud-remote-functions)
- Undo with `vela destroy scaffold todos`, which removes the routes and schema and drops the collection
- [Pattern page: CRUD scaffold](https://velastack.dev/patterns/generate-scaffold) · [Docs: vela generate scaffold](https://docs.velastack.dev/generate/scaffold)
