Full CRUD in one command: routes, server loads, forms, schemas, and database collection — all wired to shadcn-svelte table and form components. Emits index, new, and [id] routes that use sveltekit-superforms with Zod for create and update flows. Fields support the same DSL used elsewhere — required text, enum selects, one-to-many relations, and auth-bound current_user shortcuts. Nested paths like users/pets generate nested routes and collections alongside a server test per page.
import { z } from "zod";
import type { Schemas } from "@velastack/pocketbase";
export const contactSchema = z.object({
id: z.string().optional(),
collectionId: z.string().optional(),
name: z.string().optional(),
email: z.email().optional(),
}) satisfies Schemas["contacts"];
export const load = async ({ locals }) => {
const contacts = await locals.admin.collection("contacts").getFullList();
return { contacts };
};
<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["contacts"]>();
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("name", {
header: ({ column }) =>
renderComponent(ColumnHeader, { column, title: "Name" }),
cell: ({ getValue }) =>
renderComponent(Cells.TextCell, { value: getValue() }),
}),
columnHelper.accessor("email", {
header: ({ column }) =>
renderComponent(ColumnHeader, { column, title: "Email" }),
cell: ({ getValue }) =>
renderComponent(Cells.EmailCell, { value: getValue() }),
}),
columnHelper.display({
id: "actions",
cell: ({ row }) =>
renderComponent(RowActions, {
viewPath: `/contacts/${row.original.id}`,
editPath: `/contacts/${row.original.id}/edit`,
deletePath: `/contacts/${row.original.id}`,
}),
meta: { class: "w-0 text-right" },
}),
];
const table = createSvelteTable({
get data() {
return data.contacts;
},
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">Contacts</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 contacts..."
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="/contacts/new" variant="outline" size="sm">
<PlusIcon class="w-4 h-4" />
New contact
</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>
import { fail, redirect } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setPocketbaseErrors } from "@velastack/pocketbase";
import { contactSchema } from "$lib/schemas/contact";
export const load = async ({ locals }) => {
return { form: await superValidate(zod4(contactSchema)) };
};
export const actions = {
default: async ({ locals, request }) => {
const form = await superValidate(request, zod4(contactSchema));
if (!form.valid) {
return fail(400, { form });
}
let contact;
try {
contact = await locals.admin.collection("contacts").create(form.data);
} catch (error) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
return redirect(303, `/contacts/${contact.id}`);
},
};
<script lang="ts">
import { untrack } from "svelte";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { contactSchema } from "$lib/schemas/contact";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(contactSchema),
},
);
const { form: formData } = form;
</script>
<section data-role="content">
<div class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold tracking-tight">New contact</h1>
<Button href="/contacts" 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 method="POST">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Form.Field {form} name="name" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} type="text" bind:value={$formData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Form.Field {form} name="email" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} type="email" bind:value={$formData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
<div class="mt-4 flex gap-2 border-t pt-4 -mx-4 px-4">
<Form.Button size="sm">Save</Form.Button>
<Button href="/contacts" variant="outline" size="sm">Cancel</Button>
</div>
</form>
</div>
</section>
import { error, redirect } from "@sveltejs/kit";
export const load = async ({ locals, params }) => {
try {
const contact = await locals.admin.collection("contacts").getOne(params.id);
return { contact };
} catch {
throw error(404, "Not found");
}
};
export const actions = {
default: async ({ locals, params }) => {
await locals.admin.collection("contacts").delete(params.id);
throw redirect(303, "/contacts");
},
};
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
let { data } = $props();
</script>
<section data-role="content">
<div class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold tracking-tight">Contact details</h1>
<Button href="/contacts" 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">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<h3 class="font-medium leading-none">Name</h3>
<p class="text-muted-foreground">{data.contact.name}</p>
</div>
<div class="space-y-2">
<h3 class="font-medium leading-none">Email</h3>
<p class="text-muted-foreground">{data.contact.email}</p>
</div>
</div>
<div class="mt-4 flex gap-2 border-t pt-4 -mx-4 px-4 justify-between">
<Button
href="/contacts/{data.contact.id}/edit"
variant="outline"
size="sm"
>
Edit
</Button>
<form method="POST">
<Button type="submit" variant="destructive" size="sm">Delete</Button>
</form>
</div>
</div>
</section>
import { error, fail, redirect } from "@sveltejs/kit";
import { setPocketbaseErrors, setDefaultData } from "@velastack/pocketbase";
import { superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { contactSchema } from "$lib/schemas/contact";
export const load = async ({ locals, params }) => {
let contact;
try {
contact = await locals.admin.collection("contacts").getOne(params.id);
} catch {
throw error(404, "Not found");
}
return { form: await superValidate(contact, zod4(contactSchema)) };
};
export const actions = {
default: async ({ locals, params, request }) => {
const contact = await locals.admin.collection("contacts").getOne(params.id);
const form = await superValidate(request, zod4(contactSchema));
if (!form.valid) {
setDefaultData(form, contact);
return fail(400, { form });
}
try {
await locals.admin.collection("contacts").update(params.id, form.data);
return redirect(303, `/contacts/${params.id}`);
} catch (error) {
setPocketbaseErrors(form, error);
setDefaultData(form, contact);
return fail(400, { form });
}
},
};
<script lang="ts">
import { untrack } from "svelte";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { contactSchema } from "$lib/schemas/contact";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
let { data, params } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(contactSchema),
},
);
const { form: formData } = form;
</script>
<section data-role="content">
<div class="flex justify-between items-center mb-4">
<h1 class="text-3xl font-bold tracking-tight">Edit contact</h1>
<Button href="/contacts" 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 method="POST">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Form.Field {form} name="name" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} type="text" bind:value={$formData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Form.Field {form} name="email" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} type="email" bind:value={$formData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
<div class="mt-4 flex gap-2 border-t pt-4 -mx-4 px-4">
<Form.Button size="sm">Save changes</Form.Button>
<Button href="/contacts/${params.id}" variant="outline" size="sm"
>Cancel</Button
>
</div>
</form>
</div>
</section>
import { afterEach, beforeEach, describe, expect, it } from "vitest";
describe("contacts", () => {
describe("GET /contacts", () => {
it("should return a 200 status code", async (context) => {
const response = await context.request.get("/contacts");
expect(response.status).toBe(200);
});
});
describe("GET /contacts/new", () => {
it("should return a 200 status code", async (context) => {
const response = await context.request.get("/contacts/new");
expect(response.status).toBe(200);
});
});
describe("POST /contacts/new", () => {
it("should create a new contact", async (context) => {
const response = await context.request
.post("/contacts/new")
.type("form")
.send({ name: "name value", email: "test-email@example.com" });
expect(response.body.status).toBe(303);
const response2 = await context.request.get(response.body.location);
expect(response2.status).toBe(200);
});
});
describe("GET /contacts/[id]", () => {
it("should return a 200 status code", async (context) => {
const { id } = await context.admin
.collection("contacts")
.create({ name: "name value", email: "test-email@example.com" });
const response = await context.request.get(`/contacts/${id}`);
expect(response.status).toBe(200);
});
it("should return a 404 status code", async (context) => {
const response = await context.request.get("/contacts/non-existent");
expect(response.status).toBe(404);
});
});
describe("POST /contacts/[id]", () => {
it("should delete a contact", async (context) => {
const { id } = await context.admin
.collection("contacts")
.create({ name: "name value", email: "test-email@example.com" });
const response = await context.request
.post(`/contacts/${id}`)
.type("form");
expect(response.body.status).toBe(303);
const response2 = await context.request.get(`/contacts/${id}`);
expect(response2.status).toBe(404);
});
});
describe("GET /contacts/[id]/edit", () => {
it("should return a 200 status code", async (context) => {
const { id } = await context.admin
.collection("contacts")
.create({ name: "name value", email: "test-email@example.com" });
const response = await context.request.get(`/contacts/${id}/edit`);
expect(response.status).toBe(200);
});
});
describe("POST /contacts/[id]/edit", () => {
it("should update a contact", async (context) => {
const { id } = await context.admin
.collection("contacts")
.create({ name: "name value", email: "test-email@example.com" });
const response = await context.request
.post(`/contacts/${id}/edit`)
.type("form")
.send({ name: "name value", email: "test-email@example.com" });
expect(response.body.status).toBe(303);
expect(response.body.location).toBe(`/contacts/${id}`);
});
});
});
contacts