How to Upload Images in a Website: Practical Examples
Learn the complete image-upload flow: HTML forms, previews, JavaScript, server validation, secure storage, multiple files, and production troubleshooting.
Use an HTML file input inside a form encoded as multipart/form-data, send it to a server endpoint, validate the bytes on the server, store it with a generated name, and return an authorized URL or identifier. JavaScript is optional: add it for previews, asynchronous uploads, progress, multiple files, or drag and drop.
This guide covers the complete path from selecting an image to serving it safely in production.
1. The smallest working upload
A conventional browser upload needs three things:
- An
<input type="file">control. - A form with
method="post". enctype="multipart/form-data", which sends the file as its own multipart section.
<form action="/upload" method="post" enctype="multipart/form-data">
<label for="image">Choose an image</label>
<input id="image" name="image" type="file" accept="image/*" required>
<button type="submit">Upload</button>
</form>
MDN documents this form pattern and explains why multipart encoding is required: the request is split into parts for files and other fields. The MDN form guide is a useful reference. The multipart format is specified by RFC 1867.
accept="image/*" filters the file picker. It does not secure the endpoint. A client can send any bytes with any filename or MIME type, so the server must perform the authoritative checks.
2. Add a local preview before uploading
The browser exposes the selected file as a File object. An object URL lets you preview it locally without sending it to the server first.
<label for="image">Choose an image</label>
<input id="image" type="file" accept="image/*">
<img id="preview" alt="Selected image preview" hidden>
<script>
const input = document.querySelector('#image');
const preview = document.querySelector('#preview');
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Choose an image file.');
input.value = '';
return;
}
preview.src = URL.createObjectURL(file);
preview.hidden = false;
});
</script>
Release the previous object URL when replacing previews in a long-lived page:
let previewUrl;
input.addEventListener('change', () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
const file = input.files[0];
if (!file) return;
previewUrl = URL.createObjectURL(file);
preview.src = previewUrl;
});
3. Upload asynchronously with JavaScript
Use FormData with fetch when you do not need a progress bar.
<input id="image" type="file" accept="image/*">
<button id="send" type="button">Upload</button>
<p id="status" role="status"></p>
<script>
const input = document.querySelector('#image');
const button = document.querySelector('#send');
const status = document.querySelector('#status');
button.addEventListener('click', async () => {
const file = input.files[0];
if (!file) {
status.textContent = 'Choose an image first.';
return;
}
const form = new FormData();
form.append('image', file, file.name);
button.disabled = true;
status.textContent = 'Uploading…';
try {
const response = await fetch('/upload', {
method: 'POST',
body: form,
credentials: 'same-origin'
});
if (!response.ok) throw new Error(`Upload failed (${response.status})`);
const result = await response.json();
status.textContent = `Uploaded: ${result.url}`;
} catch (error) {
status.textContent = error.message;
} finally {
button.disabled = false;
}
});
</script>
Do not set the Content-Type header yourself when sending FormData. The browser adds the multipart boundary. Setting only Content-Type: multipart/form-data manually commonly produces a request the server cannot parse.
Upload progress with XMLHttpRequest
fetch does not provide a broadly supported upload-progress event. Use XMLHttpRequest when the interface must show progress. MDN’s XMLHttpRequest guide covers the progress event.
<input id="image" type="file" accept="image/*">
<button id="send" type="button">Upload</button>
<progress id="progress" value="0" max="100" hidden></progress>
<p id="status" role="status"></p>
<script>
const input = document.querySelector('#image');
const button = document.querySelector('#send');
const progress = document.querySelector('#progress');
const status = document.querySelector('#status');
button.addEventListener('click', () => {
const file = input.files[0];
if (!file) return;
const form = new FormData();
form.append('image', file, file.name);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
progress.hidden = false;
button.disabled = true;
xhr.upload.addEventListener('progress', event => {
if (event.lengthComputable) {
progress.value = event.loaded / event.total * 100;
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
status.textContent = 'Upload complete';
} else {
status.textContent = `Upload failed (${xhr.status})`;
}
});
xhr.addEventListener('error', () => {
status.textContent = 'Network error while uploading';
});
xhr.addEventListener('loadend', () => {
button.disabled = false;
});
xhr.send(form);
});
</script>
4. Upload multiple images
Use the multiple attribute and repeat the same field name for each file.
<form action="/upload" method="post" enctype="multipart/form-data">
<input name="images" type="file" accept="image/*" multiple>
<button type="submit">Upload images</button>
</form>
With JavaScript:
const form = new FormData();
for (const file of input.files) {
form.append('images', file, file.name);
}
await fetch('/upload', { method: 'POST', body: form });
Decide whether one invalid file rejects the entire batch or whether valid files continue. Return per-file results so the client can show exactly what succeeded.
5. What the server receives
A multipart request contains a boundary and one part per field. A file part commonly resembles this:
--boundary123
Content-Disposition: form-data; name="image"; filename="photo.jpg"
Content-Type: image/jpeg
(binary image bytes)
--boundary123--
The filename and media type are supplied by the client. Use the field name to select the upload, but never use the original filename as a storage path.
6. Server-side validation checklist
Process every upload through a server-side pipeline:
- Authenticate and authorize. Confirm that the user may upload to the target account, project, or record. Apply CSRF protection to cookie-authenticated browser forms.
- Limit request and file size. Configure the web server, framework, reverse proxy, and application limits. Reject oversized requests before expensive image processing.
- Require an image you support. Check the detected file signature and decode the bytes with a trusted imaging library. Do not rely on the extension or browser-supplied
Content-Type. - Constrain dimensions and processing work. An image with extreme dimensions can consume large amounts of memory even when its compressed file is small.
- Generate a storage key. Use a random identifier or server-generated path. Preserve the original name only as display metadata.
- Store metadata separately. Record owner, storage key, detected media type, dimensions, byte size, creation time, and processing status.
- Keep files out of executable paths. Serve them through a static file host or a controlled download endpoint with the correct authorization.
- Return an identifier or authorized URL. Do not expose an internal filesystem path.
Microsoft’s file-upload guidance warns that accepting uploads requires security measures and discusses physical storage and database-backed retrieval options. See Microsoft Learn’s file-upload documentation.
Minimal endpoint contract
A useful response separates upload status from the public representation:
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "img_01J...",
"url": "https://cdn.example.test/media/img_01J....webp",
"mediaType": "image/jpeg",
"width": 1600,
"height": 900,
"bytes": 248391
}
For asynchronous virus scanning, moderation, or image transformation, return a processing status and let the client poll or receive a webhook. Do not claim an image is ready until the stored object passed the required checks.
7. Storage options
| Option | Good fit | Trade-offs |
|---|---|---|
| Application-managed directory | Small single-server applications | Backups, shared access, and deployments need careful handling |
| Object storage | Distributed production systems and large media collections | Requires bucket policy, credentials, lifecycle rules, and upload/download integration |
| Database binary column | Small files tightly coupled to transactional records | Database growth, backup size, and delivery performance can become concerns |
| Database metadata plus object storage | Most production media systems | Two systems must remain consistent; use status fields and cleanup jobs |
Choose based on durability, access control, latency, transformations, backup strategy, and cost. An image CDN or delivery layer can add caching and resizing while the database retains ownership and metadata.
8. Direct browser-to-object-storage uploads
For large files or high traffic, your application can authorize an upload and issue a short-lived signed upload URL. The browser then uploads directly to object storage, while your server records the resulting key. This reduces application bandwidth, but the server must still validate the completed object and enforce ownership.
- Client asks your server to start an upload.
- Server authenticates the user and returns a constrained, short-lived upload URL.
- Browser sends the file to storage.
- Server verifies the object, records metadata, and marks it available.
9. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Request has no file | Missing enctype, wrong field name, or manually set multipart header |
Use enctype="multipart/form-data", match the server field name, and let the browser set the boundary |
| 413 Request Entity Too Large | Proxy, web server, or framework limit | Raise aligned limits deliberately or show a clear maximum-size message |
| 415 Unsupported Media Type | Server rejected the detected format | Convert to a supported format or update the allowlist; inspect bytes rather than only the extension |
| Preview is blank | No file selected, object URL revoked too early, or invalid image | Check input.files[0], keep the URL until replacement, and verify decoding |
| CORS error | Upload endpoint is on another origin without permission | Configure narrowly scoped CORS and credentials rules, or proxy through the site origin |
| Upload succeeds but image is not visible | Wrong URL, private storage, missing read permission, or delayed processing | Return a retrieval URL only after storage and processing status are ready |
| Duplicate names overwrite files | Original filename used as the key | Generate a unique server-side key |
| Memory or timeout failures | Large dimensions, slow storage, or synchronous transformations | Limit dimensions, stream where supported, process asynchronously, and set realistic timeouts |
| Users upload executable content | Extension-only validation or executable upload directory | Decode with an image library, allow only supported formats, and store outside executable paths |
10. Performance and reliability
- Show a local preview immediately while the upload runs.
- Disable the submit button or use an idempotency key to prevent accidental duplicate uploads.
- Upload several files concurrently only within a controlled limit; unlimited parallel requests can overload the browser and server.
- Use resumable or direct-to-object-storage uploads for large files.
- Generate thumbnails asynchronously when the original does not need to wait.
- Keep upload, processing, and publication states separate so retries are safe.
- Retry transient network or storage failures with bounded exponential backoff. Do not retry validation failures.
- Use content hashes or idempotency keys when deduplicating is useful.
- Cache immutable image URLs with a generated version or key. Replace an image by writing a new key rather than changing bytes behind a long-lived URL.
11. Cost and operational decisions
The main cost drivers are stored bytes, requests, outbound bandwidth, image transformations, backups, and scanning or moderation work. Keep originals only when the application needs them, define lifecycle rules for abandoned uploads, and generate delivery sizes on demand or during processing according to access patterns.
Measure upload failure rate, processing latency, storage growth, rejected bytes, and download traffic. Logs should include a request or upload identifier, user or account identifier, outcome, detected type, size, and processing duration without logging sensitive image contents.
12. Or skip the browser setup
If your workflow needs screenshots of pages that contain uploaded images, ScreenshotNeo provides a one-call website screenshot API. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the verdict and billing status in headers. It also offers an MCP server for AI agents and supports PNG, JPEG, WebP, and PDF output.
See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
There is a free plan with 1,000 screenshots each month and no card. Paid plans start at $5 for 3,000 screenshots. Create a free ScreenshotNeo account.
13. FAQ
Can I upload an image with only HTML?
Yes. The form can submit directly to a server endpoint. JavaScript is needed only for client-side previews, progress, asynchronous behavior, or richer validation feedback.
Should images be stored in the database?
Store binaries in a database only when that fits the size and access pattern. Many production systems store the binary in object storage and keep ownership and metadata in a database.
Is the accept attribute secure?
No. It affects the picker UI. Validate the actual bytes and authorization on the server.
How do I prevent users from seeing another user’s uploads?
Authorize every retrieval using the stored owner or account identifier. Use private storage with signed, short-lived URLs when files are not public.
How do I support drag and drop?
Handle dragover and drop, read event.dataTransfer.files, assign or process those files, and send them through the same FormData and server validation pipeline.


