Special Field

File Upload

A file upload field rendered as a native HTML file input. Supports accept filters, multiple selection, and acts as the attachment mechanism for hosted uploads.

stable
file upload attachment input form

Interactive demo of file upload fields

A single document — PDF or Word

Select one or more images

/**
 * File Field Demo - Interactive example of file upload fields
 */

import { Form, FormBuilder } from '@saastro/forms';

import { FormProvider } from '@/components/FormProvider';
import { TooltipProvider } from '@/components/ui/tooltip';

const config = FormBuilder.create('file-demo')
  .layout('manual')
  .columns(12)
  .addField('resume', (f) =>
    f
      .type('file')
      .label('Resume')
      .accept('.pdf,.doc,.docx')
      .helperText('A single document — PDF or Word')
      .columns({ default: 12, md: 6 })
      .optional(),
  )
  .addField('photos', (f) =>
    f
      .type('file')
      .label('Photos')
      .accept('image/*')
      .multiple(true)
      .helperText('Select one or more images')
      .columns({ default: 12, md: 6 })
      .optional(),
  )
  .addStep('main', ['resume', 'photos'])
  .build();

export default function FileDemo() {
  const handleSubmit = (data: Record<string, unknown>) => {
    const resume = data.resume as File | null;
    const photos = (data.photos as File[] | null) ?? [];

    console.log('Form submitted:', {
      resume: resume ? { name: resume.name, size: resume.size, type: resume.type } : null,
      photos: photos.map((file) => ({ name: file.name, size: file.size, type: file.type })),
    });
    alert('Form submitted! Check console for the selected files.');
  };

  return (
    <TooltipProvider>
      <FormProvider>
        <Form config={config} onSubmit={handleSubmit} className="space-y-4" />
      </FormProvider>
    </TooltipProvider>
  );
}

Overview

The file field renders a native <input type="file"> styled to match the rest of the form. After selection, the file name(s) are listed below the input.

The field value holds real File objects:

  • Single mode (default): File | nullnull until the user picks a file, and again if they clear the selection.
  • With multiple: true: File[] — an empty array if the selection is cleared.

Because the value is a File, it is not JSON-serializable — see Submitting files for how files travel in the submit pipeline.

Usage

FormBuilder API

import { FormBuilder } from '@saastro/forms';

const config = FormBuilder.create('application')
  .addField('resume', (f) =>
    f
      .type('file')
      .label('Resume')
      .accept('.pdf,.doc,.docx')
      .helperText('PDF or Word document'),
  )
  .addField('photos', (f) =>
    f
      .type('file')
      .label('Photos')
      .accept('image/*')
      .multiple(true),
  )
  .addStep('main', ['resume', 'photos'])
  .build();

JSON Configuration

{
  "type": "file",
  "label": "Resume",
  "accept": ".pdf,.doc,.docx",
  "multiple": false,
  "helperText": "PDF or Word document"
}

Props

PropTypeDefaultDescription
type'file'-Field type (required)
acceptstring-Accepted file types, passed to the native accept attribute (e.g. "image/*", ".pdf,.doc")
multiplebooleanfalseAllow selecting multiple files; changes the value shape to File[]
maxSizenumber-Max file size in bytes — validated client-side on submit (shows a field error). Validate server-side too
labelstring-Field label
helperTextstring-Help text shown below the field
columnsPartial<Record<Breakpoint, number>>-Grid columns by breakpoint
disabledboolean | function | ConditionGroupfalseDisable the input
hiddenboolean | function | ConditionGroup | ResponsivefalseHide the field
readOnlyboolean | function | ConditionGroupfalseIgnore file selection changes

accept is a browser hint, not validation. It filters the file picker dialog, but users can still drop or select other types in some browsers. Check file.type in your submit handler if the type matters.

Validation

File fields bypass schema validation: the library applies a pass-through (z.any()) schema to file fields, so .required(), .schema(...), and JSON validation rules have no effect on them. The one exception is maxSize — when set, oversized files produce a field error on submit.

If a file is mandatory, check the value in your submit handler:

const handleSubmit = (data: Record<string, unknown>) => {
  const resume = data.resume as File | null;

  if (!resume) {
    alert('Please attach your resume');
    return;
  }
  if (resume.size > 5 * 1024 * 1024) {
    alert('File must be under 5 MB');
    return;
  }

  // ... send the data
};

Submitting Files

File values cannot be serialized to JSON, so how they reach your backend depends on the submit path:

  • Hosted backend (HubForm) — file fields are the attachment mechanism for hosted uploads. On submit, File values are uploaded directly to storage via presigned URLs and the submission carries them as an attachments array ({ key, filename, size, mime }). See the HubForm guide.
  • HTTP submit actions — use format: 'form-data'; File values are appended natively to the FormData body. See the Submit guide.
  • Custom onSubmit handler — you receive the raw File / File[] values and can upload them however you like.

Upload on select (UploadAdapter)

For your own backend (R2 / S3 / anything), give the field an uploadAdapter. Files then upload as soon as they’re selected, with a progress bar, and the field value becomes the adapter’s result (e.g. { key } or { url }) instead of the raw File.

createPresignedUploadAdapter generalises the presigned-URL pipeline: you provide getUploadTarget (ask your backend for a short-lived upload URL), and the adapter PUTs the binary straight to it with real progress.

import { createPresignedUploadAdapter } from '@saastro/forms';

const uploadAdapter = createPresignedUploadAdapter({
  getUploadTarget: async (file) => {
    const r = await fetch('/api/upload-url', {
      method: 'POST',
      body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
    }).then((res) => res.json());
    return {
      uploadUrl: r.uploadUrl, // presigned PUT URL
      headers: { 'Content-Type': file.type },
      result: { key: r.key }, // what gets stored as the field value
    };
  },
});

.addField('cv', (f) => f.type('file').label('CV').accept('.pdf').prop('uploadAdapter', uploadAdapter))

uploadAdapter is a function (not serializable), so it’s for code-defined forms. Without it the field keeps its default behaviour (stores raw Files, uploaded on submit). The useFileUpload hook and UploadAdapter type are also exported if you want a fully custom file UI.

Styling

The input uses built-in Tailwind classes (h-10, rounded border, muted file-button text). Extend or override them per part:

.addField('resume', (f) =>
  f.type('file')
    .label('Resume')
    .accept('.pdf')
    .classNames({
      wrapper: 'bg-muted/50 p-4 rounded-lg',
      input: 'border-primary',
      label: 'font-medium',
    })
)

Responsive Layout

.addField('photos', (f) =>
  f.type('file')
    .label('Photos')
    .accept('image/*')
    .multiple(true)
    .columns({ default: 12, md: 6 })
)
  • HubForm - Hosted submit backend with file attachments
  • Submit - Submit actions, including form-data HTTP requests
  • Hidden - Another special field with a pass-through schema

Accessibility

  • Uses the native <input type="file"> element for built-in keyboard and screen reader support
  • Label is associated with the input via the field wrapper
  • Selected file names are displayed as visible text below the input
  • Error messages are linked via aria-describedby