How to Add File Uploads to Your Contact Form Without a Server
Short answer: Add <input type="file" name="fi-file-attachment" /> to your form, submit via FormData to Forminit, and the file is stored automatically. No server, no S3, no multipart parsing. Works on any static host.
File uploads on contact forms should be simple. A user picks a file, submits the form, and the file arrives alongside the form data. In practice, handling file uploads requires multipart parsing, storage configuration, size validation, and security checks on the server side. If you’re hosting a static site or building with a framework that doesn’t include a backend, you need a form file upload API to handle it for you.
This tutorial covers how to implement contact form file uploads using Forminit, starting with plain HTML and progressing through drag-and-drop interactions and framework-specific implementations in React, Vue, and Astro.
What do you need to get started?
A Forminit account and a form created in the dashboard. That’s it. File uploads are included on all plans with a 25 MB limit per submission. No storage buckets, no S3 credentials, no multipart middleware.
How do file uploads work in Forminit?
Forminit uses a block-based system for form data. Each field in your form maps to a block type. Files use the file block type with a specific naming convention:
fi-file-{name}
The fi- prefix tells Forminit this is a form block. The file segment identifies the block type. The {name} part is your label for the field. A resume upload would be fi-file-resume. A portfolio attachment would be fi-file-portfolio.
For multiple files on a single input, append [] to the name and add the multiple attribute:
<input type="file" name="fi-file-attachments[]" multiple />
File uploads require multipart/form-data encoding. JSON submissions do not support files. When you use FormData in JavaScript, the correct content type is set automatically.
What file types are supported?
Forminit accepts documents (PDF, DOCX, XLSX, CSV, RTF, TXT, PPTX), images (JPEG, PNG, GIF, WebP, HEIC, SVG, TIFF, PSD), video (MP4, MOV, AVI, WebM), audio (MP3, WAV, OGG, FLAC), and archives (ZIP, RAR, 7z, TAR). Apple iWork formats (Pages, Keynote, Numbers) are also supported.
What are the upload limits?
| Limit | Value |
|---|---|
| Maximum upload size per submission | 25 MB (total across all files) |
| Maximum file blocks per submission | 20 |
| Total blocks per submission | 30 |
| Content type | Must be multipart/form-data |
The 25 MB limit applies to the combined size of all files in a single submission. If you need larger uploads, split them across multiple submissions or compress before sending.
How do you add a basic HTML file input?
The simplest serverless file upload form uses Forminit’s CDN SDK with a standard HTML file input.
<form id="contact-form">
<input type="text" name="fi-sender-fullName" placeholder="Full name" required />
<input type="email" name="fi-sender-email" placeholder="Email" required />
<textarea name="fi-text-message" placeholder="Message" required></textarea>
<label for="attachment">Attachment (PDF, up to 25 MB)</label>
<input type="file" id="attachment" name="fi-file-attachment" accept=".pdf,.doc,.docx" />
<button type="submit">Send</button>
</form>
<p id="form-result"></p>
<script src="https://forminit.com/sdk/v1/forminit.js"></script>
<script>
const forminit = new Forminit();
const FORM_ID = 'YOUR_FORM_ID';
document.getElementById('contact-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const { data, error } = await forminit.submit(FORM_ID, formData);
if (error) {
document.getElementById('form-result').textContent = error.message;
return;
}
document.getElementById('form-result').textContent = 'Message sent!';
e.target.reset();
});
</script>
The accept attribute on the file input restricts the file picker to specific types. You can use extensions (.pdf,.docx), MIME types (application/pdf), or category wildcards (image/*).
Common accept patterns:
<!-- Documents only -->
<input type="file" name="fi-file-doc" accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.txt" />
<!-- Images only -->
<input type="file" name="fi-file-photo" accept=".jpg,.jpeg,.png,.gif,.webp" />
<!-- All images via wildcard -->
<input type="file" name="fi-file-image" accept="image/*" />
This works on any static host: Netlify, Vercel, Cloudflare Pages, GitHub Pages, Neocities, or a plain HTML file served from anywhere.
How do you validate files before upload?
The accept attribute is a UI hint, not a security boundary. Users can bypass it. Add client-side validation to catch problems before submission and provide clear feedback.
const MAX_TOTAL_SIZE = 25 * 1024 * 1024; // 25 MB
function validateFile(file, options = {}) {
const { maxSize = 10 * 1024 * 1024, allowedExtensions = [] } = options;
const errors = [];
if (file.size > maxSize) {
errors.push(`"${file.name}" exceeds ${maxSize / 1024 / 1024}MB limit`);
}
if (allowedExtensions.length > 0) {
const ext = file.name.split('.').pop().toLowerCase();
if (!allowedExtensions.includes(ext)) {
errors.push(`"${file.name}" is not an accepted file type`);
}
}
return errors;
}
function validateTotalSize(files) {
const total = Array.from(files).reduce((sum, f) => sum + f.size, 0);
if (total > MAX_TOTAL_SIZE) {
return `Total file size (${(total / 1024 / 1024).toFixed(1)} MB) exceeds the 25 MB limit`;
}
return null;
}
Wire it up to your file input:
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', () => {
for (const file of fileInput.files) {
const errors = validateFile(file, {
maxSize: 5 * 1024 * 1024,
allowedExtensions: ['pdf', 'doc', 'docx']
});
if (errors.length > 0) {
alert(errors.join('\n'));
fileInput.value = '';
return;
}
}
const totalError = validateTotalSize(fileInput.files);
if (totalError) {
alert(totalError);
fileInput.value = '';
}
});
How do you build a drag-and-drop file upload?
A drag-and-drop zone improves the upload experience without adding complexity to the backend. The files still submit through the same FormData flow.
<form id="upload-form">
<input type="text" name="fi-sender-fullName" placeholder="Your name" required />
<input type="email" name="fi-sender-email" placeholder="Email" required />
<div id="drop-zone">
<p>Drag files here or click to browse</p>
<input type="file" name="fi-file-attachments[]" id="file-input" multiple hidden />
</div>
<ul id="file-list"></ul>
<button type="submit">Upload</button>
</form>
<script src="https://forminit.com/sdk/v1/forminit.js"></script>
<script>
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const fileList = document.getElementById('file-list');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const dt = new DataTransfer();
for (const file of e.dataTransfer.files) {
dt.items.add(file);
}
fileInput.files = dt.files;
updateFileList();
});
fileInput.addEventListener('change', updateFileList);
function updateFileList() {
fileList.innerHTML = '';
for (const file of fileInput.files) {
const li = document.createElement('li');
li.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
fileList.appendChild(li);
}
}
const forminit = new Forminit();
const FORM_ID = 'YOUR_FORM_ID';
document.getElementById('upload-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const { data, error } = await forminit.submit(FORM_ID, formData);
if (error) {
alert(error.message);
return;
}
alert('Files uploaded successfully!');
e.target.reset();
fileList.innerHTML = '';
});
</script>
<style>
#drop-zone {
border: 2px dashed #ccc;
padding: 40px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
#drop-zone.drag-over {
border-color: #007bff;
background-color: #f0f7ff;
}
#file-list {
list-style: none;
padding: 0;
margin: 10px 0;
}
#file-list li {
padding: 5px 10px;
background: #f5f5f5;
margin: 5px 0;
border-radius: 4px;
}
</style>
The DataTransfer object bridges the gap between the drop event and the file input. Files dropped onto the zone are assigned to the hidden input, so FormData picks them up on submission as if the user selected them through the file picker.
How do you show upload progress?
For large files, show progress feedback using XMLHttpRequest. The fetch API does not expose upload progress.
async function submitWithProgress(formId, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(xhr.statusText));
}
});
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
xhr.open('POST', `https://forminit.com/f/${formId}`);
xhr.send(formData);
});
}
// Usage
const progressBar = document.getElementById('progress-bar');
submitWithProgress('YOUR_FORM_ID', formData, (percent) => {
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
});
Framework implementations
Next.js (React)
Install the SDK and create a server-side proxy route to keep your API key out of client code.
Proxy route (app/api/forminit/route.ts):
import { createForminitProxy } from 'forminit/next';
export const { POST } = createForminitProxy({
apiKey: process.env.FORMINIT_API_KEY!,
});
Form component:
'use client';
import { useState, useRef } from 'react';
import { Forminit } from 'forminit';
export function ContactForm() {
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const forminit = new Forminit({ proxyUrl: '/api/forminit' });
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('loading');
setError(null);
const formData = new FormData(e.currentTarget);
const { data, error } = await forminit.submit('YOUR_FORM_ID', formData);
if (error) {
setStatus('error');
setError(error.message);
return;
}
setStatus('success');
formRef.current?.reset();
}
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input type="text" name="fi-sender-fullName" placeholder="Name" required />
<input type="email" name="fi-sender-email" placeholder="Email" required />
<textarea name="fi-text-message" placeholder="Message" required />
<label>Attachment</label>
<input type="file" name="fi-file-attachment" accept=".pdf,.doc,.docx" />
{status === 'error' && <p>{error}</p>}
{status === 'success' && <p>Sent!</p>}
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Sending...' : 'Send'}
</button>
</form>
);
}
Nuxt.js (Vue)
Server route (server/api/forminit.post.ts):
import { createForminitNuxtHandler } from 'forminit/nuxt';
const config = useRuntimeConfig();
export default defineEventHandler(
createForminitNuxtHandler({ apiKey: config.forminitApiKey })
);
Form component:
<script setup lang="ts">
import { ref } from 'vue';
import { Forminit } from 'forminit';
const FORM_ID = 'YOUR_FORM_ID';
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle');
const errorMessage = ref<string | null>(null);
const formRef = ref<HTMLFormElement | null>(null);
const forminit = new Forminit({ proxyUrl: '/api/forminit' });
async function handleSubmit() {
if (!formRef.value) return;
status.value = 'loading';
errorMessage.value = null;
const formData = new FormData(formRef.value);
const { data, error } = await forminit.submit(FORM_ID, formData);
if (error) {
status.value = 'error';
errorMessage.value = error.message;
return;
}
status.value = 'success';
formRef.value.reset();
}
</script>
<template>
<form ref="formRef" @submit.prevent="handleSubmit">
<input type="text" name="fi-sender-fullName" placeholder="Name" required />
<input type="email" name="fi-sender-email" placeholder="Email" required />
<textarea name="fi-text-message" placeholder="Message" required />
<label>Attachment</label>
<input type="file" name="fi-file-attachment" accept=".pdf,.doc,.docx" />
<p v-if="status === 'error'">{{ errorMessage }}</p>
<p v-if="status === 'success'">Sent!</p>
<button type="submit" :disabled="status === 'loading'">
{{ status === 'loading' ? 'Sending...' : 'Send' }}
</button>
</form>
</template>
Astro
Astro’s static pages work with the CDN SDK, same as plain HTML. For SSR mode with Astro’s server endpoints, you can use the Node.js SDK with FormData forwarding.
Static (client-side):
---
// src/pages/contact.astro
---
<form id="contact-form">
<input type="text" name="fi-sender-fullName" placeholder="Name" required />
<input type="email" name="fi-sender-email" placeholder="Email" required />
<textarea name="fi-text-message" placeholder="Message" required></textarea>
<input type="file" name="fi-file-attachment" accept=".pdf,.doc,.docx" />
<button type="submit">Send</button>
</form>
<p id="form-result"></p>
<script is:inline src="https://forminit.com/sdk/v1/forminit.js"></script>
<script is:inline>
const forminit = new Forminit();
document.getElementById('contact-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const { data, error } = await forminit.submit('YOUR_FORM_ID', formData);
if (error) {
document.getElementById('form-result').textContent = error.message;
return;
}
document.getElementById('form-result').textContent = 'Sent!';
e.target.reset();
});
</script>
Can you use multiple file inputs on one form?
You can combine multiple file blocks with different names. Each gets its own field in the submission data.
<input type="file" name="fi-file-resume" accept=".pdf" />
<input type="file" name="fi-file-cover_letter" accept=".pdf,.doc,.docx" />
<input type="file" name="fi-file-portfolio[]" accept="image/*" multiple />
The 25 MB limit applies to the combined total of all files across all inputs in a single submission. Up to 20 file blocks per submission, 30 blocks total (including non-file blocks like sender, text, etc.).
What are common file upload issues?
Files not appearing in submissions: Check that your field names use the fi-file-{name} pattern. Names like resume or file-resume won’t work. It has to be fi-file-resume.
“File upload requires multipart/form-data” error: You’re sending JSON. File uploads only work with FormData. The SDK handles the content type automatically when you pass a FormData object.
Form not submitting files (native HTML): Add enctype="multipart/form-data" to your <form> tag. When using JavaScript with FormData, this is set automatically.
Summary
Adding file uploads to a contact form on a static site or serverless deployment takes a <form>, a file input with the fi-file-{name} naming convention, and a call to forminit.submit() with FormData. No server configuration, no storage setup, no multipart parsing code.
Start with the basic HTML example and add validation, drag-and-drop, or progress indicators as needed. The full file upload reference is in the Forminit file upload documentation.
If you need a contact form that sends email notifications without file uploads, see How to Send an HTML Form to Email Without a Server.