Actions
Form actions, ActionError, redirects, and RPC form submissions
Form actions
Export a server function from the route <script> block and reference it with action={fn}.
<script>
import { addItem } from '$database/items';
</script>
<form action={addItem} method="POST">
<input type="text" name="title" required />
<button type="submit">Add</button>
</form>
The action receives a single context object with the form data and per-request context. Destructure what you need:
import { ActionError } from '@kuratchi/koze';
export async function addItem({ formData }: { formData: FormData }): Promise<void> {
const title = (formData.get('title') as string)?.trim();
if (!title) throw new ActionError('Title is required');
}
The context object carries:
formData— the submitted form fields (always present; empty for button-triggered actions that don't carry a form body).request— the underlyingRequestobject.url— the parsedURLof the incoming request.params— path params for the matched route.env- Cloudflare bindings for the current Worker request.ctx- the CloudflareExecutionContext.locals- request-scoped state populated by middleware.
The full action signature is fn(...args, ctx). For <form> submissions
args is empty and the context object is the handler's only argument;
for button-triggered actions args carries
the positional call arguments and ctx stays as the trailing parameter.
If you want a reusable annotation, import ActionContext:
import type { ActionContext } from '@kuratchi/koze';
export async function addItem({ formData, env, locals }: ActionContext) {
// env.DB, locals.user, ...
}
Augmented forms
<form action={fn} method="POST"> works without JavaScript as a normal
POST-Redirect-GET form. For app-like UX, wrap the server action with
augment() and bind the form to the returned action state:
<script>
import { augment } from '@kuratchi/koze';
import { navigateTo } from 'koze:navigation';
import { createDatabase } from '$server/database/databases';
let createOpen = false;
const createDb = augment(createDatabase, {
error() {
createOpen = true;
},
success(payload) {
createOpen = false;
navigateTo(payload.redirectTo || '/databases', { replace: true });
},
});
</script>
<Dialog bind:open={createOpen}>
<form action={createDb} method="POST">
<input name="name" required />
if (createDb.error) {
<p class="error">{createDb.error}</p>
}
<button type="submit" disabled={createDb.pending}>
if (createDb.pending) {
Creating...
} else {
Create database
}
</button>
</form>
</Dialog>
augment(action, hooks) returns an action state object:
interface AugmentedActionState {
pending: boolean;
success: boolean;
error: string | undefined;
}
The compiler maps the alias (createDb) back to the wrapped server
action (createDatabase). The submitted form still carries _action
and still works as a native form if JavaScript is unavailable.
Stateful behavior
Stateful augmented forms behave like this:
- Submit uses
fetch()with the compiler-injected_actionfield. - Before the request starts,
.pendingbecomestrue,.successbecomesfalse, and.erroris cleared. - Submit buttons inside the form are disabled while the request is in flight. Buttons that were already disabled are restored to disabled.
- On success,
.pendingbecomesfalseand.successbecomestrue. - On
ActionError,.pendingbecomesfalse,.successbecomesfalse, and.errorcontains the user-facing message. - The route re-renders the pieces of UI that read
.pending,.success, or.error.
Use this for dialogs, create/delete forms, auth forms, uploads, and any flow where the user should see the button disable, a spinner, inline errors, or a success message before the next navigation.
Hooks
The second argument can define lifecycle hooks:
const save = augment(saveSettings, {
pending(payload) {},
success(payload) {},
error(payload) {},
settled(payload) {},
});
Each hook receives:
interface AugmentedActionHookContext<T = unknown> {
action: string;
form?: HTMLFormElement;
response?: Response;
result?: T;
error?: string;
redirectTo?: string | null;
redirectStatus?: number | null;
}
Use hooks for dialog state, toast dispatch, delayed navigation, or route refreshes:
<script>
import { augment } from '@kuratchi/koze';
import { navigateTo } from 'koze:navigation';
import { deleteSite } from '$server/database/sites';
let deleteOpen = false;
const removeSite = augment(deleteSite, {
error() {
deleteOpen = true;
},
success(payload) {
deleteOpen = false;
navigateTo(payload.redirectTo || '/sites', { replace: true });
},
});
</script>
Note: Stateful
augment()does not automatically navigate on action redirects. Redirect metadata is exposed aspayload.redirectToandpayload.redirectStatusso your success UI can render first. CallnavigateTo(...),location.assign(...), or another navigation helper from thesuccesshook when the flow should leave the page.
HTML augmentation
The older form attribute still exists:
<form action={saveSettings} method="POST" augment>
...
</form>
This mode fetches the form, disables submit buttons during the request, and swaps in the returned HTML or follows redirects. It does not create a route-local state object and does not run lifecycle hooks.
Prefer const state = augment(action, hooks) for new product UI. Use
the bare augment attribute only when you want progressive HTML
replacement and do not need .pending, .success, .error, or hooks.
Redirect after write
Use redirect() for POST-redirect-get flows:
import { redirect } from 'koze:navigation';
export async function createItem({ formData }: { formData: FormData }): Promise<void> {
const id = await db.items.insert({ title: formData.get('title') });
redirect(`/items/${id}`);
}
Action errors
Throw ActionError for user-facing validation failures:
import { ActionError } from '@kuratchi/koze';
export async function signIn({ formData }: { formData: FormData }) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
if (!email || !password) throw new ActionError('Email and password are required');
}
For native and HTML-augmented forms, action state is exposed in the template under the function name after the server re-renders the route:
<form action={signIn}>
if (signIn.error) {
<p class="error">{signIn.error}</p>
}
<input type="email" name="email" />
<input type="password" name="password" />
<button type="submit">Sign in</button>
</form>
For stateful augment() forms, read the alias returned by augment():
<script>
import { augment } from '@kuratchi/koze';
import { signIn } from '$server/database/auth';
const signInAction = augment(signIn);
</script>
<form action={signInAction}>
if (signInAction.error) {
<p class="error">{signInAction.error}</p>
}
<button disabled={signInAction.pending}>Sign in</button>
</form>
Page errors
Throw PageError when a route should render an HTTP error page:
import { PageError } from '@kuratchi/koze';
if (!post) throw new PageError(404);
if (!post.isPublished && !currentUser?.isAdmin) throw new PageError(403, 'Admin only');
Validated RPC input
For client-callable route RPC functions, declare a companion schemas object in the same server module. Each schema key must match the exported function name exactly.
import { schema, type InferSchema } from '@kuratchi/koze';
export const schemas = {
createPost: schema({
title: schema.string().min(1),
content: schema.string().min(1),
}),
};
export async function createPost(data: InferSchema<typeof schemas.createPost>) {
return { slug: data.title.toLowerCase().replace(/ /g, '-') };
}
Use the function normally from the route template:
<script>
const result = await createPost({ title: 'Hello', content: 'World' });
</script>
<p>Created: {result.slug}</p>
Rules:
- Schema-backed RPCs accept one object argument.
- The schema object must be exported as
schemas. - Keys in
schemasmust match the RPC function names. - If validation fails, Koze returns
400and does not execute the function.
Current TypeScript pattern:
import { schema, type InferSchema } from '@kuratchi/koze';
export const schemas = {
updateProfile: schema({
displayName: schema.string().min(1),
bio: schema.string().optional(''),
}),
};
export async function updateProfile(data: InferSchema<typeof schemas.updateProfile>) {
// data is typed from the schema
}
Koze currently uses the schema as the source of truth for validated RPC input. Automatic type generation for unannotated handler parameters is not documented as part of this API yet, so use InferSchema<typeof schemas.name> when you want the handler parameter typed.
Button-triggered actions
Reference any $server/* function directly from a button. The framework
spreads the call arguments into the server handler positionally, with
the context object as the trailing parameter:
<script>
import { deleteItem, toggleItem } from '$server/items';
</script>
<button type="button">Delete</button>
<button true)} type="button">Done</button>
The handler signature mirrors the call site — positional arguments come first, then the context object:
import { ActionError } from '@kuratchi/koze';
export async function deleteItem(id: number, { formData }: { formData: FormData }) {
if (!Number.isInteger(id)) throw new ActionError('Invalid id');
await db.items.delete(id);
}
export async function toggleItem(
id: number,
done: boolean,
{ formData, request }: { formData: FormData; request: Request }
) {
await db.items.update(id, { done });
}
Handlers that don't need the context object just drop the parameter:
export async function deleteItem(id: number) {
await db.items.delete(id);
}
Transport
Button-triggered actions share the same action endpoint as
<form action={fn}>: a POST to the current route with _action (the
function name) and _args (a JSON-encoded array of the call arguments).
The framework bridge serializes the click-site arguments, invokes the
server action, follows redirects, and refreshes SSR data after success.
For interactions that need optimistic UI or custom error
handling, wrap the call in a client handler and use the RPC path instead.
When to wrap in a client handler
Reach for a top-script client handler when you need something beyond a fire-and-refresh:
<script>
import { deleteItem } from '$server/items';
async function onDelete(id) {
if (!confirm('Delete this item?')) return;
await deleteItem(id);
location.reload();
}
</script>
<button type="button">Delete</button>
Use a client handler when you want to:
- Prompt for confirmation before sending the request.
- Show an inline spinner or optimistic UI update.
- Handle errors inline without a full page re-render.
Note: When you invoke a server function directly from client JS (e.g.
await deleteItem(id)insideonDelete), Koze dispatches it through the RPC path rather than the button-action path. RPC handlers receive their call arguments positionally without a trailing context object:
// Called from client JS → RPC path
export async function deleteItem(id: number) {
await db.items.delete(id);
}
The context object is only appended when the framework invokes the
handler via the action dispatch path (i.e. for <form action={fn}>
submissions and <button type="button"> clicks that
do NOT flow through a client handler).
See Client interactivity for the full event-handler directive, client handler registration, and SSR data hydration.
Checkbox Groups
Sync a "select all" checkbox with a group of item checkboxes:
<input type="checkbox" data-select-all="todos" />
for (const todo of todos) {
<input type="checkbox" data-select-item="todos" value={todo.id} />
}