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.

View as Markdown
stable
fileuploadattachmentinputform

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

Prop Type Default Description
type 'file' - Field type (required)
accept string - Accepted file types, passed to the native accept attribute (e.g. "image/*", ".pdf,.doc")
multiple boolean false Allow selecting multiple files; changes the value shape to File[]
maxSize number - Max file size in bytes — validated client-side on submit (shows a field error). Validate server-side too
label string - Field label
helperText string - Help text shown below the field
columns Partial<Record<Breakpoint, number>> - Grid columns by breakpoint
disabled boolean | function | ConditionGroup false Disable the input
hidden boolean | function | ConditionGroup | Responsive false Hide the field
readOnly boolean | function | ConditionGroup false Ignore file selection changes
uploadAdapter UploadAdapter - Uploads each file as it is picked (with progress); the field value becomes the adapter’s UploadResult(s) instead of the raw File(s). See createPresignedUploadAdapter.

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