kystrap
v0.0.12
Published
A SvelteKit component library that turns Zod schemas into fully-wired CRUD interfaces — forms, tables, modals, and MongoDB server actions — with a single schema definition.
Readme
Kystrap
A SvelteKit component library that turns Zod schemas into fully-wired CRUD interfaces — forms, tables, modals, and MongoDB server actions — with a single schema definition.
How it works
Everything in Kystrap flows from one Zod schema. You define your data shape once, attach configuration through .meta(), and Kystrap generates the UI components and server actions automatically.
const UserSchema = zod.object({
_id: zod.zObjectId(),
name: zod.string().min(1),
email: zod.string().email(),
role: zod.enum(['admin', 'user', 'guest'])
}).meta({
title: 'User',
database: { collection: 'users' }
});That single schema drives:
- A form with correct input types and validation
- A table with sortable, filterable columns
- A modal for inline add/edit
- Server actions for insert, update, and delete against MongoDB
Installation
pnpm add -D kystrap @iconify-json/tabler @iconify/tailwind4 tailwindcss mongodb daisyui bits-uiAdd to your root CSS file:
@plugin 'daisyui';
@plugin '@iconify/tailwind4' {
prefix: 'iconify';
prefixes: tabler;
scale: 1.5;
}
@import '../../node_modules/kystrap/dist/kystrap.css';
@source '../../node_modules/kystrap/dist/';For using ThemeToggler components, add the following to your root app.html file:
<script>
(function () {
try {
var theme = localStorage.getItem('theme');
if (theme === 'theme-dark' || theme === 'theme-light') {
document.documentElement.setAttribute('data-theme', theme);
}
} catch (e) {}
})();
</script>
%sveltekit.head%Toast notifications (optional but recommended)
Kystrap ships a Toaster component and an initCookieToast helper that display toast
notifications automatically. They integrate with InstantForm and other instants features
out of the box, and support multi-page flash messages across redirects.
1. Set up the server-side flash handler in your root +layout.server.ts:
import { loadFlash } from 'kystrap/server';
export const load = loadFlash(() => ({}));2. Render Toaster and initialize cookie toasts in your root +layout.svelte:
<script lang="ts">
import { onMount } from 'svelte';
import { Toaster, initCookieToast } from 'kystrap/functions';
const { children } = $props();
onMount(() => {
initCookieToast();
});
</script>
<Toaster richColors />
{@render children()}That's it. Success and error messages returned from instant form actions (or any SvelteKit form action using flash messages) will automatically appear as toast notifications without any additional wiring.
The .meta() system
.meta() is the configuration layer that tells Kystrap how to render and handle each field. It can be applied at the field level or the schema level.
Schema-level meta
Set on the zod.object() itself. Controls the entity name, database target, and description.
const ProductSchema = zod.object({ ... }).meta({
title: 'Product', // Used in success messages ("Product created!")
description: 'Store products', // Optional description
database: {
collection: 'products', // MongoDB collection name (required for server functions)
db: 'myapp' // Database name (optional, uses default if omitted)
}
});You can read these values back using the schema utility functions:
import { titleOf, descriptionOf, collectionOf, dbOf } from 'kystrap/schemas';
titleOf(ProductSchema) // 'Product'
collectionOf(ProductSchema) // 'products'
dbOf(ProductSchema) // 'myapp'Field-level meta
Applied to individual fields with .meta({}). Controls input type, form visibility, table columns, dropdown options, and more.
fieldType — input type override
By default Kystrap infers the input type from the Zod type. Use fieldType to override.
const schema = zod.object({
_id: zObjectId(),
email: zod.string().meta({ fieldType: 'email' }),
password: zod.string().meta({ fieldType: 'password' }),
bio: zod.string().meta({ fieldType: 'area' }),
tags: zod.array(zod.string()).meta({ fieldType: 'arrayText' }),
color: zod.string().meta({ fieldType: 'color' }),
phone: zod.string().meta({ fieldType: 'phone' }),
active: zod.boolean().meta({ fieldType: 'toggle' }),
birthday: zISOdate.meta({ fieldType: 'date' }),
createdAt: zISODate.meta({ fieldType: 'datetime' }),
avatar: zod.string().meta({ fieldType: 'file' }),
volume: zod.number().min(0).max(100).default(50).meta({ fieldType: 'slider', sliderProps: { valuePostfix: '%' } }),
});Available values: text, email, password, number, area, arrayText, color, phone, toggle, date, datetime, file, singleOption, multipleOption, parsedInput, slider
form — form visibility
const schema = zod.object({
_id: zod.zObjectId().meta({
form: { hidden: true } // Never show in any form
}),
createdAt: zISODate.meta({
form: {
hideOnAdd: true, // Hide when creating a new record
hideOnEdit: false // Show when editing an existing record
}
})
});datatable — column display
const schema = zod.object({
_id: zod.zObjectId().meta({
datatable: { hidden: true } // Hide from table
}),
name: zod.string().meta({
datatable: {
order: 1, // Column position (lower = leftmost)
label: 'Full Name' // Custom column header
}
}),
createdAt: zod.date().meta({
fieldType: 'date', // Render as date picker in form
order: 4,
dateProps: {
formatString: 'MMM dd, yyyy' // Date display format in datatable
}
})
});optionProps — dropdown and select options
Three ways to supply options to singleOption / multipleOption fields:
Static options — hardcoded list:
role: zod.enum(['admin', 'user', 'guest']).meta({
optionProps: {
staticOptions: [
{ value: 'admin', label: 'Administrator' },
{ value: 'user', label: 'Regular User' },
{ value: 'guest', label: 'Guest' }
]
}
})Remote options — fetched from an API endpoint:
country: zod.string().meta({
optionProps: {
remoteOptions: {
endpoint: '/api/countries',
method: 'GET',
headers: { 'Authorization': 'Bearer token' },
responseSchema: zod.array(zod.object({
code: zod.string(),
name: zod.string()
})).transform(opts => opts.map(o => ({ value: o.code, label: o.name })))
}
}
})Load options — from SvelteKit page.data:
category: zod.string().meta({
optionProps: {
loadOptions: {
key: 'categories', // Reads from page.data.categories
dataSchema: zod.array(zod.object({
_id: zod.string(),
name: zod.string()
})).transform(opts => opts.map(o => ({ value: o._id, label: o.name })))
},
allowAdd: true, // Let user add new options inline
addSchema: zod.object({ name: zod.string() })
}
})toggleProps — toggle labels
notifications: zod.boolean().meta({
toggleProps: {
yes: 'Enabled',
no: 'Disabled'
}
})Other field-level options
name: zod.string().meta({
order: 1, // Display order within the form
alwaysRequired: true, // Mark required even if schema has .optional()
readonly: true, // Render as read-only
isLabelKey: true // Use this field as the display label for the record
})Complete example
Here is a full CRUD setup for a products entity — schema, server actions, and UI in one place.
Schema (shared)
// src/lib/schemas/product.ts
import { zod } from 'kystrap/schemas';
export const ProductSchema = zod.object({
_id: zod.zObjectId().meta({
form: { hidden: true },
datatable: { hidden: true }
}),
name: zod.string().min(1).meta({
order: 1,
datatable: { order: 1, label: 'Product Name' }
}),
price: zod.number().min(0).meta({
order: 2,
datatable: { order: 2 }
}),
category: zod.string().meta({
order: 3,
fieldType: 'singleOption',
datatable: { order: 3 },
optionProps: {
staticOptions: [
{ value: 'electronics', label: 'Electronics' },
{ value: 'clothing', label: 'Clothing' },
{ value: 'food', label: 'Food' }
]
}
}),
rating: zod.number().min(0).max(5).default(0).meta({
order: 3,
fieldType: 'slider',
sliderProps: {
showTick: true,
tickLabelSkips: 1,
valuePostfix: ' stars'
}
}),
inStock: zod.boolean().default(true).meta({
order: 4,
fieldType: 'toggle',
toggleProps: { yes: 'In Stock', no: 'Out of Stock' },
datatable: { order: 4 }
}),
createdAt: zod.coerce.date().default(new Date()).meta({
fieldType: 'date',
form: { hideOnAdd: true },
dateProps: { formatString: 'MMM dd, yyyy' },
datatable: { order: 5 }
})
}).meta({
title: 'Product',
database: { collection: 'products' }
});Server actions
// src/routes/products/+page.server.ts
import { collectionInstantAddEditRemove, collectionFind } from 'kystrap/server/mongodb';
import { ProductSchema } from '$lib/schemas/product';
export const load = async () => {
const products = await collectionFind(ProductSchema);
return { products };
};
export const actions = {
...collectionInstantAddEditRemove(ProductSchema)
};Page component
<!-- src/routes/products/+page.svelte -->
<script lang="ts">
import { InstantTable, InstantWindowForm } from 'kystrap/instants';
import { ProductSchema } from '$lib/schemas/product';
let isOpen = $state(false);
let editData = $state<any>(null);
</script>
<button onclick={() => isOpen = true}>Add Product</button>
<InstantTable
schema={ProductSchema}
/>
<InstantWindowForm
schema={ProductSchema}
bind:open={isOpen}
data={editData}
/>This gives you: a sortable, filterable table; an Add button that opens a modal; Edit and Delete per row; and all server actions wired to MongoDB — with no additional code.
Instant components
Which component to use
| Situation | Component |
|-----------|-----------|
| Standalone form on its own page | InstantForm |
| Add record via a modal | InstantWindowForm + button |
| Table + add/edit/delete in a modal | InstantTable + InstantWindowForm |
| Read-only data display | InstantTable with no window form |
InstantForm
A standalone form rendered inline on the page. Good for dedicated add/edit pages.
<script lang="ts">
import { InstantForm } from 'kystrap/instants';
import { UserSchema } from '$lib/schemas/user';
</script>
<InstantForm schema={UserSchema} />
<!-- For editing: -->
<InstantForm schema={UserSchema} data={existingRecord} />Props: schema (required), data (optional, pre-fills for editing), action (optional, form action name)
InstantWindowForm
A modal dialog that wraps InstantForm. Typically driven by InstantTable, but can be opened manually.
<script lang="ts">
import { InstantWindowForm } from 'kystrap/instants';
import { UserSchema } from '$lib/schemas/user';
let isOpen = $state(false);
</script>
<button onclick={() => isOpen = true}>Add User</button>
<InstantWindowForm
schema={UserSchema}
bind:open={isOpen}
/>Props: schema (required), open (bindable), data (optional, pre-fills for editing), title (optional, overrides dialog title)
InstantTable
A full datatable with built-in add, edit, and delete. Columns are auto-generated from schema .meta({ datatable: { ... } }) configuration.
<InstantTable
schema={UserSchema}
bind:open={isOpen}
bind:editData={editData}
/>Props: schema (required), open (bindable, connects to InstantWindowForm), editData (bindable, carries row data to form), data (optional, override loaded data), columns (optional, override auto-generated columns), rowActions (optional, custom per-row actions)
What it provides automatically:
- Add button → opens
InstantWindowFormin add mode - Edit button per row → opens
InstantWindowFormpre-filled with that row - Delete button per row → removes record with confirmation
Charts
Kystrap provides configurable chart components powered by LayerChart. Import from $lib/charts.
ChartContainer
A wrapper card with title, optional background icon (Tabler icons), responsive grid column span (1–12), and an optional info popover.
<script lang="ts">
import { ChartContainer } from '$lib/charts';
import { ChartPie } from '$lib/charts';
</script>
<ChartContainer title="Revenue" icon="tabler--currency-dollar" colSpan={2}>
<ChartPie data={revenueData} angle="amount" label="source" type="donut" />
</ChartContainer>| Prop | Type | Default | Description |
|------|------|---------|-------------|
| title | string | 'New Orders' | Card heading |
| colSpan | number | 4 | Grid column span (1–12) |
| icon | string | — | Tabler icon class for decorative background |
| iconColor | IconColor | 'primary' | Icon tint: primary | secondary | success | warning | error | info |
| children | Snippet | required | Chart or content to render inside the card |
| popoverChildren | Snippet | — | Extra content for the info popover |
StatsWrapper
Displays a single numeric stat with optional subtitle and trend indicator.
<script lang="ts">
import { StatsWrapper } from '$lib/charts';
</script>
<StatsWrapper title="$45,230" subtitle="Total Revenue" trend={{ value: 12.5, direction: 'up', suffix: '%', label: 'vs last month' }} />| Prop | Type | Default | Description |
|------|------|---------|-------------|
| title | string | required | Stat value (formatted as you want it displayed) |
| subtitle | string | — | Small label below the title |
| trend | Trend | — | Trend indicator: { value, direction: 'up' \| 'down', suffix?, label? } |
| class | string | — | Additional CSS classes |
ChartTimeline
Multi-purpose time-series chart supporting line, bar, and area chart types. Use the single-series shorthand for simple charts or the series prop for multi-series with mixed types, dual-axis, and bar stacking.
<script lang="ts">
import { ChartTimeline } from '$lib/charts';
</script>
<!-- Single series: line chart -->
<ChartTimeline data={orders} x="date" y="revenue" />
<!-- Single series: bar chart -->
<ChartTimeline data={orders} x="date" y="revenue" type="bar" />
<!-- Multi-series with mixed types -->
<ChartTimeline data={monthlyData} x="month" series={[
{ type: 'bar', y: 'revenue', color: 'var(--color-primary)' },
{ type: 'line', y: 'profit', color: 'var(--color-success)', area: true },
]} />| Prop | Type | Default | Description |
|------|------|---------|-------------|
| data | Record<string, unknown>[] | required | Chart data array |
| x | string | required | Date/category field accessor for the x-axis |
| y | string | — | Single-series shorthand — value accessor. Omit when using series |
| type | 'line' \| 'bar' \| 'area' | 'line' | Single-series chart type |
| color | string | 'var(--color-primary)' | Single-series color |
| yAxis | 'left' \| 'right' | 'left' | Which y-axis this series uses (dual-axis only) |
| curve | boolean | — | Smooth curve interpolation (line/area only) |
| area | boolean | — | Fill area under the line (line only) |
| series | TimelineSeries[] | — | Multi-series config. Omit y/type/color/curve/area when using this |
| height | number | 256 | Chart height in pixels |
| padding | { top?, right?, bottom?, left? } | — | Chart padding in pixels |
| tooltipMode | 'bisect-x' \| 'band' \| 'bisect-band' | 'band' | Tooltip interaction mode |
| tooltip | Snippet | — | Custom tooltip content (receives hovered data point) |
| tooltipFormat | string \| object | — | Format for tooltip values ('currency', 'percent', etc.) |
| showLabel | boolean | — | Show value labels on each data point |
| labelFormat | string \| object | — | Format for value labels ('currency', 'percent', etc.) |
| labelPlacement | 'outside' \| 'inside' \| 'center' \| 'smart' | 'outside' | Value label position |
| showPoint | boolean | — | Show dot markers at data values (line/area only) |
| pointRadius | number | 4 | Point marker radius in pixels |
| pointStrokeWidth | number | 2 | Point marker stroke width |
| barWidth | number | — | Fixed bar width in pixels (bar only) |
| barVariant | 'fill' \| 'outline' \| 'both' | — | Bar visual style (bar only) |
| barStrokeWidth | number | — | Bar stroke width (bar only) |
| barFillOpacity | number | — | Bar fill opacity (bar only) |
| barRadius | number | 6 | Bar corner radius in pixels (bar only) |
| showLegend | boolean | — | Show series legend below chart |
| legendProps | object | — | Props forwarded to the Legend component |
| seriesLayout | 'overlap' \| 'stack' \| 'group' \| 'stackExpand' \| 'stackDiverging' | 'overlap' | Layout for multiple bar series |
| xScale | AnyScale | scaleBand().padding(0.4) | X-axis scale factory |
| yScale | AnyScale | auto | Y-axis scale factory |
| xDomain | DomainType | auto | Explicit x-axis domain override |
| yDomain | DomainType | auto | Explicit y-axis domain override |
| xAxisProps | object | — | Props forwarded to the bottom (x) axis |
| yAxisProps | object | — | Props forwarded to the left (y) axis |
| yRightAxisProps | object | — | Props forwarded to the right axis (dual-axis only) |
| class | string | — | Additional CSS classes |
| style | string | — | Inline styles
ChartPie
Pie and donut chart for proportional data.
<script lang="ts">
import { ChartPie } from '$lib/charts';
</script>
<ChartPie data={browserData} angle="share" label="browser" />
<ChartPie data={revenueData} angle="amount" label="source" type="donut" showLabel showLegend />📌 Slice ordering: ChartPie renders slices in the order they appear in the
dataarray — it does not sort internally. For consistent display, sort your data on the server before passing it to the component (e.g., by value descending for largest-to-smallest slices).
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| data | Record<string, unknown>[] | required | Chart data array |
| angle | string | required | Numeric field accessor for arc size |
| label | string | required | Category/label field accessor for slice names |
| type | 'pie' \| 'donut' | 'pie' | Chart style |
| colors | string[] | theme colors | Slice color overrides |
| innerRadius | number | 0.6 (donut) | Inner radius: pixel (≥1), percent (0–1), or offset (<0) |
| outerRadius | number | auto (half height) | Outer radius in pixels |
| padAngle | number | 0 | Angle between arcs in radians |
| cornerRadius | number | 0 | Corner radius of arcs in pixels |
| showLabel | boolean | false | Show value labels on each slice |
| labelFormat | object | — | Arc label config (placement, format, fontSize, calloutLineLength, etc.) |
| labelPlacement | 'centroid' \| 'callout' \| 'inner' \| 'middle' \| 'outer' | 'callout' | Label positioning strategy |
| tooltipFormat | string \| object | — | Tooltip value format ('currency', 'percent', etc.) |
| showLegend | boolean | false | Show legend below chart |
| legendProps | object | — | Props forwarded to the Legend component (title, placement, classes, etc.) |
| onSliceClick | function | — | Click handler: (e, { data, series }) => void |
| height | number | 256 | Chart height in pixels |
| class | string | — | Additional CSS classes |
| style | string | — | Inline styles
TableMini
A compact mini-table widget for dashboards — ideal for displaying "Recent Orders", "Top Products", or "Latest Signups" inside a ChartContainer. It reads column configuration from your Zod schema's datatable meta, supports row click handlers, row actions snippets, and a "View All" footer link.
<script lang="ts">
import { ChartContainer, TableMini } from '$lib/charts';
import { OrderSchema } from './schema';
let { data } = $props();
</script>
<ChartContainer title="Recent Orders" icon="tabler--shopping-cart">
<TableMini
data={data.orders}
schema={OrderSchema}
maxRows={5}
viewAllHref="/orders"
/>
</ChartContainer>| Prop | Type | Default | Description |
|------|------|---------|-------------|
| data | T[] | required | Array of typed records |
| schema | ZodObject | required | Kystrap Zod schema |
| columns | string[] | schema datatable order | Column keys to show (in order) |
| labelMap | Record<string, string> | — | Override column labels |
| maxRows | number | 5 | Max visible rows before scroll |
| viewAllHref | string | — | "View All" link URL |
| rowClick | (row) => void | — | Row click handler |
| showRowNumber | boolean | false | Show row number column |
| rowActions | Snippet<[row]> | — | Extra column with per-row action buttons |
| rowActionsLabel | string | 'Actions' | Actions column header |
| rowActionsWidth | string | 'w-20' | Actions column width |
| locale | DateLocale | 'enGB' | Date formatting locale |
| timezone | string | local | Date formatting timezone |
| emptyMessage | string | 'No data' | Empty state message |
| class | string | 'w-full' | Container CSS classes |
Tip: TableMini automatically resolves field types (
date,toggle,singleOption,parsedInput) anddatatable.displayParserfrom the schema meta. No manual column config needed for standard types.
InstantDashboard
Location:
$lib/instants/InstantDashboard.svelte
Auto-generates a full dashboard page from your Zod schema's dashboard meta config.
Pass one or more schemas — each becomes a titled section with stat cards, charts, and mini-tables
defined in the schema's meta({ dashboard: {...} }).
Data is resolved server-side via collectionInstantDashboardDataMany() —
define which fields to aggregate, which charts to render, and which tables to show.
<script lang="ts">
import InstantDashboard from '$lib/instants/InstantDashboard.svelte';
import { TransactionSchema } from '../schema.ts';
let { data } = $props();
</script>
<InstantDashboard schemas={[TransactionSchema]} data={data.dashboardData} />| Prop | Type | Default | Description |
|------|------|---------|-------------|
| schemas | zod.ZodObject[] | required | Zod schemas with dashboard meta — each becomes a section |
| data | Record<string, DashboardWidgetData> | required | Widget data keyed by schema identifier |
| locale | DateLocale | 'enGB' | Locale for date formatting in tables |
| timezone | string | local tz | Timezone for date formatting in tables |
Server-side data resolution:
// +page.server.ts
import { collectionInstantDashboardDataMany } from '$lib/server/mongodb';
export const load = async () => {
return {
dashboardData: await collectionInstantDashboardDataMany([TransactionSchema])
};
};Multi-schema with different databases: Pass a second argument with collection → dbName overrides:
const data = await collectionInstantDashboardDataMany( [OrdersSchema, UsersSchema], { users: 'analytics-db' } );
Dashboard meta reference
Configure widgets in your schema's .meta({ dashboard: {...} }):
export const TransactionSchema = zod.object({...}).meta({
title: 'Transaction',
database: { collection: 'transactions' },
dashboard: {
title: 'Transactions Insights',
cols: 3,
statCards: [
{
field: 'amount',
label: 'Total Revenue',
icon: 'tabler--currency-dollar',
valueFormat: 'currency',
query: { type: 'sum' }
}
],
charts: [
{
type: 'line',
title: 'Revenue Over Time',
xField: 'createdAt',
yField: 'amount',
colSpan: 2,
timelineChartProps: { showPoint: true }
},
{
type: 'pie',
title: 'By Status',
angleField: 'amount',
labelField: 'status',
pieChartProps: { showLegend: true }
}
],
tables: [
{
title: 'Recent Transactions',
columns: ['customer', 'amount', 'status'],
maxRows: 5,
query: { sort: { createdAt: -1 } }
}
]
}
});Stat card aggregation auto-inference: When
query.typeis omitted, the server infers it from the field's type —'sum'for number/slider/parsedInput fields,'count'for everything else.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| key | string | collection → title | Unique data lookup key for multi-schema |
| title | string | schema's title | Section header |
| cols | 2 \| 3 \| 4 | 3 | Grid columns |
| statCards | DashboardStatCard[] | auto-discovered | Stat card widget definitions. Omit to auto-discover fields with statCard meta |
| charts | DashboardChart[] | — | Chart definitions (timelineChartProps / pieChartProps for customization) |
| tables | DashboardTable[] | — | Mini-table widget definitions |
Auto-discovery: If you don't define
statCards, InstantDashboard auto-discovers fields with astatCardmeta property and creates stat cards for them. The aggregation type is inferred from the field type.
Empty states: When a chart or stat card has no data, InstantDashboard gracefully shows "No data available" instead of a blank chart. Stat cards show
—when the aggregation returns no results.
Server / MongoDB
All server functions for MongoDB are imported from $lib/server/mongodb.ts.
Low-level collection functions
These functions give you direct, type-safe access to MongoDB collections using
your Kystrap schema. The collection and database names are automatically resolved
from the schema's .meta({ database: { db, collection } }).
import {
collection, // Get typed MongoDB Collection handle
collectionFind, // Find documents
collectionInsertOne, // Insert a document
collectionUpdateOne, // Update a document
collectionRemoveOne, // Delete a document
collectionInstantDashboardData, // Single-schema dashboard widget data
collectionInstantDashboardDataMany, // Multi-schema dashboard widget data
} from '$lib/server/mongodb.ts';collection(schema, [options])
Returns a typed MongoDB Collection handle. The collection is typed with the schema's output
(with _id made optional), so all chain operations are fully type-safe.
const users = collection(userSchema);
const doc = await users.findOne({ _id: new ObjectId(id) });| Param | Type | Description |
|-------|------|-------------|
| schema | ZodObject | The Kystrap schema describing the collection |
| options.dbName | string | Database override. Falls back to MONGODB_DEFAULT_DB env, then 'default' |
Returns: Collection<OptionalId<zOutput<T>>>
collectionFind(schema, [options])
Finds all documents matching the filter and returns them as an array.
const admins = await collectionFind(userSchema, {
filter: { role: 'admin' },
findOptions: { limit: 10, sort: { name: 1 } }
});| Param | Type | Default | Description |
|-------|------|---------|-------------|
| schema | ZodObject | — | The Kystrap schema |
| options.filter | Filter | {} | MongoDB filter query, typed against the schema |
| options.findOptions | FindOptions | {} | Projection, sort, limit, etc. |
| options.dbName | string | — | Database override |
collectionInsertOne(schema, document, [options])
Inserts a single typed document.
const result = await collectionInsertOne(userSchema, {
name: 'Alice',
email: '[email protected]'
});| Param | Type | Description |
|-------|------|-------------|
| schema | ZodObject | The Kystrap schema |
| document | OptionalUnlessRequiredId<OptionalId<zOutput<T>>> | The document to insert |
| options.dbName | string | Database override |
collectionUpdateOne(schema, updateQuery, [filter], [options])
Updates a single document matching the filter using MongoDB update operators.
const result = await collectionUpdateOne(
userSchema,
{ $set: { name: 'Bob' } },
{ _id: new ObjectId(id) }
);| Param | Type | Default | Description |
|-------|------|---------|-------------|
| schema | ZodObject | — | The Kystrap schema |
| updateQuery | UpdateFilter | — | MongoDB update operators or aggregation pipeline |
| filter | Filter | {} | Filter to match the document |
| options.dbName | string | — | Database override |
| options.updateOptions | UpdateOptions | {} | Upsert, arrayFilters, etc. |
collectionRemoveOne(schema, [filter], [options])
Deletes a single document matching the filter.
const result = await collectionRemoveOne(userSchema, { _id: new ObjectId(id) });| Param | Type | Default | Description |
|-------|------|---------|-------------|
| schema | ZodObject | — | The Kystrap schema |
| filter | Filter | {} | Filter to match the document to delete |
| options.dbName | string | — | Database override |
| options.deleteOptions | DeleteOptions | {} | Collation, hint, etc. |
Instant SvelteKit form actions
These functions generate auto-keyed SvelteKit form actions (e.g. add-users, edit-users,
remove-users) that validate incoming data against the schema, perform the database
operation, and return success/failure responses consumable by InstantForm or SvelteKit's
$page.form.
import {
collectionInstantAdd, // SvelteKit form action - add
collectionInstantEdit, // SvelteKit form action - edit
collectionInstantRemove, // SvelteKit form action - remove
collectionInstantAddEditRemove // All three combined
} from '$lib/server/mongodb.ts';Lifecycle hooks (beforeAdd/beforeEdit/beforeRemove) receive an options object
{ data, event } giving you access to the validated data and the full RequestEvent
(which includes event.locals, event.request.headers, event.cookies, etc.).
Aborting with before- hooks: Return a string from any before- hook to abort
the operation and display that string as an error message on the form. This is the
recommended pattern for authorization guards.
collectionInstantAdd(schema, [options])
Creates an add-{collectionName} form action. The action validates the form data against
the schema (via InstantValidate), transforms string IDs to ObjectId, and inserts the
document via collectionInsertOne.
export const actions = {
...collectionInstantAdd(userSchema, {
successMessage: 'User created!',
redirectTo: '/users',
beforeAdd: async ({ data, event }) => {
// Authorization guard: return a string to abort
if (!event.locals.user) return 'You must be logged in';
// Transform data if needed
},
afterAdd: async ({ data, event }) => {
// Log or notify — runs only after successful insert
}
})
};| Option | Type | Default | Description |
|--------|------|---------|-------------|
| dbName | string | — | Database override |
| errorMessage | string | 'Invalid data' | Message on validation failure |
| successMessage | string | '{Title} added!' | Message on success |
| redirectTo | string | — | URL to redirect to on success |
| redirectOnError | boolean | false | Redirect on validation failure |
| beforeAdd | ({ data, event }) => Promise<string \| undefined> | — | Async hook before insert. Receives { data, event }. Return a string to abort and show an error |
| afterAdd | ({ data, event }) => Promise<void> | — | Async hook after successful insert. Receives { data, event } |
collectionInstantEdit(schema, [options])
Creates an edit-{collectionName} form action. Automatically:
- Validates the request is an edit (requires
validator.isEditandvalidator.dataId) - Strips the
updateKeyfield from the data to avoid overwriting the identifier - Sets
updated_atto the current date if the field exists in the schema
export const actions = {
...collectionInstantEdit(userSchema, {
updateKey: '_id',
updateKeyType: 'objectId',
beforeEdit: async ({ data, event }) => {
if (!event.locals.user?.roles?.includes('admin'))
return 'Only admins can edit';
},
afterEdit: async ({ data, event }) => {
console.log(`Edited by ${event.locals.user?.name}`);
}
})
};| Option | Type | Default | Description |
|--------|------|---------|-------------|
| updateKey | string | '_id' | Field used to identify the document |
| updateKeyType | 'string' \| 'number' \| 'objectId' | 'objectId' | Type of the identifier value |
| dbName | string | — | Database override |
| errorMessage | string | 'Invalid data' | Message on validation failure |
| successMessage | string | '{Title} edited!' | Message on success |
| redirectTo | string | — | URL to redirect to on success |
| redirectOnError | boolean | false | Redirect on validation failure |
| beforeEdit | ({ data, event }) => Promise<string \| undefined> | — | Async hook before update. Receives { data, event }. Return a string to abort and show an error |
| afterEdit | ({ data, event }) => Promise<void> | — | Async hook after successful update. Receives { data, event } |
collectionInstantRemove(schema, [options])
Creates a remove-{collectionName} form action. Extracts the identifier value from the
submitted data using removeKey and deletes via collectionRemoveOne.
export const actions = {
...collectionInstantRemove(userSchema, {
beforeRemove: async ({ data, event }) => {
if (!event.locals.user) return 'Login required to delete';
},
afterRemove: async ({ data, event }) => {
await auditLog(event.locals.user.id, 'deleted');
}
})
};| Option | Type | Default | Description |
|--------|------|---------|-------------|
| removeKey | string | '_id' | Field used to identify the document to delete |
| removeKeyType | 'string' \| 'number' \| 'objectId' | 'objectId' | Type of the identifier value |
| dbName | string | — | Database override |
| errorMessage | string | 'Invalid data' | Message on validation failure |
| successMessage | string | '{Title} removed!' | Message on success |
| redirectTo | string | — | URL to redirect to on success |
| redirectOnError | boolean | false | Redirect on validation failure |
| beforeRemove | ({ data, event }) => Promise<string \| undefined> | — | Async hook before removal. Receives { data, event }. Return a string to abort and show an error |
| afterRemove | ({ data, event }) => Promise<void> | — | Async hook after successful removal. Receives { data, event } |
collectionInstantAddEditRemove(schema, [options])
Combines all three CRUD actions into a single spreadable object — the easiest way to wire up full CRUD for a collection.
export const actions: Actions = {
...collectionInstantAddEditRemove(userSchema, {
dbName: 'my-db',
add: { redirectTo: '/users' },
edit: { updateKey: '_id' },
remove: {}
})
};| Option | Type | Description |
|--------|------|-------------|
| dbName | string | Database name shared across all three actions |
| add | Omit<InstantAddOptions, 'dbName'> | Options forwarded to collectionInstantAdd |
| edit | Omit<InstantEditOptions, 'dbName'> | Options forwarded to collectionInstantEdit |
| remove | Omit<InstantRemoveOptions, 'dbName'> | Options forwarded to collectionInstantRemove |
Schema utilities
Imported from kystrap/schemas.
Custom Zod types
import { zod, zObjectId, zObjectIdNullable, zPhone } from 'kystrap/schemas';
zod.zObjectId() // MongoDB ObjectId field
zod.zObjectIdNullable() // Nullable ObjectId
zod.zPhone() // Phone number with formattingSchema helpers
import { WithObjectId, enumToOptions } from 'kystrap/schemas';
// Add _id: zObjectId() to any schema
const WithId = WithObjectId(UserSchema);
// Convert a zod.enum to an options array for selects
const options = enumToOptions(zod.enum(['admin', 'user']));
// → [{ value: 'admin', label: 'admin' }, { value: 'user', label: 'user' }]Schema meta readers
import { titleOf, descriptionOf, collectionOf, dbOf } from 'kystrap/schemas';
titleOf(UserSchema) // 'User'
descriptionOf(UserSchema) // 'User account'
collectionOf(UserSchema) // 'users'
dbOf(UserSchema) // 'myapp'ObjectId utilities
import { SingleObjectId, MultipleObjectId, isObjectId } from 'kystrap/schemas';
// Schemas for ID-only payloads (e.g. delete by ID)
SingleObjectId // zod.object({ _id: zObjectId() })
MultipleObjectId // zod.object({ ids: zod.array(zObjectId()) })
isObjectId('507f1f77bcf86cd799439011') // trueServer-side schema transforms
Used in +page.server.ts to coerce string ObjectIds from form data to ObjectId instances.
import { TransformObjectId, TransformNullableObjectId, TransformAllObjectId } from 'kystrap/server/schemas';Fields
Field components pair an input with a label, validation display, and error message. They read field type from the Zod schema and its .meta() configuration.
<script lang="ts">
import { InputField, AreaField, SingleOptionField, ToggleField, SliderField, DateField } from 'kystrap/fields';
</script>
<InputField {schema} name="name" />
<AreaField {schema} name="bio" />
<SingleOptionField {schema} name="role" options={[{ value: 'admin', label: 'Admin' }]} />
<ToggleField {schema} name="active" />
<SliderField {schema} name="rating" />
<DateField {schema} name="birthday" />All fields: InputField, AreaField, SingleOptionField, MultipleOptionField, ToggleField, TagField, HiddenField, DateField, SliderField, FieldPopover, SubmitButton
UI components
Imported from kystrap/components.
import { Alert, Dialog, Dropdown, Popover } from 'kystrap/components';
<Alert type="success">Saved!</Alert>
<Alert type="error">Something went wrong.</Alert>
<Dialog bind:open={isOpen} title="Confirm">
Are you sure?
</Dialog>Components: Alert, AlertAction, Dialog, Dropdown, Popover, Scrollable, Swaps, ToggleButton, ThemeToggler
ThemeToggler
Imported from kystrap/components.
The ThemeToggler toggles between light and dark themes by setting a data-theme attribute on <html> and persisting the choice in localStorage.
1. Add the init script to app.html
This prevents a flash of the wrong theme on first page load, before Svelte hydrates. Add this inside <head> before %sveltekit.head%:
<script>
(function () {
try {
var theme = localStorage.getItem('theme');
if (theme === 'theme-dark' || theme === 'theme-light') {
document.documentElement.setAttribute('data-theme', theme);
}
} catch (e) {}
})();
</script>
%sveltekit.head%Already included in the Installation section above — make sure it's in your project.
2. Use the component
<script lang="ts">
import { ThemeToggler } from 'kystrap/components';
</script>
<ThemeToggler />
<!-- Uses default theme names: 'theme-dark' and 'theme-light' -->Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| darkThemeName | string | 'theme-dark' | Theme name applied when dark mode is active |
| lightThemeName | string | 'theme-light' | Theme name applied when light mode is active |
| content | Snippet<[{ theme: string; isDark: boolean }]> | — | Custom snippet for the toggle icon/ content |
Custom theme names
If your DaisyUI config uses different theme names, pass them via props:
<ThemeToggler darkThemeName="dracula" lightThemeName="corporate" />Custom toggle content
Use the content snippet prop to render your own icon or markup. It receives { theme, isDark } so you can react to the current state:
<script lang="ts">
import { ThemeToggler } from 'kystrap/components';
</script>
<ThemeToggler>
{#snippet content({ isDark })}
<span class="iconify size-6 {isDark ? 'tabler--moon' : 'tabler--sun'}" />
{/snippet}
</ThemeToggler>When no content snippet is provided, the component renders a sun icon in dark mode and a moon icon in light mode (with svelte/transition:fade animation) as fallback.
Datatable
The raw Datatable component, for cases where you want full control over columns and data without the Instant layer.
<script lang="ts">
import { Datatable } from 'kystrap/datatable';
import type { ColumnDef } from 'kystrap/datatable';
const data = [...];
const columns: ColumnDef<any>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' }
];
</script>
<Datatable {data} {columns} />Types: ColumnDef, RowSelectionState, ColumnFiltersState, SortingState
Helpers: renderComponent, renderSnippet, dataTableSelectorColumn
Utility functions
Imported from kystrap/functions.
import { toast, debounce, formatDate, simpleDelay } from 'kystrap/functions';
toast.success('Saved!');
toast.error('Failed.');
const debouncedSearch = debounce(search, 300);
formatDate(new Date(), 'MMM dd, yyyy'); // 'Jun 09, 2026'
await simpleDelay(500); // wait 500msDate utilities: getLocalTimeZone, today, todayLocal, fromToday, toToday, dateTimezoned, formatDate, formatRelativeDate, nowDate
Low-level inputs
Bare input components without label or validation wrapper. Use these when building custom form layouts.
import { Input, Select, Area, Toggle, TagInput, Combobox, DateInput } from 'kystrap/inputs';
<Input bind:value={name} placeholder="Name" />
<Select {options} bind:value={selected} />
<Toggle bind:checked={active} />License
MIT
