# Forms

URL: https://developers.uidu.org/docs/domains/forms

> Fetch form schemas and submit form responses against the uidu Forms API.

* **Use it for:** [Public website](https://developers.uidu.org/docs/paths/public-website.md) · [Server integration](https://developers.uidu.org/docs/paths/server-integration.md)
* **Reads:** public token — works in the browser
* **Writes:** Bearer (`apiKey`) — server-side only
* **CLI:** `uidu forms`
* **Auth:** what each token can do and where it may run — [Authentication](https://developers.uidu.org/docs/foundations/authentication.md)

A form is a questionnaire you build in the uidu workspace: you read its schema to render it, and
submit the answers as a **form response**. Forms stand alone or ride along with other records — a CMS
block, a call, a donation campaign, an event RSVP — and the same rules apply everywhere.

## Mental model

* **`Form`** — `id`, `name&#x60;, and its questions under &#x2A;*`inputs`**, a Relay connection of
  `FormQuestion` nodes (`formQuestions` in the schema, aliased to `inputs` by the query; same data).
* **`FormQuestion`** — how the question is asked on this form: `label`, `required`, `hidden`,
  `placeholder`, `hint`, `position`, `fieldKind`, `rules`, `preferences&#x60;, and its &#x2A;*`field`**.
* **`Field`** — what the answer is: `id` (the `fieldId` you submit against), `kind` (`text`,
  `select`, `attachments`, …), `shortname`, `type` (`'system'` for built-in ones), `options` (the
  `{ id, name }` choices of select-like fields, `optionList` in the schema) and, for media fields,
  the upload target (`uploadPresignedUrl`, `uploadUrl`, `uploadParams`).
* **`FormResponse`** — one submission: `fieldValuesAttributes` (one `{ fieldId, content }` per answer),
  an optional respondent created inline with `contactAttributes` (or an existing `contactId`), and a
  `status`. It comes back with `id`, `status`, `fieldValues` (`id`, `content`, `linkedRecordId`,
  `field.id`) and `contact.id`.
  Inside a [custom app](https://developers.uidu.org/docs/paths/custom-app.md) the response belongs to the signed-in member, so
  `contactId` and `contactAttributes` aren't accepted there.

Where forms appear besides `getForm`: a CMS block's field value links one (`linkedRecord`), a call
and a donation campaign carry a `form`, and RSVPs, donations and applications take the answers
nested as `formResponseAttributes`.

Shapes and gotchas for the whole domain:

* **`content` is always `{ value: … }`** — `{ value: 'text' }`, `{ value: 42 }`, `{ value: true }`,
  `{ value: '2026-07-27' }`, `{ value: ['gid://…'] }` for `multipleSelect`, `{ value: doc }` for
  `richText`/`json`. A bare value isn't rejected: the record saves, its typed columns stay empty and
  later reads of that field error. `UiduClient.mutate` wraps a bare `content` for you and warns in
  development; other API clients won't. The full rule:
  [Pagination & field values](https://developers.uidu.org/docs/foundations/pagination-and-field-values.md).
* **Status follows one event.** A response is `initialized` until you send `event: 'complete!'`, on
  create or on a later update; then it is `completed` (`abandoned` is the third state). Omit `event`
  and the response is saved as a draft. `'complete!'` is the only event the mutations act on, and it
  fires once: completing an already completed response is a no-op. Completion is what announces the
  response to the workspace — except for a form attached to another record (a call, a campaign…),
  where that record's own flow announces it.
* **Answers to selects** are option `id`s from `field.options`.
* **Errors come back, they don't throw.** Both mutations return the payload as-is,
  `{ errors, formResponse } | null`; a non-empty `errors` (`{ key, name, type }`) is a validation
  failure. See [Writes](https://developers.uidu.org/docs/foundations/writes.md) and [Errors](https://developers.uidu.org/docs/foundations/errors.md).
* **Reads are public, writes are not.** `getForm` and `listForms` work with the public token, in the
  browser too. `createFormResponse` and `updateFormResponse` need the `apiKey` client, so they run in a
  server action — never with the public token from the browser. See
  [Authentication](https://developers.uidu.org/docs/foundations/authentication.md).

## Use cases

### A contact form on your site

Goal: render a form built in uidu and store each submission as a completed response.

```tsx
// app/contact/page.tsx — a Server Component
import { createFormResponse, getForm } from '@uidu/client';
import {
  DynamicForm,
  type DynamicFormResult,
  type DynamicFormValues,
} from '@uidu/react';
import { notFound } from 'next/navigation';
import { uidu } from '@/lib/uidu'; // createClient({ workspace, publicToken, apiKey })

const FORM_ID = 'Z2lkOi8v…'; // from the Forms app, or `uidu forms list`

export default async function ContactPage() {
  const form = await getForm(uidu, { id: FORM_ID });
  if (!form) notFound();

  async function submit(values: DynamicFormValues): Promise<DynamicFormResult> {
    'use server';
    const result = await createFormResponse(uidu, {
      input: {
        attributes: {
          formId: FORM_ID,
          fieldValuesAttributes: values.fieldValues.map((fv) => ({
            fieldId: fv.fieldId,
            content: { value: fv.value },
          })),
          contactAttributes: values.contact && {
            email: values.contact.email,
            contactableAttributes: {
              kind: 'person',
              firstName: values.contact.firstName,
              lastName: values.contact.lastName || undefined,
            },
          },
        },
        event: 'complete!', // omit to save a draft
      },
    });

    if (result?.errors?.length) return { ok: false, errors: result.errors };
    return { ok: true, meta: { responseId: result?.formResponse?.id } };
  }

  return <DynamicForm form={form} action={submit} submitLabel="Send" />;
}
```

* [`<DynamicForm>`](https://developers.uidu.org/docs/tools/react/components.md#dynamicform-form-action-) walks `form.inputs`, picks
  an HTML control per `fieldKind` (a `<select>` when the field has `options`), skips hidden and system
  questions, adds first name / last name / email unless `includeContactFields={false}`, and calls your
  action with `{ contact, fieldValues, formData }`. Every value it collects is a string — a checkbox
  arrives as `'true'`/`'false'`. It has no file input: see [Accept a file upload](https://developers.uidu.org/docs/domains/forms.md#accept-a-file-upload).
* To render the questions yourself, walk the same connection:
  `form.inputs.edges?.map((e) => e?.node)` gives each question's `label`, `required`, `field.kind`
  and `field.options`.
* A forms index: `listForms(uidu, { first: 50 })` returns summaries — `id`, `name`, `cover`,
  `published`, `publishedAt` — without the questions; filter on `published` and call `getForm` for the
  one you render.

### Multi-step form with drafts

Goal: save each step as the visitor goes, so a long form survives a closed tab, and complete it on the
last step.

```ts
// app/apply/actions.ts
'use server';

import { createFormResponse, updateFormResponse } from '@uidu/client';
import { uidu } from '@/lib/uidu';

type Answer = { fieldId: string; value: unknown };

export async function saveStep(
  formId: string,
  responseId: string | null,
  answers: Answer[],
  last: boolean,
) {
  const event = last ? 'complete!' : undefined; // no event = still a draft

  if (!responseId) {
    const created = await createFormResponse(uidu, {
      input: {
        attributes: {
          formId,
          fieldValuesAttributes: answers.map((a) => ({
            fieldId: a.fieldId,
            content: { value: a.value },
          })),
        },
        event,
      },
    });
    if (created?.errors?.length)
      return { ok: false as const, errors: created.errors };
    return { ok: true as const, responseId: created?.formResponse?.id ?? null };
  }

  // Answers already saved are updated by their field value `id`; new ones are added without one.
  const updated = await updateFormResponse(uidu, {
    input: {
      id: responseId,
      event,
      attributes: {
        formId,
        fieldValuesAttributes: answers.map((a) => ({
          fieldId: a.fieldId,
          content: { value: a.value },
        })),
      },
    },
  });
  if (updated?.errors?.length)
    return { ok: false as const, errors: updated.errors };
  return { ok: true as const, responseId };
}
```

* Keep the returned `responseId` between steps (a cookie, the URL, client state) and pass it back.
* To **change** an answer you already saved, include that field value's `id` in its entry:
  `{ id, fieldId, content: { value } }`. Without an `id` the entry adds a new value. The ids are in
  every payload — `formResponse.fieldValues[]` pairs each `id` with its `field.id`.
* `formResponse.status` tells you where it stands: `initialized` while it's a draft, `completed`
  after `'complete!'`.
* Validate per step on the client for a fast UI, but treat `errors` from the server as the answer.

### Accept a file upload

Goal: let the visitor attach a file (a CV, a photo) to an `attachments`-kind question.

```ts
// 1. In the browser: upload the file straight to storage with the field's presigned POST.
//    Pass `question.field.uploadUrl` and `question.field.uploadParams` from a node of
//    form.inputs (read with the public token).
async function uploadToField(
  uploadUrl: string,
  uploadParams: Record<string, string>,
  file: File,
) {
  const body = new FormData();
  Object.entries(uploadParams).forEach(([key, value]) =>
    body.append(key, value),
  );
  body.append('file', file);

  const res = await fetch(uploadUrl, { method: 'POST', body });
  if (!res.ok) throw new Error('upload failed');

  // The uploaded file as uidu's attachment data: the object key without its `cache/` prefix.
  return JSON.stringify({
    id: uploadParams.key.replace(/^cache\//, ''),
    storage: 'cache',
    metadata: { filename: file.name, mime_type: file.type, size: file.size },
  });
}

// 2. In a server action: submit the response with the file as a linked Attachment.
await createFormResponse(uidu, {
  input: {
    attributes: {
      formId,
      fieldValuesAttributes: [
        {
          fieldId: question.field.id,
          linkedRecordType: 'Attachment',
          linkedRecordAttributes: { file: uploadedFile }, // the string from step 1
        },
      ],
    },
    event: 'complete!',
  },
});
```

* `field.uploadPresignedUrl` is the whole presign as JSON (`{ url, fields }`); `uploadUrl` and
  `uploadParams` are its two halves. Each read of the form signs a fresh, short-lived target, and only
  media fields have one — so read the form close to the upload.
* `'Attachment'` is the only `linkedRecordType` the API accepts on a field value. An `attachments`
  answer lives in the linked record, not in `content`.
* The upload itself carries no uidu token; the response that references it is still a write, from
  the server. The same flow serves a call's application form: its questions are at
  `call.form.inputs.edges[].node`. A fuller walk-through: [File upload through Forms](https://developers.uidu.org/docs/recipes/file-upload.md).

## API at a glance

Every `@uidu/client` function in **Forms**, generated from the source — the [full reference](https://developers.uidu.org/docs/reference/client.md#forms) has the rest of the SDK.

| Function                                                                                          | Kind  | Auth         | CLI            | Description                                                                                                                                              |
| ------------------------------------------------------------------------------------------------- | ----- | ------------ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`createForm`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/mutations.ts)     | write | Bearer       | `forms create` | Create a form, questions included via `attributes.formQuestionsAttributes`. Returns the payload (with `errors`) or null. The new form is at `form.node`. |
| [`createFormResponse`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/forms.ts) | write | Bearer       | —              | Submit a new response to a form; returns the payload as-is, `{ errors, formResponse }`, validation failures included.                                    |
| [`deleteForm`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/mutations.ts)     | write | Bearer       | `forms delete` | Delete a form by `{ input: { id } }`. Returns the payload (with `errors`) or null.                                                                       |
| [`getForm`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/forms.ts)            | read  | public token | `forms get`    | One form with its questions (`inputs`, a connection of `FormQuestion` nodes with field metadata) ready to render, or null.                               |
| [`listForms`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/forms.ts)          | read  | public token | `forms list`   | The workspace's forms as a flat array of summaries, without their questions (use `getForm` for those); page with `first`/`after`.                        |
| [`updateForm`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/mutations.ts)     | write | Bearer       | `forms update` | Update a form: `{ input: { id, attributes } }`. Returns the payload (with `errors`) or null.                                                             |
| [`updateFormResponse`](https://github.com/uidu-org/api.js/blob/main/packages/client/src/forms.ts) | write | Bearer       | —              | Update a form response (answers by field value `id`, or a state `event` like `'complete!'`); returns `{ errors, formResponse }` as-is.                   |

**Types:** `Form`

**CLI:**

| Entity  | list | get | create | update | delete |
| ------- | :--: | :-: | :----: | :----: | :----: |
| `forms` |   ✓  |  ✓  |    ✓   |    ✓   |    ✓   |
