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
✔ 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.
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:
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:
<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:
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:
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:
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.
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
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:
The select becomes a Zod enum, and owner is a relation to users:
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.
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.
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 });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
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
- The field syntax in full: relations, files, dates, JSON, nested models
- Add or rename fields later with
vela generate migration - The same scaffold on remote functions
- Undo with
vela destroy scaffold todos, which removes the routes and schema and drops the collection - Pattern page: CRUD scaffold · Docs: vela generate scaffold


