uidudevelopers
Domains

Forms

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

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, and its questions under 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, and its 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 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.
  • 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 ids 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 and Errors.
  • 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.

Use cases

A contact form on your site

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

// 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> 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.
  • 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.

// 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.

// 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.

API at a glance

Every @uidu/client function in Forms, generated from the source — the full reference has the rest of the SDK.

FunctionKindAuthCLIDescription
createFormwriteBearerforms createCreate a form, questions included via attributes.formQuestionsAttributes. Returns the payload (with errors) or null. The new form is at form.node.
createFormResponsewriteBearer—Submit a new response to a form; returns the payload as-is, { errors, formResponse }, validation failures included.
deleteFormwriteBearerforms deleteDelete a form by { input: { id } }. Returns the payload (with errors) or null.
getFormreadpublic tokenforms getOne form with its questions (inputs, a connection of FormQuestion nodes with field metadata) ready to render, or null.
listFormsreadpublic tokenforms listThe workspace's forms as a flat array of summaries, without their questions (use getForm for those); page with first/after.
updateFormwriteBearerforms updateUpdate a form: { input: { id, attributes } }. Returns the payload (with errors) or null.
updateFormResponsewriteBearer—Update a form response (answers by field value id, or a state event like 'complete!'); returns { errors, formResponse } as-is.

Types: Form

CLI:

Entitylistgetcreateupdatedelete
forms✓✓✓✓✓

On this page