Skip to content
Built with

Actions and forms ​

Build & Deploy

Use an action to change server data from a form or a button. Define it in the page's .server.ts file with defineHandler, just like a loader or a server route.

On this pageHandle a form submission · Validate input and return errors · Update the page after a change

Defining an action ​

This step defines an action. Export action from a .server.ts file. Validate the body with withValidator() and a schema-derived validator.

ts
import { users, insertUserSchema } from '@schema';
// pages/users/index.server.ts
import { defineHandler } from 'void';
import { db } from 'void/db';

export const action = defineHandler.withValidator({
  body: insertUserSchema,
})(async (c, { body }) => {
  await db.insert(users).values(body);
  // No return → re-runs loader, page re-renders with fresh data
});
Action returnsBehavior
Nothing (void)Re-runs the loader, page re-renders with fresh props.
c.redirect('/path')Navigates to another page.

Form ​

Pair Form with useForm to submit values and display validation and action errors. Inputs stay editable when a submission fails.

Import Form from your adapter and pass form. Optionally set method="put", "patch" or "delete". The default is "post". Use renderError for a custom error renderer. In Svelte it is a snippet.

useForm ​

useForm handles submissions, loading state, and validation errors. URLs, form values, and error fields are typed from your action.

This step submits to the /users action defined above.

tsx
// pages/users/index.tsx
import { Form, useForm } from '@void/react';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>Create</button>;
}

export default function CreateUser() {
  const form = useForm('/users', { name: '', email: '' });

  return (
    <Form form={form}>
      <input
        name="name"
        value={form.data.name}
        onChange={(e) => form.setData('name', e.target.value)}
      />
      {form.errors.name && <span>{form.errors.name}</span>}

      <input
        name="email"
        value={form.data.email}
        onChange={(e) => form.setData('email', e.target.value)}
      />
      {form.errors.email && <span>{form.errors.email}</span>}

      <SubmitButton />
    </Form>
  );
}
vue
<!-- pages/users/index.vue -->
<script setup lang="ts">
import { Form, useForm } from '@void/vue';

const form = useForm('/users', { name: '', email: '' });
</script>

<template>
  <Form :form="form">
    <input v-model="form.data.name" />
    <span v-if="form.errors.name">{{ form.errors.name }}</span>

    <input v-model="form.data.email" />
    <span v-if="form.errors.email">{{ form.errors.email }}</span>

    <button :disabled="form.pending">Create</button>
  </Form>
</template>
svelte
<!-- pages/users/index.svelte -->
<script>
  import { Form, useForm } from "@void/svelte";

  const form = useForm("/users", { name: "", email: "" });
</script>

<Form {form}>
  <input bind:value={form.data.name} />
  {#if form.errors.name}<span>{form.errors.name}</span>{/if}

  <input bind:value={form.data.email} />
  {#if form.errors.email}<span>{form.errors.email}</span>{/if}

  <button disabled={form.pending}>Create</button>
</Form>
tsx
// pages/users/index.tsx
import { Form, useForm } from '@void/solid';

export default function CreateUser() {
  const form = useForm('/users', { name: '', email: '' });

  return (
    <Form form={form}>
      <input value={form.data.name} onInput={(e) => form.setData('name', e.target.value)} />
      {form.errors.name && <span>{form.errors.name}</span>}

      <input value={form.data.email} onInput={(e) => form.setData('email', e.target.value)} />
      {form.errors.email && <span>{form.errors.email}</span>}

      <button disabled={form.pending}>Create</button>
    </Form>
  );
}

Types come from the action's withValidator() schema in the .server.ts file. Without a validator, the body is Record<string, unknown>. URL autocomplete still works.

useForm API ​

useForm returns a reactive object with:

Property / MethodPurpose
data / setDataCurrent form values, typed to match the action's body schema.
errorsField-level validation errors, keys typed to body field names.
errorAction error, or null.
post, put, patch, deleteSubmit the form with that method. In React these are native form action callbacks.
pendingtrue while the submission is in flight.
hasChangestrue if form data differs from initial values.
wasSuccessfultrue after a successful submission. Stays true until the next submission.
recentlySuccessfultrue for 2 seconds after a successful submission. Useful for flash messages.
reset(...fields)Reset form data to initial values. Field names autocomplete.
clearErrors(...fields)Clear validation errors. Field names autocomplete.
clearError()Clear error.

For a native React form, use form.post, form.put, form.patch, or form.delete as a native form action. For an awaitable mutation, use action(). In Vue, Svelte and Solid, submissions return Promise<void>. Catch thrown errors or use your framework's error handling.

For dynamic routes, pass params in the options.

ts
// pages/users/[id].server.ts has an action
const form = useForm('/users/:id', { name: '' }, { params: { id: '42' } });
<Form form={form} method="put">{/* submits to /users/42 */}</Form>

Named actions ​

This step adds several actions to one page, such as update and delete. Export actions (plural) instead of action.

ts
import { users, updateUserSchema } from '@schema';
// pages/users/[id].server.ts
import { defineHandler } from 'void';
import { db, eq } from 'void/db';

export const actions = {
  update: defineHandler.withValidator({
    body: updateUserSchema,
  })(async (c, { body }) => {
    await db
      .update(users)
      .set(body)
      .where(eq(users.id, Number(c.req.param('id'))));
  }),

  delete: defineHandler(async (c) => {
    await db.delete(users).where(eq(users.id, Number(c.req.param('id'))));
  }),
};

A ?actionName suffix on the URL selects the action, for example /users/42?update. useForm and action() handle this for you.

Use action or actions in a file, not both.

An actions.default key runs when no name is given, as with a bare POST to the page URL:

ts
export const actions = {
  default: defineHandler(async (c) => {
    // runs on POST /users/edit (no ?suffix)
  }),
  delete: defineHandler(async (c) => {
    // runs on POST /users/edit?delete
  }),
};

Named actions with useForm ​

This step targets a named action. Append ?actionName to the URL.

ts
const form = useForm('/users/:id?update', { name: '' }, { params: { id } });
<Form form={form} method="put">{/* submits to /users/42?update */}</Form>

Each named action has its own validator schema. It types the URL, body and error keys. See Type Safety for an example.

action() helper ​

This step runs a mutation without form state, such as a button. action() updates the page after a successful request.

ts
import { action } from '@void/react'; // or "@void/vue", "@void/svelte", "@void/solid"

const result = await action('/users/:id?delete', {
  params: { id: '42' },
  method: 'DELETE',
});
if (!result.ok) {
  showToast(result.error.message);
}

action() defaults to POST. Pass data, params, and method to set the body, route parameters, or HTTP method.

A successful call returns { ok: true, pageData }. Expected errors, such as validation failures or conflicts, return { ok: false, error }.

Validation errors ​

withValidator() and ValidationError populate form.errors automatically.

Expected errors such as 400, 404, 409, 422, and 429 stay with the form or action call. useForm stores them in form.errors or form.error. action() returns { ok: false, error }.

Authentication errors (401, 403), server errors (500, 502), and unexpected failures are thrown. Handle them with your framework's error boundary or error handling.

This step adds custom validation by throwing ValidationError.

ts
import { defineHandler, ValidationError } from 'void/handler';

export const action = defineHandler(async (c) => {
  const body = await c.req.json();
  if (await emailExists(body.email)) {
    throw new ValidationError({ email: 'Email already taken' });
  }
  // ...
});

Usage limits ​

For AI and Sandbox actions, an uncaught UsageLimit becomes a structured HTTP 429. To change the message, catch the error with instanceof UsageLimit and return error.response({ message: 'Try again shortly. Your draft is still editable.' }). Form shows the message and blocks resubmission until a known retry deadline. Fields stay editable. It never retries on its own.

Authentication and origin checks ​

Browser submissions must come from the page's origin: same scheme, hostname and port. The SDK rejects cross-origin page actions with 403 Forbidden. Server clients can call actions without origin headers. Protected data still needs authentication and authorization.

File uploads ​

This step uploads a file. Put a File, Blob or FileList in the form data. useForm sends it as multipart/form-data.

tsx
import { Form, useForm } from '@void/react';

export default function Upload() {
  const form = useForm('/photos', { title: '', photo: null as File | null });

  return (
    <Form form={form}>
      <input value={form.data.title} onChange={(e) => form.setData('title', e.target.value)} />
      <input type="file" onChange={(e) => form.setData('photo', e.target.files?.[0] ?? null)} />
      <button disabled={form.pending}>Upload</button>
    </Form>
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import { Form, useForm } from '@void/vue';

const form = useForm('/photos', { title: '', photo: null as File | null });
const fileInput = ref<HTMLInputElement>();

function onFileChange() {
  form.data.photo = fileInput.value?.files?.[0] ?? null;
}
</script>

<template>
  <Form :form="form">
    <input v-model="form.data.title" />
    <input type="file" ref="fileInput" @change="onFileChange" />
    <button :disabled="form.pending">Upload</button>
  </Form>
</template>
svelte
<script>
  import { Form, useForm } from "@void/svelte";

  const form = useForm("/photos", { title: "", photo: null });
</script>

<Form {form}>
  <input bind:value={form.data.title} />
  <input type="file" onchange={(e) => { form.data.photo = e.target.files?.[0] ?? null; }} />
  <button disabled={form.pending}>Upload</button>
</Form>
tsx
import { Form, useForm } from '@void/solid';

export default function Upload() {
  const form = useForm('/photos', { title: '', photo: null as File | null });

  return (
    <Form form={form}>
      <input value={form.data.title} onInput={(e) => form.setData('title', e.target.value)} />
      <input type="file" onChange={(e) => form.setData('photo', e.target.files?.[0] ?? null)} />
      <button disabled={form.pending}>Upload</button>
    </Form>
  );
}

This step reads the file on the server with c.req.parseBody().

ts
// pages/photos.server.ts
import { defineHandler, ValidationError } from 'void/handler';
import { storage } from 'void/storage';

// file.name and file.type come from the browser. Don't use them as the key or trust the type.
const imageTypes = new Map([
  ['image/jpeg', 'jpg'],
  ['image/png', 'png'],
  ['image/webp', 'webp'],
]);

export const action = defineHandler(async (c) => {
  const body = await c.req.parseBody();
  const file = body['photo'];
  if (!(file instanceof File) || file.size === 0) return;
  const ext = imageTypes.get(file.type);
  if (!ext) {
    throw new ValidationError({ photo: 'Upload a JPEG, PNG, or WebP image.' });
  }
  await storage.put(`uploads/${crypto.randomUUID()}.${ext}`, file.stream(), {
    httpMetadata: { contentType: file.type },
  });
});

Pick the storage key on the server and allow only the file types you expect. See Serving Files to send them back safely.

Choosing a primitive ​

PrimitivePage updateForm stateFramework-specific
useFormYesYes (errors, dirty, reset)Yes
action()YesNoYes
Native fetch()NoNoNo

Next steps ​

implemented.stream AI