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.pb.collection("contacts").getFullList();
return { contacts };
};
<script lang="ts">
import {
columnFilteringFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_text,
tableFeatures,
} from "@tanstack/table-core";
import {
createTable,
FlexRender,
renderComponent,
} from "#lib/components/ui/data-table/index.js";
import * as Table from "#lib/components/ui/table/index.js";
import { Checkbox } from "#lib/components/ui/checkbox/index.js";
import { ColumnHeader } from "#lib/components/ui/column-header/index.js";
import { Pagination } from "#lib/components/ui/pagination/index.js";
import { RowActions } from "#lib/components/ui/row-actions/index.js";
import * as Cells from "#lib/components/ui/cells/index.js";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import XIcon from "@lucide/svelte/icons/x";
import PlusIcon from "@lucide/svelte/icons/plus";
import type { Models } from "@velastack/pocketbase";
let { data } = $props();
const features = tableFeatures({
rowSelectionFeature,
rowSortingFeature,
columnFilteringFeature,
rowPaginationFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
filterFns: { includesString: filterFn_includesString },
});
const columnHelper = createColumnHelper<
typeof features,
Models["contacts"]
>();
const columns = columnHelper.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,
meta: { class: "w-0" },
}),
columnHelper.accessor("name", {
header: ({ column }) =>
renderComponent(ColumnHeader, { column, title: "Name" }),
cell: ({ getValue }) =>
renderComponent(Cells.TextCell, { value: getValue() }),
filterFn: "includesString",
}),
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 = createTable({
features,
columns,
get data() {
return data.contacts;
},
enableRowSelection: true,
});
</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);
}}
class="h-8 w-[150px] lg:w-[250px]"
/>
{#if table.atoms.columnFilters.get().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 {header} />
{/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.getAllCells() as cell (cell.id)}
<Table.Cell class={cell.column.columnDef.meta?.class}>
<FlexRender {cell} />
</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.js";
export const createContactForm = form(contactSchema, async (data) => {
const { locals } = getRequestEvent();
const contact = await locals.pb.collection("contacts").create(data);
redirect(303, `/contacts/${contact.id}`);
});
<script lang="ts">
import { createContactForm } from "./form.remote";
import { Button } from "#lib/components/ui/button/index.js";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
import { Input } from "#lib/components/ui/input/index.js";
</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("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.pb.collection("contacts").getOne(params.id);
return { contact };
} catch {
error(404, "Not found");
}
};
export const actions = {
default: async ({ locals, params }) => {
await locals.pb.collection("contacts").delete(params.id);
redirect(303, "/contacts");
},
};
<script lang="ts">
import { Button } from "#lib/components/ui/button/index.js";
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.pb.collection("contacts").getOne(params.id);
} catch {
error(404, "Not found");
}
return { contact };
};
import { form, getRequestEvent } from "$app/server";
import { error, redirect } from "@sveltejs/kit";
import { contactSchema } from "#lib/schemas/contact.js";
export const updateContactForm = form(contactSchema, async (data) => {
const { locals } = getRequestEvent();
const { id, collectionId, ...rest } = data;
if (!id) error(400, "id is required");
await locals.pb.collection("contacts").update(id, rest);
redirect(303, `/contacts/${id}`);
});
<script lang="ts">
import { updateContactForm } from "./form.remote";
import { Button } from "#lib/components/ui/button/index.js";
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
import { Input } from "#lib/components/ui/input/index.js";
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 {...updateContactForm.fields.id.as("hidden", 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("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 { beforeEach, describe, expect, it } from "vitest";
describe("contacts", () => {
beforeEach(async (context) => {
await context.agent.authenticateUser();
});
describe("GET /contacts", () => {
it("should return a 200 status code", async (context) => {
const response = await context.agent.get("/contacts");
expect(response.status).toBe(200);
});
});
describe("GET /contacts/new", () => {
it("should return a 200 status code", async (context) => {
const response = await context.agent.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.agent.get(`/contacts/${id}`);
expect(response.status).toBe(200);
});
it("should return a 404 status code", async (context) => {
const response = await context.agent.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.agent.post(`/contacts/${id}`).type("form");
expect(response.body.status).toBe(303);
const response2 = await context.agent.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.agent.get(`/contacts/${id}/edit`);
expect(response.status).toBe(200);
});
});
});
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import adapter from "@sveltejs/adapter-node";
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit({
compilerOptions: {
runes: ({ filename }) =>
filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
experimental: {
async: true,
},
},
adapter: adapter(),
// The public origin, which SvelteKit checks form posts against and uses
// for prerendered pages. `vela deploy` sets VELA_ORIGIN, and the value is
// baked in at build time. It is left unset for a deploy that serves more
// than one host, where each request's own origin is used instead.
...(process.env.VELA_ORIGIN
? { paths: { origin: process.env.VELA_ORIGIN } }
: {}),
experimental: {
remoteFunctions: true,
},
}),
],
});