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 crudscaffolddata-tablesuperformspocketbasegeneratorssveltekit

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). The routes below land under (app) because auth is enabled; without it they land under (public).

Run the command

$ 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: one Zod schema, checked against the synced collection type.

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

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
export const load = async ({ locals }) => {
  const todos = await locals.pb.collection("todos").getFullList();
  return { todos };
};

The page renders those rows with a TanStack 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
<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
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
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
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
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

$ vela dev

Sign in, open 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:

$ 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
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
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)
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

$ npm run test:server

Each scaffold ships its own server.test.ts, so the suite grows with the app. See server tests in SvelteKit for what the harness does.

Going further

Related tutorials

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

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

Build a validated form in SvelteKit with Zod and Superforms in one command

A SvelteKit form with server-side validation means a schema, a form action, a page wired to Superforms, and a test. vela generate form writes all four from a single field list, so you start from working code instead of boilerplate.

4 min read