Logo VelaStack

Generate a scaffold (remote functions)

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.

$ vela generate scaffold --remote
src/lib/schemas/contact.ts
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"];
src/routes/(public)/contacts/+page.server.ts
export const load = async ({ locals }) => {
  const contacts = await locals.admin.collection("contacts").getFullList();
  return { contacts };
};
src/routes/(public)/contacts/+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["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>
src/routes/(public)/contacts/new/form.remote.ts
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}`);
});
src/routes/(public)/contacts/new/+page.svelte
<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>
src/routes/(public)/contacts/[id]/+page.server.ts
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");
  },
};
src/routes/(public)/contacts/[id]/+page.svelte
<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>
src/routes/(public)/contacts/[id]/edit/+page.server.ts
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 };
};
src/routes/(public)/contacts/[id]/edit/form.remote.ts
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}`);
});
src/routes/(public)/contacts/[id]/edit/+page.svelte
<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>
src/routes/(public)/contacts/server.test.ts
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);
    });
  });
});