Form storage
Upload rich text images and save submissions to S3, R2 or your own API, per form.
Example
Installation
bunx --bun shadcn@latest add @fieldkit/form-storage-serverNeeds the @fieldkit registry. Add it once
Usage
Terminal
import { Form } from "@/components/form/form"import { FormStorageProvider } from "@/components/form/form-storage"import { presignFrom, presignedStorage } from "@/lib/form-storage"
const storage = presignedStorage({ presign: presignFrom("/api/uploads"), submitUrl: "/api/submissions",})
<FormStorageProvider storage={storage}> <Form schema={postSchema} defaultValues={{ title: "", content: "" }} storage={{ formId: "blog_post", maxImageMb: 2 }} > <FieldGroup> <FormText name="title" /> <FormRichText name="content" /> <FormActions> <FormSubmit>Publish</FormSubmit> </FormActions> </FieldGroup> </Form></FormStorageProvider>
// Presign route, for S3, R2 or any S3-compatible storeimport { StorageRequestError, createS3Presigner, limitsFromSpec,} from "@/lib/form-storage-server"import specs from "./form-specs"
const presign = createS3Presigner({ endpoint: process.env.S3_ENDPOINT, bucket: process.env.S3_BUCKET, region: process.env.S3_REGION, accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, publicUrl: process.env.S3_PUBLIC_URL, limits: (formId) => limitsFromSpec(specs[formId]),})
export async function POST(request: Request) { try { return Response.json(await presign(await request.json())) } catch (error) { if (error instanceof StorageRequestError) return error.toResponse() throw error }}
// Submit routeimport { readSubmission } from "@/lib/form-storage-server"import specs from "./form-specs"
export async function POST(request: Request) { const body = await request.json() const result = readSubmission(body, specs[body.formId], { imageHosts: [process.env.S3_PUBLIC_URL], }) if (!result.ok) { return Response.json( { message: result.message, fields: result.fields }, { status: result.status } ) } const id = await saveSubmission(result) return Response.json({ id })}
// Your own API, or any storeimport { endpointStorage } from "@/lib/form-storage"
const storage = endpointStorage({ uploadUrl: "/api/files", submitUrl: "/api/submissions", headers: { authorization: `Bearer ${token}` },})
const custom: FormStorage = { upload: async ({ formId, folder, field, file }) => { const { url, key } = await myStore.put(`${folder}/${formId}/${field}`, file) return { url, key, name: file.name, type: file.type, size: file.size } }, submit: ({ formId, mode, recordId, values }) => mode === "edit" ? db.update(formId, recordId, values) : db.insert(formId, values),}API reference
FormStorage
PropDescription
upload (required)({ formId, folder, field, file, signal, onProgress }) => Promise<StoredFile>Stores one file and returns its url and key. Runs on submit, 3 at a time.submit({ formId, mode, recordId, values }) => Promise<{ id }>Saves the values. values is the payload, so edits send only changes with patch.Adapters
PropDescription
presignedStorage({ presign, submitUrl?, headers? }) => FormStoragepresign returns { uploadUrl, headers, url, key }. The browser PUTs the file there.presignFrom(url, headers?) => presignAsks your presign route for an upload URL.endpointStorage({ uploadUrl, submitUrl?, headers? }) => FormStoragePosts each file as multipart to your API. It returns a StoredFile.memoryStorage({ delayMs? }) => MemoryStorageKeeps files and submissions in memory, for demos and tests.Server
PropDescription
createS3Presigner({ endpoint, bucket, region?, accessKeyId, secretAccessKey, publicUrl, limits, expiresIn?, signContentLength? }) => (body) => Promise<PresignedUpload>Checks type and size against limits(formId), then signs a 5-minute PUT to <folder>/<formId>/<field>/.limitsFromSpec(spec) => StorageLimits | nullReads folder, size and types from a spec's storage block.readSubmission(body, spec, { imageHosts }) => SubmissionResultValidates values with the spec's schema and rejects images and file attachments outside imageHosts. Errors match what the adapters show on fields.Notes
- Images and files stay in the browser until submit, so leaving a form uploads nothing.
- If saving fails after uploads, files stay under <folder>/<formId>/ with no record. Sweep that prefix to clean up.
- A failed upload stops the save and marks the field. Uploaded images keep their URL, so a retry sends only the rest.
- File fields still submit File objects. Send them with onSubmit, not a JSON submit.