Recipes
File upload through Forms
Use the uploadPresignedUrl flow to attach files to a form submission.
<DynamicForm> has no file input — an attachments question renders as a plain text input. To
collect files, render that question yourself with the presigned-upload flow below.
How it works
- Read the upload target from the form.
getFormreturns it on each media question's field:field.uploadPresignedUrlis the presign as JSON —{ url, fields }, a presigned POST — andfield.uploadUrl/field.uploadParamsare its two halves. This is a public read. - POST the file from the browser straight to
uploadUrl, as multipart form data: every entry ofuploadParams, then the file. No uidu token is involved. - Submit the response from a server action, with the uploaded file as a linked
Attachmenton that question's field value.
Read the form close to the upload
Every read of the form signs a fresh target, and it expires after a short while. Only media fields have one; other fields return it empty.
The page
// app/apply/page.tsx — a Server Component
import { getForm } from '@uidu/client';
import { notFound } from 'next/navigation';
import { uidu } from '@/lib/uidu';
import { ApplyForm } from './apply-form';
const FORM_ID = 'Z2lkOi8v…';
export default async function ApplyPage() {
const form = await getForm(uidu, { id: FORM_ID });
if (!form) notFound();
const fileQuestion = form.inputs.edges
?.map((e) => e?.node)
.find((q) => q?.field.kind === 'attachments');
if (!fileQuestion) notFound();
return (
<ApplyForm
formId={form.id}
fieldId={fileQuestion.field.id}
uploadUrl={fileQuestion.field.uploadUrl as string}
uploadParams={(fileQuestion.field.uploadParams ?? {}) as Record<string, string>}
/>
);
}The upload (client)
// app/apply/apply-form.tsx
'use client';
import { useState } from 'react';
import { submitWithFile } from './actions';
export function ApplyForm(props: {
formId: string;
fieldId: string;
uploadUrl: string;
uploadParams: Record<string, string>;
}) {
const [file, setFile] = useState<string | null>(null);
const [status, setStatus] = useState<'idle' | 'uploading' | 'uploaded' | 'sent'>('idle');
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const picked = e.target.files?.[0];
if (!picked) return;
setStatus('uploading');
const body = new FormData();
Object.entries(props.uploadParams).forEach(([key, value]) => body.append(key, value));
body.append('file', picked); // the file goes last, after the signed fields
const res = await fetch(props.uploadUrl, { method: 'POST', body });
if (!res.ok) throw new Error('upload failed');
// uidu's attachment data for the uploaded file: the object key without its `cache/` prefix.
setFile(
JSON.stringify({
id: props.uploadParams.key.replace(/^cache\//, ''),
storage: 'cache',
metadata: { filename: picked.name, mime_type: picked.type, size: picked.size },
}),
);
setStatus('uploaded');
}
async function send() {
if (!file) return;
const result = await submitWithFile(props.formId, props.fieldId, file);
if (result.ok) setStatus('sent');
}
return (
<div>
<input type="file" onChange={handleChange} />
<button disabled={status !== 'uploaded'} onClick={send}>
Send
</button>
<span>{status}</span>
</div>
);
}Submitting (server action)
The response is a write, so it runs on the server with the API key client:
// app/apply/actions.ts
'use server';
import { createFormResponse } from '@uidu/client';
import { uidu } from '@/lib/uidu'; // createClient({ workspace, publicToken, apiKey })
export async function submitWithFile(formId: string, fieldId: string, file: string) {
const result = await createFormResponse(uidu, {
input: {
attributes: {
formId,
fieldValuesAttributes: [
{
fieldId,
linkedRecordType: 'Attachment',
linkedRecordAttributes: { file }, // the attachment data from the upload
},
// other answers as usual: { fieldId, content: { value } }
],
},
event: 'complete!',
},
});
if (result?.errors?.length) return { ok: false as const, errors: result.errors };
return { ok: true as const, responseId: result?.formResponse?.id };
}'Attachment'is the onlylinkedRecordTypethe API accepts on a field value; the file lives in that linked record, not incontent.- Every other answer in the same response still uses
content: { value }— see Forms. - The same flow serves a call's application form: its questions are at
call.form.inputs.edges[].node— see Calls.
More on the response shape, drafts and statuses: Forms.