Generate full CRUD for a model using SvelteKit remote functions in place of form actions. You get index, new, and [id] routes, a PocketBase collection, Zod schemas, and form.remote.ts handlers for create and update — all tied together with shadcn-svelte table and form components. The same field DSL supports required fields, enum selects, and auth-bound relations, and nested paths generate nested routes. Also enables experimental.remoteFunctions and async compiler options in svelte.config.js.
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 { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { contactSchema } from "$lib/schemas/contact";
export const createContactForm = form(contactSchema, async (data) => {
const { locals } = getRequestEvent();
const contact = await locals.admin.collection("contacts").create(data);
redirect(303, `/contacts/${contact.id}`);
});
<script lang="ts">
import { createContactForm } 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 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 {...createContactForm}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2 col-span-1">
<label for="name" class="text-sm font-medium">Name</label>
<Input id="name" {...createContactForm.fields.name.as("text")} />
{#each createContactForm.fields.name.issues() as issue}
<p class="text-destructive text-sm">{issue.message}</p>
{/each}
</div>
<div class="space-y-2 col-span-1">
<label for="email" class="text-sm font-medium">Email</label>
<Input
id="email"
{...createContactForm.fields.email.as("text")}
type="email"
/>
{#each createContactForm.fields.email.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="/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 } from "@sveltejs/kit";
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 { contact };
};
import { form, getRequestEvent, error } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { contactSchema } from "$lib/schemas/contact";
export const updateContactForm = form(contactSchema, async (data) => {
const { locals } = getRequestEvent();
const { id, collectionId, ...rest } = data;
if (!id) error(400, "id is required");
await locals.admin.collection("contacts").update(id, rest);
redirect(303, `/contacts/${id}`);
});
<script lang="ts">
import { updateContactForm } 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";
let { data, params } = $props();
</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 {...updateContactForm}>
<input type="hidden" name="id" value={data.contact.id} />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2 col-span-1">
<label for="name" class="text-sm font-medium">Name</label>
<Input id="name" {...updateContactForm.fields.name.as("text")} />
{#each updateContactForm.fields.name.issues() as issue}
<p class="text-destructive text-sm">{issue.message}</p>
{/each}
</div>
<div class="space-y-2 col-span-1">
<label for="email" class="text-sm font-medium">Email</label>
<Input
id="email"
{...updateContactForm.fields.email.as("text")}
type="email"
/>
{#each updateContactForm.fields.email.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 changes</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("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);
});
});
});
import adapter from "@sveltejs/adapter-auto";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter,
experimental: {
remoteFunctions: true,
},
},
compilerOptions: {
experimental: {
async: true,
},
},
};
export default config;
contacts