Form storage

Upload rich text images and save submissions to S3, R2 or your own API, per form.

Example

0 / 5000

Installation
bunx --bun shadcn@latest add @fieldkit/form-storage-server

Needs 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

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

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

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.

Search docs

Find a component, input or example