Forms
Fetch form schemas and submit form responses against the uidu Forms API.
- Use it for: Public website · Server integration
- 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
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 underinputs, a Relay connection ofFormQuestionnodes (formQuestionsin the schema, aliased toinputsby the query; same data).FormQuestion— how the question is asked on this form:label,required,hidden,placeholder,hint,position,fieldKind,rules,preferences, and itsfield.Field— what the answer is:id(thefieldIdyou submit against),kind(text,select,attachments, …),shortname,type('system'for built-in ones),options(the{ id, name }choices of select-like fields,optionListin 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 withcontactAttributes(or an existingcontactId), and astatus. It comes back withid,status,fieldValues(id,content,linkedRecordId,field.id) andcontact.id. Inside a custom app the response belongs to the signed-in member, socontactIdandcontactAttributesaren'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:
contentis always{ value: … }—{ value: 'text' },{ value: 42 },{ value: true },{ value: '2026-07-27' },{ value: ['gid://…'] }formultipleSelect,{ value: doc }forrichText/json. A bare value isn't rejected: the record saves, its typed columns stay empty and later reads of that field error.UiduClient.mutatewraps a barecontentfor you and warns in development; other API clients won't. The full rule: Pagination & field values.- Status follows one event. A response is
initializeduntil you sendevent: 'complete!', on create or on a later update; then it iscompleted(abandonedis the third state). Omiteventand 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 fromfield.options. - Errors come back, they don't throw. Both mutations return the payload as-is,
{ errors, formResponse } | null; a non-emptyerrors({ key, name, type }) is a validation failure. See Writes and Errors. - Reads are public, writes are not.
getFormandlistFormswork with the public token, in the browser too.createFormResponseandupdateFormResponseneed theapiKeyclient, 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>walksform.inputs, picks an HTML control perfieldKind(a<select>when the field hasoptions), skips hidden and system questions, adds first name / last name / email unlessincludeContactFields={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'slabel,required,field.kindandfield.options. - A forms index:
listForms(uidu, { first: 50 })returns summaries —id,name,cover,published,publishedAt— without the questions; filter onpublishedand callgetFormfor 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
responseIdbetween steps (a cookie, the URL, client state) and pass it back. - To change an answer you already saved, include that field value's
idin its entry:{ id, fieldId, content: { value } }. Without anidthe entry adds a new value. The ids are in every payload —formResponse.fieldValues[]pairs eachidwith itsfield.id. formResponse.statustells you where it stands:initializedwhile it's a draft,completedafter'complete!'.- Validate per step on the client for a fast UI, but treat
errorsfrom 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.uploadPresignedUrlis the whole presign as JSON ({ url, fields });uploadUrlanduploadParamsare 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 onlylinkedRecordTypethe API accepts on a field value. Anattachmentsanswer lives in the linked record, not incontent.- 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.
| Function | Kind | Auth | CLI | Description |
|---|---|---|---|---|
createForm | 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 | write | Bearer | — | Submit a new response to a form; returns the payload as-is, { errors, formResponse }, validation failures included. |
deleteForm | write | Bearer | forms delete | Delete a form by { input: { id } }. Returns the payload (with errors) or null. |
getForm | 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 | 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 | write | Bearer | forms update | Update a form: { input: { id, attributes } }. Returns the payload (with errors) or null. |
updateFormResponse | 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 | ✓ | ✓ | ✓ | ✓ | ✓ |