npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

runeforge

v0.0.56

Published

SvelteKit toolkit for building metadata-driven CRUD interfaces with tables, forms, and actions

Downloads

4,470

Readme

Runeforge

A SvelteKit toolkit that forges forms, tables, actions, and CRUD workflows from reusable definitions.


Table of Contents


Introduction

Runeforge provides a set of composable, metadata-driven components for building data-heavy interfaces in SvelteKit. It handles the repetitive parts of CRUD UIs — listing records, creating and editing forms, sorting and filtering tables — through a declarative API built on top of DaisyUI and Tailwind CSS.


Requirements

  • SvelteKit 2+
  • Svelte 5 (runes mode)
  • Tailwind CSS 4
  • DaisyUI 5
  • Cally
  • sortablejs (optional, only if you enable drag-to-reorder)
  • xlsx (optional, only if you enable Excel export)

Key Features

  • GenericCRUD — a single orchestrator component that wires together list, create, read, and update views from field and column definitions.
  • PaginatedTable — a full-featured table with sorting, filtering, pagination, and row selection, usable either fully client-side or driven by a server-paginated backend.
  • Field system — declarative field definitions that drive both form rendering and display, supporting text, email, password, number, boolean, textarea, file, select, multiselect, tree, datetime, and embedded (sub-document list) types.
  • Validation — built-in required, min/max, integer, minLength/maxLength, and pattern rules, checked client-side before submit with consistent, translatable error messages.
  • Conditional fields & field grouping — disable, or entirely hide, a field based on the current values of others in the same form, and visually group related fields under a titled fieldset.
  • Smart select fields — options can be static, computed from page data, dependent on another field's value, or resolved live from the server as the user types. multiselect supports the same resolvers for a checkbox-style multiple-choice list.
  • Tree fields — a hierarchical, cascading-selection picker (e.g. categories with parent/child relationships) driven by a flat option list with a parentValue link.
  • Embedded fields — model one-to-many sub-documents (e.g. line items, adjustments) as an in-form add/edit list backed by a single JSON field.
  • Custom row & bulk actions — add entity-specific actions (in a panel or via redirect) alongside the built-in view/edit/delete, and bulk actions that operate on the current selection.
  • CSV/XLSX export — one-click export of the current table view, with optional Excel support via the xlsx package.
  • Drag-to-reorder — an opt-in row-reordering layer that persists a sequential order attribute after each drag.
  • Server-side pagination, sorting & filtering — point GenericCRUD/PaginatedTable at a paginated envelope and it drives page/sort/filter state through the URL for you.
  • Pluggable icon system — swap the default icon set or use the included Bootstrap Icons alternative via setIconSet.
  • Standalone components — table, form, and navigation components can be used independently without the full CRUD orchestrator.

Installation

pnpm add runeforge

Theming

Tailwind source scanning

Runeforge components use Tailwind CSS utility classes internally. Tailwind v4 does not scan node_modules by default, so add an @source directive in your project's app.css to ensure every utility class is generated:

@import "tailwindcss";
@source "../node_modules/runeforge/dist";
@plugin "daisyui";

CSS variables

Key visual properties are exposed as CSS custom properties so you can tune them per project. Set them on :root (or any narrower selector) in your app.css:

:root {
  --runeforge-crud-title-size: 1.875rem;
  --runeforge-breadcrumb-font-size: 0.875rem;
  --runeforge-breadcrumb-icon-size: 1rem;
}

Responsive overrides work too:

:root {
  --runeforge-crud-title-size: 1.25rem;
}
@media (min-width: 768px) {
  :root {
    --runeforge-crud-title-size: 1.875rem;
  }
}

| Variable | Default | Affects | | --- | --- | --- | | --runeforge-crud-max-width | (none) | Max width of the Header and List view; centers them when set | | --runeforge-form-max-width | 32rem | Max width of the form/fields panel in Create, Update, and Read views | | --runeforge-crud-title-size | 1.875rem | <h1> inside the Header component | | --runeforge-breadcrumb-font-size | 0.875rem | Breadcrumb label text size | | --runeforge-breadcrumb-icon-size | 1rem | Breadcrumb icon width and height | | --runeforge-tree-max-height | 24rem | Max height of a tree field before it scrolls internally |

Modal sizing (see Shared Components) is set per-instance via props rather than a CSS variable.


Configuration

Global settings are applied once in your root layout via setConfig. This avoids passing the same prop to every CRUD component.

<!-- +layout.svelte -->
<script>
  import { setConfig } from 'runeforge';

  setConfig({ homeHref: '/admin' });
</script>

| Option | Default | Description | | --- | --- | --- | | homeHref | '/' | URL for the home crumb in every breadcrumb trail |


Basic Usage

1. Define your interface and metadata

// interface.ts
import { AttributeType, type InterfaceMetadata } from 'runeforge';
import { formatBoolean, formatDatetime } from 'runeforge';

export interface IArticle {
  _id: string;
  title: string;
  published: boolean;
  createdAt: Date;
}

export const articleMeta = {
  title: {
    label: 'Title',
    type: AttributeType.text,
    placeholder: 'My article',
    required: true,
  },
  published: {
    label: 'Published',
    type: AttributeType.boolean,
    formatter: formatBoolean,
    default: false,
    required: true,
  },
  createdAt: {
    label: 'Created',
    type: AttributeType.datetime,
    formatter: formatDatetime(),
    excludedFromCreate: true,
    excludedFromUpdate: true
  },
  updatedAt: {
    label: 'Updated',
    type: AttributeType.datetime,
    formatter: formatDatetime(),
    excludedFromCreate: true,
    excludedFromUpdate: true
  },
} satisfies InterfaceMetadata<IArticle>;

Each metadata entry drives both the table column and the form field for that attribute. You can use excludedFromList, excludedFromCreate, excludedFromRead, or excludedFromUpdate to hide a field from specific views. The Field System section below covers the full set of options — validation, conditional/grouped fields, smart selects, and embedded sub-documents.

2. Create the model

// model.ts
import crypto from 'node:crypto';
import mongoose from 'mongoose';
import type { IArticle } from './interface';

const schema = new mongoose.Schema<IArticle>(
  {
    _id: { type: String, default: () => crypto.randomUUID() },
    title: { type: String, required: true, trim: true },
    published: { type: Boolean, required: true, default: false },
  },
  { timestamps: true }
);

export const Article = mongoose.models.Article ?? mongoose.model<IArticle>('Article', schema);

3. Set up the server

// +page.server.ts
import { fail, error } from '@sveltejs/kit';
import { Article } from '$lib/server/articles/model';
import type { Actions, PageServerLoad } from './$types';
import type { IArticle } from './interface';

export const load: PageServerLoad = async ({ url }) => {
  const id = url.searchParams.get('id');
  if (id) {
    const article = await Article.findById(id).lean<IArticle>();
    if (!article) error(404, 'Not found');
    return { article };
  }
  const articles = await Article.find({}).sort({ createdAt: -1 }).lean<IArticle[]>();
  return { articles };
};

export const actions: Actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    const title = String(data.get('title') ?? '').trim();
    if (!title) return fail(400, { error: 'Title is required' });
    await Article.create({ title, published: data.has('published') });
    return { success: true };
  },

  update: async ({ request }) => {
    const data = await request.formData();
    const id = String(data.get('id') ?? '').trim();
    if (!id) return fail(400, { error: 'ID is required' });
    await Article.findByIdAndUpdate(id, {
      title: String(data.get('title') ?? '').trim(),
      published: data.has('published'),
    });
    return { success: true };
  },

  delete: async ({ request }) => {
    const data = await request.formData();
    const id = String(data.get('id') ?? '').trim();
    if (!id) return fail(400, { error: 'ID is required' });
    await Article.findByIdAndDelete(id);
    return { success: true };
  },
};

The load function returns a single record when ?id= is present (used by the read/edit views), or the full list otherwise.

4. Add the page component

<!-- +page.svelte -->
<script lang="ts">
  import { GenericCRUD } from 'runeforge';
  import { articleMeta as meta } from './interface';

  let { data, form } = $props();
</script>

<GenericCRUD
  labelOne="Article"
  labelMany="Articles"
  {data}
  {form}
  {meta}
  dataKey="articles"
  creation={{ endpoint: '?/create' }}
  read={{ endpoint: '?/read' }}
  update={{ endpoint: '?/update' }}
  deletion={{ endpoint: '?/delete' }}
/>

dataKey must match the key returned by the load function for the list. Each endpoint maps to a SvelteKit form action on the same page.

If your records use a different identifier field than _id (e.g. a plain id), pass the idKey prop:

<GenericCRUD idKey="id" ... />

This propagates to navigation URLs, form submissions, deletion calls, and the auto-excluded column list, so no other changes are needed on your end.


Field System

Every entry in an InterfaceMetadata<T> object is an AttributeMetadata — a superset of what drives the table column, the form input, and its validation. This section documents every option beyond the basics shown above.

Attribute reference

| Option | Type | Applies to | Description | | --- | --- | --- | --- | | label | string | all | Column header, form label, and the field name used in validation messages | | type | AttributeType | all | text | email | password | number | boolean | textarea | file | select | multiselect | tree | datetime | embedded | | required | boolean \| (record) => boolean | all | Marks the label and enforces a non-empty value on submit. The function form re-evaluates against the other fields' current values — see Validation | | autocomplete | FullAutoFill | text-like | Native autocomplete attribute | | placeholder | string | text-like, select, multiselect | Placeholder text | | default | value \| (data) => value | all | Initial value on the create form — see Default values | | min / max | number | number | Numeric range validation | | integer | boolean | number | Rejects non-whole numbers | | minLength / maxLength | number | text-like | Character-count validation | | pattern | string | text-like | Regex the value must match (new RegExp(pattern)) | | disabled | (record) => boolean | all | Conditionally disables the input — see Conditional fields | | hidden | boolean \| (record) => boolean | all | Conditionally removes the field from the form entirely — not rendered, not validated, not submitted — see Conditional fields | | groupedAs | string | all | Visually groups fields under a titled section — see Field grouping | | row | string | all | Renders fields sharing the same value side by side (desktop) / stacked (mobile) — see Field rows | | defaultExpanded | boolean | tree | Whether parent nodes start expanded. Defaults to true | | options | SelectOption[] \| (data) => SelectOption[] | select, multiselect, tree | Static or computed option list — see Select options. tree options additionally accept parentValue — see Tree fields | | dependentOptions | (data, record) => SelectOption[] | select, multiselect, tree | Options derived from other fields' current values | | search | (query) => Promise<SelectOption[]> | select, multiselect | Server-side option search as the user types | | seed | (instance) => unknown | all | Overrides how the update form seeds this field from the loaded record | | fields | InterfaceMetadata<any> | embedded | Sub-field schema for each item — see Embedded fields | | itemLabel | (item) => string | embedded | Summary label for an item in the embedded list | | component | CellComponent | all | Custom cell renderer — see Custom Cell Components | | formatter | (data) => (value, row) => string | all | Custom cell text — see Formatters | | excludedFromList/Create/Read/Update | boolean | all | Hides the field from that specific view | | sortable / filterable | boolean | all | Table column controls | | filterOptions | SelectOption[] | all | Static, exhaustive column filter choices, replacing the sampled-from-loaded-rows checkbox list — see Server-side pagination, sorting & filtering |

Validation

required, min/max, integer, minLength/maxLength, and pattern are checked client-side on submit, before the request hits your form action. Every failure is surfaced through the same field-level error UI (and the same translatable strings) regardless of which rule failed, so your server-side checks and Runeforge's checks look identical to the user.

code: {
  label: 'Code',
  type: AttributeType.text,
  required: true,
  pattern: '^[A-Z0-9]{3,8}$',
},
quantity: {
  label: 'Quantity',
  type: AttributeType.number,
  min: 1,
  max: 100,
  integer: true,
},
notes: {
  label: 'Notes',
  type: AttributeType.textarea,
  minLength: 3,
  maxLength: 200,
},

required also accepts a function of the other fields' current values, for when whether a field is mandatory depends on the rest of the form rather than being fixed:

formula: {
  label: 'Formula',
  type: AttributeType.select,
  options: [
    { value: 'benchmark', label: 'Benchmark' },
    { value: 'max', label: 'Max' },
  ],
},
quantity: {
  label: 'Quantity',
  type: AttributeType.number,
  // Not required for the "benchmark" formula, mandatory for every other one.
  required: (record) => record.formula !== 'benchmark',
},

The label's required marker and the submit-time check both re-evaluate the same way disabled does — see Conditional fields.

[!TIP] Client-side validation is a UX nicety, not a security boundary — always re-validate in your form actions.

Conditional fields

disabled receives the form's current draft record (including in-progress edits to sibling fields) and returns whether the input should be disabled. It re-evaluates as the user types. required (see Validation) follows the same pattern for making a field mandatory only in certain conditions.

unlimited: {
  label: 'Unlimited quantity',
  type: AttributeType.boolean,
  default: false,
},
quantity: {
  label: 'Quantity',
  type: AttributeType.number,
  min: 1,
  disabled: (record) => !!record.unlimited,
},

hidden follows the exact same boolean | (record) => boolean shape, but goes a step further than disabled: a hidden field isn't just greyed out, it's removed from the form entirely — not rendered, not required-checked, not submitted. Use it when a field only makes sense for certain values of another field, rather than merely being non-editable:

paymentMethod: {
  label: 'Payment method',
  type: AttributeType.select,
  options: [
    { value: 'card', label: 'Credit card' },
    { value: 'cash', label: 'Cash on delivery' },
  ],
},
cardNumber: {
  label: 'Card number',
  type: AttributeType.text,
  hidden: (record) => record.paymentMethod !== 'card',
  required: (record) => record.paymentMethod === 'card',
},
cardExpiry: {
  label: 'Expiry date',
  type: AttributeType.text,
  hidden: (record) => record.paymentMethod !== 'card',
  required: (record) => record.paymentMethod === 'card',
},

Switching paymentMethod between card and cash swaps which fields are present, live, in the same create/edit view — no separate step or modal needed to collect the payment-specific details.

Field grouping

Fields sharing the same groupedAs string render together inside a titled fieldset, at the position of the group's first field. Fields without groupedAs keep the original flat layout.

code: {
  label: 'Code',
  type: AttributeType.text,
  groupedAs: 'Identification',
},
sku: {
  label: 'SKU',
  type: AttributeType.text,
  groupedAs: 'Identification',
},

Field rows

Fields sharing the same row string render side by side on desktop and stacked on mobile, instead of each taking a full line. It's meant for small, related fields — a date range, a min/max pair — where a flat vertical stack wastes space.

createdFrom: {
  label: 'Created from',
  type: AttributeType.datetime,
  row: 'createdRange',
},
createdTo: {
  label: 'Created to',
  type: AttributeType.datetime,
  row: 'createdRange',
},

A row only merges fields that are also in the same groupedAs fieldset (or both ungrouped) — it never pulls fields together across two different fieldsets. If one of the fields in a row is conditionally hidden, the remaining field(s) simply expand to fill the row instead of leaving a gap.

groupedAs and row both work the same way inside an embedded field's fields sub-schema — the "+ Add"/edit modal groups and lays out its own fields identically to a top-level form.

Default values

default can be a plain value or a function of the page data object, evaluated once when the create form's fields are resolved — handy for defaulting a select to something derived from prefetched data.

published: {
  label: 'Published',
  type: AttributeType.boolean,
  default: false,
},
assignedTo: {
  label: 'Assigned to',
  type: AttributeType.select,
  options: (data: { users?: IUser[] }) => (data.users ?? []).map((u) => ({ value: u._id, label: u.name })),
  default: (data: { currentUserId?: string }) => data.currentUserId ?? '',
},

Select options

select fields support four ways of resolving their options, which can be combined as needed:

  • Static — a plain SelectOption[] array.
  • Computed from page data — a function of the page data object, useful for prefetched, related records (see formatInstance in Formatters for rendering the resolved link back).
  • DependentdependentOptions(data, record) recomputes the option list from the current draft record, so one field's choices can depend on another's value. If the currently selected value is no longer in the recomputed list, it's cleared automatically.
  • Server searchsearch(query) is called (debounced) as the user types, instead of filtering the (possibly partial) options list in memory. Combine it with options to keep a usable list before the user starts typing.
// Dependent options: narrow "city" choices by the selected "country"
country: {
  label: 'Country',
  type: AttributeType.select,
  options: [{ value: 'ar', label: 'Argentina' }, { value: 'uy', label: 'Uruguay' }],
},
city: {
  label: 'City',
  type: AttributeType.select,
  dependentOptions: (data, record) => CITIES_BY_COUNTRY[record.country as string] ?? [],
},

// Server-aware search: fall back to a prefetched slice, but query the
// server for anything outside it.
owner: {
  label: 'Owner',
  type: AttributeType.select,
  placeholder: 'Choose an owner',
  options: (data: { owners?: IOwner[] }) => (data.owners ?? []).map((o) => ({ value: o.id, label: o.name })),
  search: async (query) => {
    const fd = new FormData();
    fd.set('query', query);
    const res = await fetch('?/searchOwners', { method: 'POST', body: fd });
    const result = deserialize(await res.text());
    if (result.type !== 'success') return [];
    return (result.data.owners ?? []).map((o: IOwner) => ({ value: o.id, label: o.name }));
  },
},
// +page.server.ts
export const actions: Actions = {
  // ...create/update/delete
  searchOwners: async ({ request }) => {
    const data = await request.formData();
    const query = String(data.get('query') ?? '');
    return { owners: await Owner.find({ name: { $regex: query, $options: 'i' } }).limit(20).lean() };
  },
};

Multiselect fields

AttributeType.multiselect is a checkbox-style multiple-choice dropdown — the same options/dependentOptions/default/search resolvers as select (see Select options), but the stored value is a string[] instead of a single string. Picking an option toggles it without closing the dropdown, and the closed-state button summarizes the count ("2 selected").

tags: {
  label: 'Tags',
  type: AttributeType.multiselect,
  options: [
    { value: 'fragile', label: 'Fragile' },
    { value: 'perishable', label: 'Perishable' },
    { value: 'oversized', label: 'Oversized' },
  ],
  default: [],
},

Like embedded, the value is submitted as a single hidden field holding a JSON array — parse it back out the same way:

const tags = JSON.parse(String(data.get('tags') ?? '[]'));

If the field also sets dependentOptions, selections that fall outside the recomputed list are pruned automatically (rather than clearing the whole field, as a single select does) — e.g. narrowing a provinces multiselect to only the options valid for the currently selected country.

Tree fields

AttributeType.tree is a hierarchical picker — checkboxes in a collapsible tree, where checking a parent node cascades the selection to all of its descendants. It's driven by the same flat SelectOption[] as select/multiselect, plus an optional parentValue linking each option to its parent's value (omit or set null for a root node):

categories: {
  label: 'Categories',
  type: AttributeType.tree,
  options: (data: { categories?: ICategory[] }) =>
    (data.categories ?? []).map((c) => ({
      value: String(c.id),
      label: c.name,
      parentValue: c.parentCategory != null ? String(c.parentCategory) : null,
    })),
},

The stored value is a string[] of selected node values, submitted the same way as multiselect — a single hidden field holding a JSON array, parsed back out server-side with JSON.parse. dependentOptions and hidden work the same as any other field type.

By default every parent node renders expanded; set defaultExpanded: false to start with the whole tree collapsed instead (the user can still expand any branch — this only sets the initial state). The field itself is capped at --runeforge-tree-max-height (default 24rem, see CSS variables) and scrolls internally once its content grows past that.

Embedded fields (sub-documents)

AttributeType.embedded models a one-to-many list of sub-records — line items, adjustments, contacts, anything you'd otherwise store as an array of objects — entirely within one form field. It renders as a list with an "+ Add" button; each item is added/edited through a modal built from the fields sub-schema, and removed with a single click. The whole list is serialized to JSON and submitted as a single hidden form field.

export interface IAdjustment {
  kind: string;
  amount: number;
}

export interface IWidget {
  _id: string;
  name: string;
  adjustments: IAdjustment[];
}

export const widgetMeta = {
  name: { label: 'Name', type: AttributeType.text, required: true },
  adjustments: {
    label: 'Adjustments',
    type: AttributeType.embedded,
    // Arrays of objects have no sensible plain-text table cell.
    excludedFromList: true,
    fields: {
      kind: {
        label: 'Kind',
        type: AttributeType.select,
        required: true,
        options: [
          { value: 'bonus', label: 'Bonus' },
          { value: 'penalty', label: 'Penalty' },
        ],
      },
      amount: { label: 'Amount', type: AttributeType.number, required: true, min: 0 },
    },
    itemLabel: (item) => `${item.kind === 'bonus' ? 'Bonus' : 'Penalty'}: ${item.amount}`,
  },
} satisfies InterfaceMetadata<IWidget>;

On the server, parse the field back out of FormData as JSON:

function widgetFromFormData(data: FormData) {
  let adjustments: IAdjustment[];
  try {
    adjustments = JSON.parse(String(data.get('adjustments') ?? '[]'));
  } catch {
    adjustments = [];
  }
  return { name: String(data.get('name') ?? '').trim(), adjustments };
}

Sub-fields support the same validation rules as top-level fields (required, min/max, pattern, etc.), checked when an item is added or edited in the modal. itemLabel controls how each item summarizes itself in the list; without it, Runeforge joins the resolved display value of every sub-field with ·.


Components

GenericCRUD

The main CRUD orchestrator. It manages navigation between List, Create, Read, and Update views using URL search params (?view=create, ?id=xxx, ?view=edit).

Key props:

  • data / dataKey — the record array (or server-paginated envelope) and its primary key field
  • labelOne / labelMany — singular and plural names for the entity
  • columnsColumnDefinition[] for the table view
  • fieldsFieldDefinition[] for form views
  • creation, update, read, deletionActionConfiguration objects that define handlers and permissions for each operation. Set confirm: true on deletion to show a confirmation dialog before any delete (single row or batch)
  • actions — a ListActions object grouping the list view's extra actions:
    • actions.customCustomAction[], extra per-row actions — see Custom row actions
    • actions.bulkCustomBulkAction[], extra actions on the current selection — see Custom bulk actions
  • config — a ListConfig object grouping the list view's opt-in behaviors:
    • config.searchSearchConfiguration, shows a free-text search box — see Free-text search
    • config.exportExportConfiguration, enables CSV/Excel export — see Exporting data
    • config.reorderReorderConfiguration, enables drag-to-reorder rows — see Reordering rows

Free-text search

Passing config.search renders a debounced search box in the header. Typing updates a URL search param (?search=... by default), resets pagination and any open create/read/edit view, and leaves interpreting the term entirely to your load function — it's the same mechanism server-side pagination uses, so it composes naturally with it.

<GenericCRUD
  ...
  config={{ search: { param: 'q', placeholder: 'Search tasks...', debounceMs: 300 } }}
/>

| Option | Default | Description | | --- | --- | --- | | param | 'search' | Query-string parameter name | | placeholder | strings.searchPlaceholder | Input placeholder | | debounceMs | 300 | Delay before the URL updates |

Custom row actions

actions.custom adds entries to the per-row action menu, alongside the built-in view/edit/delete. Each CustomAction resolves in one of two ways — provide exactly one of view or href:

  • href(item) — plain navigation, e.g. deep-linking into another CRUD's filtered list.
  • view — a Svelte component of your own that GenericCRUD mounts directly (no wrapper) when the action runs. Since you own the whole component, you decide how it presents itself — typically as a modal built on the exported Modal component, sized however that action needs via Modal's class/width/maxWidth/height/maxHeight props (see Shared Components).
import ArchiveIcon from './icons/Archive.svelte';
import ArchiveForm from './ArchiveForm.svelte';

const actions: CustomAction<IWidget>[] = [
  {
    label: 'Archive',
    icon: ArchiveIcon,
    endpoint: '?/archive',
    view: ArchiveForm,
    condition: (item) => !item.archived,
  },
  {
    label: 'Open in new tab',
    icon: ExternalLinkIcon,
    href: (item) => `/widgets/${item._id}`,
  },
];
<GenericCRUD ... actions={{ custom: actions }} />

A view component receives instance, label, endpoint, serverError, onCancel, and onSuccess — the same shape Create/Update use internally — so it can reuse enhance-based form submission while rendering as a parametrized modal:

<!-- ArchiveForm.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import { Modal } from 'runeforge';

  let { instance, label, endpoint, serverError, onCancel, onSuccess } = $props();
</script>

<Modal title={label} onClose={onCancel} maxWidth="28rem">
  <form
    method="POST"
    action={endpoint}
    use:enhance={() => async ({ result, update }) => {
      await update({ reset: false });
      if (result.type === 'success') onSuccess();
    }}
  >
    <input type="hidden" name="id" value={instance._id} />
    {#if serverError}<p class="text-error">{serverError}</p>{/if}
    <div class="flex justify-end gap-2 mt-4">
      <button type="button" onclick={onCancel}>Cancel</button>
      <button type="submit">{label}</button>
    </div>
  </form>
</Modal>

Custom bulk actions

actions.bulk adds buttons next to the built-in Delete button in the header, operating on the current row selection. Each one is disabled until at least one row is selected, and (like deletion) can require confirmation.

<GenericCRUD
  ...
  actions={{
    bulk: [
      { kind: 'endpoint', label: 'Complete', icon: CheckIcon, endpoint: '?/complete' },
      { kind: 'endpoint', label: 'Mark pending', icon: UndoIcon, endpoint: '?/incomplete', variant: 'error', confirm: true },
    ],
  }}
/>

endpoint is called once per selected row (POST with an id field), then the list is refreshed. variant matches DaisyUI's btn-* modifiers ('primary', 'error', 'ghost', ...). condition(selectedItems) can hide the action entirely based on the current selection.

Exporting data (CSV/XLSX)

config.export adds an export button to the header offering CSV (always) and Excel (when a xlsx module is supplied). Its mere presence enables the button — pass {} for CSV-only export. Runeforge never bundles xlsx itself — install it separately and pass the resolved module in, so the dependency stays fully optional:

pnpm add xlsx
<script>
  import { GenericCRUD } from 'runeforge';
  import * as xlsx from 'xlsx';
</script>

<GenericCRUD ... config={{ export: { xlsx } }} />

In client-pagination mode, export includes every row currently matching the table's filters (not just the visible page). In server-pagination mode, pass config.export.callback to fetch the full, unpaginated result set for the current query — without it, export falls back to just the currently loaded page:

<GenericCRUD
  ...
  config={{
    export: {
      callback: async (query) => {
        const params = new URLSearchParams();
        if (query.ordering) params.set('ordering', query.ordering);
        // ...translate query.filters into your API's params
        const res = await fetch(`/api/widgets/export?${params}`);
        return res.json();
      },
    },
  }}
/>

Reordering rows

config.reorder turns on drag-to-reorder: each row gets a drag handle (⋮⋮ by default — pass icon for something else, e.g. a hamburger or a grab-hand icon) with a thicker left border, and dragging a row persists a new sequential value for whichever attribute you point it at. Off by default; only appears once config.reorder is set.

Runeforge never imports sortablejs itself — install it separately and pass the resolved default export in via sortable, the same way xlsx works for export, so the dependency stays fully optional:

pnpm add sortablejs
<script>
  import { GenericCRUD } from 'runeforge';
  import Sortable from 'sortablejs';
</script>

<GenericCRUD
  ...
  config={{
    reorder: { attribute: 'order', sortable: Sortable, endpoint: '?/reorder' },
  }}
/>
// +page.server.ts
export const actions: Actions = {
  reorder: async ({ request }) => {
    const data = await request.formData();
    const id = String(data.get('id') ?? '');
    const order = Number(data.get('order') ?? 0);
    await setWidgetOrder(id, order);
    return { success: true };
  },
};

endpoint is POSTed once per row whose attribute value actually changed (FormData: id plus the attribute, e.g. order) after a drag, then the list refreshes — the same convention deletion/bulk actions use. Pass callback instead to handle the changed rows yourself (each one already carries its new attribute value):

config={{
  reorder: {
    attribute: 'order',
    sortable: Sortable,
    callback: async (items) => {
      await Promise.all(items.map((item) => api.updateWidget(item._id, { order: item.order })));
    },
  },
}}

A few things worth knowing about how reorder mode behaves:

  • The list is ordered by attribute ascending (or compare, see below) the whole time reorder is active — not by _id, not by whatever order the backend/array happens to return, and not by clicking a column header either: column-header sorting is unavailable while config.reorder is set, since row order needs a single, unambiguous source of truth for dragging to mean anything. This holds even if attribute is excludedFromList and has no column of its own — ordering reads the raw row value regardless of what's rendered. Per-column filters are unavailable for the same reason (a filtered-out row's position would become undefined).
  • Pagination stays on. To move a row across a page boundary, drag it to the narrow zone at either edge of the table and hold — after pageFlipThresholdMs (default 2000) it flips to the previous/next page; keep hovering (without letting go) to flip again. Drop once you're on the right page.
  • It's not supported in server-pagination mode (a PaginatedEnvelope data) — cross-page drag positions aren't meaningful without a lot more server-side machinery, so config.reorder is ignored whenever server pagination is active.
  • Set enabled: false to keep the configuration in place (attribute, endpoint, icon) but temporarily turn dragging off, without having to remove the whole object.

Composite orders. Sometimes the attribute that stores a row's position isn't unique on its own — e.g. an indicator's order only makes sense within its parent chapter, so two indicators in different chapters can share the same order value, and the true display order is really (chapter's order, indicator's own order). Pass compare to take full control of that ordering instead of the plain attribute-ascending default:

config={{
  reorder: {
    attribute: 'order',
    sortable: Sortable,
    compare: (a, b) => a.chapter.order - b.chapter.order || a.order - b.order,
    endpoint: '?/reorder',
  },
}}

attribute is still what gets written back after a drag (as a plain sequential 0-based index across the whole reordered list) — compare only decides how rows are displayed and dragged. If a naive global renumbering doesn't fit your data model (as in the chapter example — you don't want every indicator in every chapter renumbered whenever one indicator moves within its own chapter), use callback instead of endpoint and remap the incoming rows' indices to whatever scoped scheme your backend actually expects before saving.

Multi-select drag. Set multiDrag: true to let dragging one row of the current checkbox selection carry the whole selection along with it, via SortableJS's MultiDrag plugin — mount it on the module you pass in:

import Sortable, { MultiDrag } from 'sortablejs';
Sortable.mount(new MultiDrag());
config={{
  reorder: { attribute: 'order', sortable: Sortable, multiDrag: true, endpoint: '?/reorder' },
}}

The existing row-selection checkboxes are the multi-drag selection — there's no separate ctrl/cmd-click UI to learn. Note that multiDrag uses native HTML5 drag-and-drop rather than the mouse-simulated dragging the rest of reorder mode uses (SortableJS's MultiDrag plugin needs it to track a multi-row drag correctly) — runeforge switches automatically, but it's worth knowing if you're scripting drags for tests.

Server-side pagination, sorting & filtering

By default, GenericCRUD and PaginatedTable paginate, sort, and filter the full data array in the browser. For large datasets, return a PaginatedEnvelope<T> from your load function instead — { results, count, page, pageSize } — and Runeforge switches to server mode automatically: it drives page, ordering, and per-column filter values through the URL, and expects your load function to read them back.

// +page.server.ts
export const load: PageServerLoad = ({ url }) => {
  const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
  const ordering = url.searchParams.get('ordering');
  const name = url.searchParams.get('name'); // per-column text filter

  let rows = [...allWidgets];
  if (name) rows = rows.filter((w) => w.name.toLowerCase().includes(name.toLowerCase()));
  if (ordering) {
    const desc = ordering.startsWith('-');
    const field = desc ? ordering.slice(1) : ordering;
    rows = [...rows].sort((a, b) => (desc ? -1 : 1) * compare(a[field], b[field]));
  }

  const pageSize = 20;
  const start = (page - 1) * pageSize;
  return { widgets: { results: rows.slice(start, start + pageSize), count: rows.length, page, pageSize } };
};
<GenericCRUD
  ...
  data={{ widgets: data.widgets }}
  dataKey="widgets"
/>

No other prop changes are needed — column sorting/filtering UI, the paginator, and (with config.export.callback) export all keep working the same way, just backed by the server instead of the in-memory array. Boolean-column filters send comma-separated values (?active=true,false); date-range filters send <attribute>_from/<attribute>_to.

A text column's filter checkbox list is populated from values seen on the currently loaded page — a cosmetic hint in server mode, not an exhaustive list, since the full set of values lives server-side. For a column whose possible values are a known, bounded set (an enum-like field, a small lookup table), give it filterOptions instead so every choice always shows up, regardless of what the current page contains:

status: {
  label: 'Status',
  type: AttributeType.text,
  filterable: true,
  filterOptions: [
    { value: 'DRAFT', label: 'Draft' },
    { value: 'PUBLISHED', label: 'Published' },
    { value: 'ARCHIVED', label: 'Archived' },
  ],
},

value is matched against the column's rendered cell text and sent server-side as-is (same as any other checkbox filter value); label is only what's displayed, falling back to value.

PaginatedTable

A standalone table component with built-in sort, filter, and pagination — the same engine GenericCRUD uses internally.

<script>
  import { PaginatedTable } from 'runeforge';
</script>

<PaginatedTable {data} {columns} />

Sort and filter state can be managed externally via the exported SortState and FilterState classes. Pass a pagination prop (ServerPagination) plus onPaginationChange to opt into the same server-driven mode GenericCRUD uses. bind:visibleRows and bind:query expose the currently filtered/sorted rows and query snapshot, useful for building your own export UI on top of the raw table.

Pass a reorder prop ({ attribute, sortable, compare?, icon?, multiDrag?, pageFlipThresholdMs? } — same shape as GenericCRUD's config.reorder, minus endpoint/callback) plus onReorder to get the same drag-to-reorder behavior without the CRUD-level persistence wiring — onReorder fires with the complete reordered row list after each drag, and it's on you to decide what to do with it. Ignored whenever pagination is also set.

Form Components

Individual form primitives styled with DaisyUI:

  • Button — styled action button
  • Label — form label with optional required marker
  • Select — dropdown with option group support, optional in-memory filtering, and an optional search prop for server-resolved options (see Select options)
  • PasswordInput — password field with show/hide toggle; labelClass, inputClass, and buttonClass props let you restyle the wrapper, input, and toggle button independently

Shared Components

  • Avatar — user avatar display
  • Modal — DaisyUI modal wrapper. Size it with Tailwind utility classes via class (e.g. class="max-w-4xl"), or with explicit width/maxWidth/height/maxHeight CSS lengths, which are applied as inline styles and take priority over class
  • Breadcrumbs — navigation breadcrumb trail
  • IconRenderer — renders icons from the active icon set

Formatters

Formatters are functions you attach to a metadata field to control how its value is displayed in the table and read view. They follow a curried signature: (data) => (value) => string, where data is the full page data object (useful for resolving related records).

formatBoolean

Converts a boolean to a readable label.

[!NOTE] Defaults to / No because this was created at Argentina papá! 🇦🇷.

import { formatBoolean } from 'runeforge';

isActive: {
  label: 'Active',
  type: AttributeType.boolean,
  formatter: formatBoolean(),
  // or with custom labels:
  formatter: formatBoolean('Enabled', 'Disabled'),
},

formatDatetime

Formats a Date value using the tokens dd, mm, YYYY, HH, MM, ss.

[!NOTE] Defaults to 'dd/mm/YYYY HH:MM'.

import { formatDatetime } from 'runeforge';

createdAt: {
  label: 'Created',
  type: AttributeType.datetime,
  formatter: formatDatetime(),             // → "13/06/2026 09:45"
},

publishedAt: {
  label: 'Published',
  type: AttributeType.datetime,
  formatter: formatDatetime('dd/mm/YYYY'), // → "13/06/2026"
},

formatTruncateTextUpTo

Truncates long text to a maximum character count, appending .

import { formatTruncateTextUpTo } from 'runeforge';

description: {
  label: 'Description',
  type: AttributeType.textarea,
  formatter: formatTruncateTextUpTo(80),
},

formatInstance

Resolves a foreign-key ID to a linked label. Receives the related records and the URL path for the detail view, and renders an anchor tag pointing to that record.

import { formatInstance } from 'runeforge';
import type { ICategory } from '$lib/server/categories/interface';

categoryId: {
  label: 'Category',
  type: AttributeType.select,
  options: (data: { categories?: ICategory[] }) =>
    (data.categories ?? []).map((c) => ({ value: c._id, label: c.name })),
  formatter: (data: { categories?: ICategory[] }) =>
    formatInstance<ICategory>('name', data.categories ?? [], '/admin/categories'),
},

Custom Cell Components

Instead of a formatter, a metadata field can declare a component — a Svelte component that renders the cell in both the table list and the read view. This is useful when you need to render something visual, like an avatar image or an icon, rather than plain text.

A cell component receives two props defined by CellProps<T, V>:

  • value — the raw field value for that cell
  • row — the full record object, useful when the rendering depends on other fields
// CellProps interface (from runeforge)
interface CellProps<T extends object, V> {
  value: V;
  row: T;
}

Example: avatar column

The following renders a user photo with a fallback to initials, using data from sibling fields on the row:

<!-- components/UserAvatar.svelte -->
<script lang="ts">
  import { Avatar } from 'runeforge';
  import type { CellProps } from 'runeforge';

  type UserRow = { firstName?: string; lastName?: string; email?: string };

  let { value, row }: CellProps<UserRow, string | null> = $props();

  const initials = [row.firstName?.[0], row.lastName?.[0]].filter(Boolean).join('').toUpperCase();
</script>

<Avatar src={value} text={initials} alt={row.email ?? ''} />

Register it in the metadata with component:

// interface.ts
import UserAvatar from './components/UserAvatar.svelte';

export const userMeta = {
  photo: {
    label: 'Photo',
    type: AttributeType.file,
    component: UserAvatar,
    sortable: false,
    filterable: false,
  },
  // ...
} satisfies InterfaceMetadata<IUser>;

Example: icon column

A simpler case — render a Bootstrap icon by name stored as a plain string:

<!-- components/IconCell.svelte -->
<script lang="ts">
  import { IconRenderer } from 'runeforge';
  import type { CellProps } from 'runeforge';

  let { value }: CellProps<Record<string, unknown>, string> = $props();
</script>

<IconRenderer name={value} />
icon: {
  label: 'Icon',
  type: AttributeType.text,
  component: IconCell,
},

[!TIP] Both AvatarCell and IconCell are included in the package and ready to use — you don't need to build them from scratch:

import { AvatarCell, IconCell } from 'runeforge';

photo: { label: 'Photo', type: AttributeType.file, component: AvatarCell },
icon:  { label: 'Icon',  type: AttributeType.text, component: IconCell  },

Internationalization

All UI strings default to Spanish (Argentina). To switch to another language, call setStrings in your root layout with a full or partial RuneforgeStrings object. Values you omit fall back to the Spanish defaults.

Switch to English

<!-- +layout.svelte -->
<script>
  import { setStrings, en } from 'runeforge';

  setStrings(en);
</script>

Override individual strings

<script>
  import { setStrings } from 'runeforge';

  setStrings({
    create: 'New',
    save: 'Confirm',
    required: (field) => `${field} cannot be blank`,
  });
</script>

Full RuneforgeStrings reference

| Key | Type | Spanish default | | --- | --- | --- | | showing | (start, end, total) => string | Mostrando 1–10 de 25 | | actions | string | Acciones | | filter | string | Filtrar | | filterColumn | (column) => string | Filtrar Nombre | | filterPlaceholder | string | Filtrar… | | clearFilter | string | Limpiar filtro | | emptyValue | string | (vacío) | | previous | string | Anterior | | next | string | Siguiente | | selectPlaceholder | string | Seleccioná una opción | | selectSearch | string | Buscar... | | selectSearching | string | Buscando... | | selectNoResults | string | Sin resultados | | view | string | Ver | | edit | string | Editar | | delete | string | Eliminar | | create | string | Crear | | searchPlaceholder | string | Buscar... | | reorder | string | Arrastrar para reordenar | | export | string | Exportar | | exportCsv | string | Exportar a CSV | | exportExcel | string | Exportar a Excel | | save | string | Guardar | | saveAndContinue | string | Guardar y continuar | | cancel | string | Cancelar | | back | string | Volver | | add | string | Agregar | | remove | string | Quitar | | noItems | string | Sin elementos agregados | | confirm | string | Confirmar | | deleteConfirm | (count, actionLabel) => string | ¿Seguro que querés eliminar 3 elementos? | | required | (field) => string | Título es requerido | | invalidNumber | (field) => string | Cantidad debe ser un número | | integer | (field) => string | Cantidad debe ser un número entero | | min | (field, min) => string | Cantidad debe ser mayor o igual a 1 | | max | (field, max) => string | Cantidad debe ser menor o igual a 100 | | minLength | (field, min) => string | Notas debe tener al menos 3 caracteres | | maxLength | (field, max) => string | Notas debe tener como máximo 200 caracteres | | pattern | (field) => string | Código tiene un formato inválido | | serverError | string | Error inesperado del servidor. |

[!NOTE] Defaults to Spanish because this was built in Argentina! 🇦🇷

Bundled locales

| Import | Language | | --- | --- | | es | Spanish 🇦🇷 (default) | | en | English 🇺🇸 |


Icon System

Runeforge ships with a default icon set. To use Bootstrap Icons instead:

<script>
  import { setIconSet, bootstrapIcons } from 'runeforge';

  setIconSet(bootstrapIcons);
</script>

You can also provide a fully custom icon set by passing an object that satisfies the icon set interface.


Running Tests

Unit Tests

Unit tests cover utility functions (formatters, resolution helpers, misc utilities) and run with Vitest.

# Single run
pnpm test:unit

# Watch mode
pnpm test:unit:watch

End-to-End Tests

E2E tests cover table interactions (pagination, sorting, filtering) and run with Playwright. The dev server starts automatically when running locally.

pnpm test:e2e

Run All Tests

pnpm test

Development

# Start the dev server
pnpm dev

# Type-check
pnpm check

# Lint and format
pnpm lint
pnpm format

# Build the library
pnpm build

License

MIT