Implementing file uploads with Bootstrap 5
Use Bootstrap’s file input, button, alert, and progress styles to build a single-file upload form. This example accepts a JPEG, PNG, or PDF up to 5 MiB, prevents a second submission while an upload is pending, and waits for the server’s response before reporting acceptance.
Bootstrap supplies the presentation. JavaScript handles selection and transfer, and a small Node.js
receiver checks the request. You will create three files: public/index.html, public/upload.js,
and server.ts. The receiver reports the file’s size and SHA-256 checksum, then discards it; it does
not save uploads.
Setting up a Bootstrap 5 environment
Use Node.js 26 and a current browser. This example was tested with Node.js 26.8.1 and 26.5.0, and Chromium 152 on Linux. It uses Bootstrap 5.3.8 CSS from its official quick start, including the matching integrity hash. These components do not need Bootstrap’s JavaScript bundle. Internet access is required to load the stylesheet; the server needs no third-party packages.
In a Bash-compatible terminal, create a fresh project:
mkdir bootstrap-upload &&
cd bootstrap-upload &&
printf '%s\n' '{"type":"module"}' > package.json &&
mkdir public
The generated package.json enables ES modules for server.ts. The chain stops if the directory
already exists or navigation fails. Choose a different directory
name in that case; do not overwrite an existing project. Create the three files below in this new
project before starting the server.
Creating a simple file upload form
Save this complete document as public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bootstrap file upload</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
crossorigin="anonymous"
/>
<script src="/upload.js" defer></script>
</head>
<body>
<main class="container py-4">
<div class="row justify-content-center">
<section class="col-12 col-md-8 col-lg-6" aria-labelledby="title">
<h1 id="title" class="h3">Upload a file</h1>
<p>This local demo checks your upload and discards it.</p>
<form id="uploadForm" aria-label="File upload" action="/upload"
method="post" enctype="multipart/form-data" novalidate>
<fieldset id="controls">
<legend class="visually-hidden">Select and upload one file</legend>
<div id="dropZone" class="border rounded p-3 mb-3">
<label for="formFile" class="form-label">Choose a file</label>
<input id="formFile" name="file" class="form-control" type="file"
accept="image/jpeg,image/png,application/pdf" required
aria-describedby="fileHelp errorMessage" />
<p id="fileHelp" class="form-text mb-0">
Drop one file here or use the file picker. JPEG, PNG, or PDF;
nonempty files up to 5 MiB (5,242,880 bytes).
</p>
</div>
<div class="d-grid d-sm-flex mb-3">
<button class="btn btn-primary" type="submit">Upload</button>
</div>
</fieldset>
<div id="errorMessage" class="alert alert-danger" role="alert" hidden></div>
<div id="progress" class="progress mb-2" role="progressbar"
aria-label="Upload transfer" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="0" hidden>
<div id="progressFill" class="progress-bar"></div>
</div>
<p id="status" class="text-break" role="status" aria-atomic="true">
No file selected.
</p>
</form>
</section>
</div>
</main>
</body>
</html>
The visible, labeled Bootstrap file input
keeps the browser’s keyboard-operated picker. Dropping is an additional way to select a file. The
column fills a small screen and narrows on larger screens; d-grid d-sm-flex makes the Upload button
full-width on phones.
A red border alone does not explain an error. The script pairs .is-invalid with aria-invalid
and a visible alert referenced by aria-describedby. The form uses novalidate so that these
messages handle submission consistently. Bootstrap’s
validation documentation
warns against relying on its custom validation styles and tooltips alone for accessibility.
Add drag-and-drop, validation, and progress
Save the entire script as public/upload.js. Selection and submission share the same checks.
An invalid selection clears the input so that choosing the same file again triggers a fresh check.
During a request, the fieldset is disabled and the event handlers reject further submissions and
drops. The selected file remains available for a retry after a failed request.
const form = document.getElementById('uploadForm')
const controls = document.getElementById('controls')
const input = document.getElementById('formFile')
const dropZone = document.getElementById('dropZone')
const errorMessage = document.getElementById('errorMessage')
const status = document.getElementById('status')
const progress = document.getElementById('progress')
const progressFill = document.getElementById('progressFill')
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const maxSize = 5 * 1024 * 1024
let busy = false
let activeFiles = null
function clearFeedback() {
errorMessage.hidden = true
errorMessage.textContent = ''
input.classList.remove('is-invalid')
input.removeAttribute('aria-invalid')
status.textContent = ''
progress.hidden = true
}
function showError(message, invalid = false) {
errorMessage.textContent = message
errorMessage.hidden = false
input.classList.toggle('is-invalid', invalid)
if (invalid) input.setAttribute('aria-invalid', 'true')
}
function validateSelection() {
clearFeedback()
const file = input.files[0]
let message = ''
if (input.files.length !== 1) message = 'Please select exactly one file.'
else if (!allowedTypes.includes(file.type)) message = 'Choose a JPEG, PNG, or PDF file.'
else if (file.size === 0 || file.size > maxSize) {
message = 'Choose a nonempty file no larger than 5 MiB.'
}
if (message) {
input.value = ''
showError(message, true)
return false
}
status.textContent = `Selected: ${file.name}`
return true
}
input.addEventListener('change', () => {
if (busy) {
input.files = activeFiles
return
}
validateSelection()
})
dropZone.addEventListener('dragover', (event) => {
event.preventDefault()
if (!busy) dropZone.classList.add('border-primary', 'bg-body-tertiary')
})
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-primary', 'bg-body-tertiary')
})
dropZone.addEventListener('drop', (event) => {
event.preventDefault()
dropZone.classList.remove('border-primary', 'bg-body-tertiary')
if (busy) return
const files = event.dataTransfer?.files
if (!files || files.length !== 1) {
clearFeedback()
input.value = ''
showError('Please drop exactly one file.', true)
return
}
input.files = files
validateSelection()
})
function setProgress(percent) {
progress.setAttribute('aria-valuenow', String(percent))
progressFill.style.width = `${percent}%`
}
form.addEventListener('submit', (event) => {
event.preventDefault()
if (busy) return
if (!validateSelection()) {
input.focus()
return
}
// Disabled controls are omitted from FormData, so capture the body first.
const body = new FormData(form)
const file = input.files[0]
activeFiles = input.files
busy = true
controls.disabled = true
progress.hidden = false
setProgress(0)
status.textContent = 'Uploading…'
const xhr = new XMLHttpRequest()
function finish(message, failed) {
busy = false
controls.disabled = false
activeFiles = null
if (failed) {
progress.hidden = true
status.textContent = ''
showError(message)
return
}
setProgress(100)
status.textContent = message
form.reset()
}
xhr.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable || event.total === 0) {
progress.removeAttribute('aria-valuenow')
status.textContent = 'Uploading; transfer size unknown…'
return
}
const percent = Math.floor((event.loaded / event.total) * 100)
setProgress(percent)
status.textContent = `${percent}% transferred. Waiting for the server…`
})
xhr.upload.addEventListener('load', () => {
setProgress(100)
status.textContent = '100% transferred. Waiting for the server…'
})
xhr.addEventListener('load', () => {
const reply = xhr.response
if (xhr.status !== 200) {
const message = xhr.status === 413
? 'The server rejected the upload size.'
: `The server rejected the upload (HTTP ${xhr.status}).`
finish(message, true)
return
}
if (!reply || reply.bytes !== file.size || typeof reply.sha256 !== 'string'
|| !/^[a-f0-9]{64}$/.test(reply.sha256)) {
finish('The server returned an unexpected receipt. Acceptance is unconfirmed.', true)
return
}
finish(`Accepted: ${file.name} (${reply.bytes.toLocaleString()} bytes). `
+ `SHA-256: ${reply.sha256}. The demo did not save the file.`, false)
})
xhr.addEventListener('error', () => {
finish('Network error. Acceptance is unconfirmed; you can retry.', true)
})
xhr.addEventListener('timeout', () => {
finish('No response within 30 seconds. Acceptance is unconfirmed; you can retry.', true)
})
xhr.open('POST', form.action)
xhr.responseType = 'json'
xhr.timeout = 30_000
xhr.send(body)
})
XMLHttpRequest.upload
provides transfer progress. Reaching 100% means the request body was sent, not that the server
accepted the file. The labeled Bootstrap progress bar
exposes the numeric value, while the separate status text explains that distinction. Small local
uploads may jump straight to 100%.
Leave the multipart Content-Type header to the browser: it includes the boundary needed to parse
FormData.
The body is captured before disabling the controls because disabled fields are excluded. A
30-second timeout
releases the form if no response arrives. A timeout or connection failure cannot prove whether a
server already processed the request. This discard-only demo is safe to retry; a storage service
would need its own retry policy.
Handling file uploads on the server side
Save this as server.ts beside public/. It serves only the two public files, caps the buffered
request, and parses multipart data with Node’s
built-in web APIs. The extra 64 KiB allows room for
multipart headers; the file itself still has a 5 MiB limit. The checksum lets you compare the
received bytes with the original file independently.
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
const maxSize = 5 * 1024 * 1024
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
const publicFiles = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/upload.js', ['upload.js', 'text/javascript; charset=utf-8']],
])
const server = createServer(async (req, res) => {
function reply(status: number, data: object): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(data))
}
try {
const asset = publicFiles.get(req.url ?? '')
if (req.method === 'GET' && asset) {
const bytes = await readFile(new URL(`./public/${asset[0]}`, import.meta.url))
res.writeHead(200, { 'Content-Type': asset[1] })
res.end(bytes)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
reply(404, { error: 'Not found.' })
return
}
const chunks: Buffer[] = []
let size = 0
for await (const chunk of req) {
size += chunk.length
if (size > maxSize + 64 * 1024) {
reply(413, { error: 'Request too large.' })
return
}
chunks.push(chunk)
}
const body = new Response(Buffer.concat(chunks), {
headers: { 'Content-Type': req.headers['content-type'] ?? '' },
})
const data = await body.formData().catch(() => null)
const file = data?.get('file')
if (!data || [...data].length !== 1 || !(file instanceof File)) {
reply(400, { error: 'Send exactly one file field named file.' })
return
}
if (!allowedTypes.includes(file.type)) {
reply(415, { error: 'Unsupported declared file type.' })
return
}
if (file.size === 0 || file.size > maxSize) {
reply(413, { error: 'File must be nonempty and at most 5 MiB.' })
return
}
const sha256 = createHash('sha256')
.update(Buffer.from(await file.arrayBuffer()))
.digest('hex')
reply(200, { bytes: file.size, sha256 })
} catch {
reply(500, { error: 'Unable to handle the request.' })
}
})
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}`)
}
})
From inside bootstrap-upload, start it with:
node server.ts
Open the printed URL, which uses an available port. Do not open index.html directly: the form and
receiver must share the server’s origin. Stop the server with Ctrl+C. Uploading the same file again
does not replace anything on disk because no uploaded file is retained.
Try the form
Select a small PDF with the keyboard, then activate Upload. The result should say “Accepted” and show the received byte count and SHA-256, followed by “The demo did not save the file.” You can also drop a file inside the bordered group. Resize the window to check the stacked mobile layout.
Try an empty file, a text file, two dropped files, and a file larger than 5 MiB. Each should produce
a visible explanation without sending a request. An empty or unexpected MIME label is also rejected,
even if the filename ends in .pdf. While uploading, the chooser and Upload button
remain disabled. After a failure, activate Upload to retry the same selection. After success, select
a file again to start another upload.
Browser developer tools can throttle the connection to make progress easier to observe. Stopping the receiver produces a network error; a receiver that never replies produces the timeout message. Neither should display acceptance. If the page looks unstyled, check the stylesheet request and integrity error in the browser console.
Security considerations
The accept attribute and JavaScript checks help users choose a file. They are not a security
boundary. A browser’s File.type
and a multipart file’s MIME type describe client-supplied metadata; the receiver does not inspect
whether the bytes are really an image or PDF. Its checksum reports what arrived, not whether the
content is safe.
This receiver binds to loopback and buffers requests in memory for local testing. Do not deploy it as an upload service. An application that retains files needs authenticated and authorized access, request and concurrency limits, content inspection, and a deliberate retry policy. Keep uploaded files outside public static directories, and apply any required scanning before making them available. Those storage decisions do not change the Bootstrap form’s distinction between sending a body and receiving server acceptance.
