Creating a file upload form in HTML: a developer's tutorial
An HTML file upload needs a named file input inside a form with method="post" and
enctype="multipart/form-data", plus a server that handles the form’s action. This walkthrough
connects those pieces: you will submit one or several files without browser JavaScript and see the
receiver report their names, byte counts, and SHA-256 hashes.
Setting up a basic file upload form in HTML
You need Node.js 24 or later, a browser, and a POSIX shell such as Bash on Linux, macOS, or WSL. The receiver uses Node’s built-in APIs, so there are no packages to install. Node can run this TypeScript directly using type stripping. The examples were tested on Linux with Node.js 24.15.0, 26.5.0, and 26.8.1, and Chromium 145 and 152.
From a directory where you keep experiments, run:
mkdir html-upload-demo &&
cd html-upload-demo &&
touch index.html server.ts
This refuses an existing directory. If it fails, stop before following the remaining steps; choose a new directory name or resolve the error. Paste the next two examples into the files just created. The server only inspects uploads in memory. It does not create or overwrite uploaded files, and submitting the same file again simply produces another receipt.
Save this complete page as index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HTML file upload demo</title>
</head>
<body>
<main>
<h1>Upload files</h1>
<form action="/upload" method="post" enctype="multipart/form-data">
<p>
<label for="file-upload">Choose files (required)</label>
<input
type="file"
id="file-upload"
name="files"
multiple
required
aria-describedby="file-help"
/>
</p>
<p id="file-help">Choose up to three files, at most 1 MiB each. Nothing is saved.</p>
<button type="submit">Upload files</button>
</form>
</main>
</body>
</html>
Each form attribute has a separate job:
| Attribute | What it does here |
|---|---|
action="/upload" | Sends the submission to the /upload route on the server serving this page. |
method="post" | Sends the form data in the HTTP request body. |
enctype="multipart/form-data" | Encodes file contents as separate parts in that body. |
name="files" | Names each uploaded part so the receiver can retrieve it. |
id="file-upload" | Connects the input to its label; it does not name the submitted field. |
multiple | Lets the picker select more than one file. |
required | Makes the browser ask for a selection before submitting. |
The browser builds the multipart boundary and request headers for you. MDN’s
guide to sending form data
explains the encoding. An input without a name is omitted from the submitted data, even if it has
an id and shows a selected filename; see the HTML standard’s
form entry rules.
Add a local multipart receiver
Save this as server.ts. It serves the page and accepts up to three files of 1 MiB each. A separate
4 MiB limit caps the buffered request body, including multipart headers, before parsing. These are
small demonstration limits; buffering and parsing also require memory beyond the raw body size.
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
const MAX_FILES = 3
const MAX_FILE_BYTES = 1024 * 1024
const MAX_REQUEST_BYTES = 4 * 1024 * 1024
const page = await readFile(new URL('./index.html', import.meta.url))
function reply(response: ServerResponse, status: number, message: string): void {
response.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
})
response.end(`${message}\n\nUse Back to choose files again. Nothing was saved.\n`)
}
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.method === 'GET' && request.url === '/') {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(page)
return
}
if (request.method === 'GET' && request.url === '/favicon.ico') {
response.writeHead(204).end()
return
}
if (request.method !== 'POST' || request.url !== '/upload') {
reply(response, 404, 'Route not found. Open the URL printed in the terminal.')
return
}
const contentType = request.headers['content-type'] ?? ''
if (!contentType.toLowerCase().startsWith('multipart/form-data;')) {
request.resume()
reply(response, 415, 'Expected multipart/form-data. Check the form enctype.')
return
}
const chunks: Buffer[] = []
let bytes = 0
// Keep the connection alive long enough to return readable size-limit feedback.
for await (const chunk of request.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > MAX_REQUEST_BYTES) {
request.resume()
reply(response, 413, 'Request exceeds 4 MiB. Choose smaller files.')
return
}
chunks.push(chunk)
}
let form: FormData
try {
form = await new Request('http://localhost/upload', {
method: 'POST',
headers: { 'Content-Type': contentType },
body: Buffer.concat(chunks),
}).formData()
} catch {
reply(response, 400, 'Could not read the multipart form. Check its encoding.')
return
}
const files = form.getAll('files')
if (files.length === 0) {
reply(response, 400, 'Choose at least one file in the files field.')
return
}
if (files.length > MAX_FILES) {
reply(response, 400, 'Choose at most three files.')
return
}
const receipts = []
for (const file of files) {
if (!(file instanceof File) || file.name === '') {
reply(response, 400, 'Choose at least one file in the files field.')
return
}
if (file.size > MAX_FILE_BYTES) {
reply(response, 413, 'Each file must be at most 1 MiB. Choose smaller files.')
return
}
receipts.push({
field: 'files',
name: file.name,
bytes: file.size,
sha256: createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex'),
})
}
reply(response, 200, `Received ${files.length} file(s).\n${JSON.stringify(receipts, null, 2)}`)
}
const server = createServer((request, response) => {
void handle(request, response).catch(() => {
console.error('Upload handler failed.')
if (!response.headersSent) reply(response, 500, 'Could not process the upload. Try again.')
else response.destroy()
})
})
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (address && typeof address !== 'string') {
console.log(`Open http://127.0.0.1:${address.port}/`)
}
})
The built-in Request API
parses the collected body with
formData().
Constructing this object does not send another network request. The receiver uses
getAll('files')
to collect every part with that name. get('files') would return only the first.
The stream’s
destroyOnReturn: false option
lets the receiver leave the read loop without destroying the connection when the body is too large.
It discards the remaining input and returns an HTTP 413 response.
Handling single and multiple file uploads
In the same html-upload-demo directory, start the receiver:
node server.ts
Open the exact URL printed in the terminal. The server chooses a free port and listens only on
127.0.0.1. Opening index.html directly as a local file will not connect its /upload action to
this receiver. Stop the server with Ctrl+C when finished; restart it after editing either file.
Disable JavaScript in the browser, reload, and choose one small file. Click Upload files.
The browser navigates to /upload and displays Received 1 file(s)., followed by a receipt with
field, name, bytes, and sha256. That response confirms that the server accepted and inspected
the bytes. A filename appearing in the picker only confirms selection.
Use Back, choose two files together, and submit again. You should see Received 2 file(s). and two
receipts. Try filenames with spaces or non-ASCII characters. To compare a receipt against your local
file, run this from the demo directory, replacing the path after -- with your file’s path:
node --input-type=module -e 'import { readFile } from "node:fs/promises"; import { createHash } from "node:crypto"; const bytes = await readFile(process.argv[1]); console.log(bytes.length, createHash("sha256").update(bytes).digest("hex"))' -- '/path/to/your file.txt'
Both the byte count and hash should match. This command only reads the file. A deliberately selected empty file is valid and reports zero bytes; an empty picker is a different case.
multiple changes how many files the user can select, not the field’s name. Each selected file is
sent under files. HTML does not require the bracket convention files[]; this receiver expects the
literal key files. If another back end expects files[], both sides must use that exact name.
For a picker that allows only one file, omit multiple. That changes the browser control only;
enforce a one-file rule on the server too if your application requires it.
Adding client-side validation
With no selection, Upload files triggers the browser’s required-field feedback and keeps you on the form. The receiver also rejects an empty upload with HTTP 400 because clients can bypass browser validation. HTML has no file-size attribute that enforces the 1 MiB limit, so oversized selections reach the receiver and get an error page.
This demo accepts any file type and only inspects its bytes. To guide an image or document picker,
you can add accept=".jpg,.jpeg,.png,.pdf" to the input. As MDN’s
file input documentation
explains, accept is a picker hint, not content validation. It does not add type checks to this
receiver. A filename, extension, or supplied MIME type cannot establish that a file is safe.
Try these failure cases before adapting the example:
| Submission | Expected result |
|---|---|
| No selected file | Browser asks for a file; a direct empty request gets HTTP 400. |
| Four small files | HTTP 400: “Choose at most three files.” |
| One file over 1 MiB | HTTP 413 with size-limit feedback. |
| Total request over 4 MiB | HTTP 413 before multipart parsing. |
A file field named file or files[] | HTTP 400 because the receiver cannot find files. |
After an error, use Back and choose a valid selection. A successful response applies to that whole submission. Nothing is saved from either a successful or rejected request.
Customizing the file upload button
You can style the native button while keeping its label, selected-file display, and keyboard
behavior. Add this inside index.html’s <head>, then restart the server:
<style>
input[type='file']::file-selector-button {
font: inherit;
padding: 0.5rem 0.75rem;
margin-inline-end: 0.75rem;
cursor: pointer;
}
</style>
The ::file-selector-button pseudo-element
targets the button inside the input. Tab to the labeled picker and press Space to open it, then
Tab to Upload files and press Enter to submit. Keep the input visible and its focus indication
intact. Its name="files" and position inside the form still connect the selection to the receiver.
Security considerations
Keep this receiver local. It accepts arbitrary contents, uses memory per request, and provides no login, durable storage, or malware scanning. Filenames are displayed as JSON in a plain-text response and are never used as filesystem paths. Hashing confirms which bytes arrived; it does not validate their format or safety.
When adding storage to an authenticated application, define authorization, request limits, content validation, and CSRF protection at the server boundary. The separate secure AJAX upload guide covers that security-focused task.
Add progress or drag-and-drop when needed
For an interface that stays on the page and shows upload progress, continue with the custom JavaScript uploader. It covers drag-and-drop, retries, and progress with a compatible receiver. If your application already uses Bootstrap, see the Bootstrap file upload tutorial for that styling approach.
