How to Upload a Picture to a Website Using HTML
Learn the correct multipart HTML form, backend handling, JavaScript FormData, validation, security, troubleshooting, and testing for image uploads.
Use a POST form with enctype="multipart/form-data" and an <input type="file">. HTML presents the file picker and sends the selected bytes; your server endpoint must parse the multipart request, validate the file, and store or process it.
<form action="/upload" method="post" enctype="multipart/form-data">
<label for="picture">Choose a picture</label>
<input id="picture" name="picture" type="file" accept="image/jpeg,image/png" required>
<button type="submit">Upload</button>
</form>
This follows MDN’s multipart form guidance: use POST, multipart/form-data, and a file input. The action URL must be a server route; HTML alone cannot persist an upload.
1. Build the HTML upload form
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Upload a picture</title>
</head>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
<label for="picture">Choose a picture</label>
<input id="picture" name="picture" type="file" accept="image/jpeg,image/png,image/webp" required>
<button type="submit">Upload</button>
</form>
</body>
</html>
What each attribute does
| Attribute | Purpose |
|---|---|
method="post" |
Sends file bytes in the request body. |
enctype="multipart/form-data" |
Splits binary data and ordinary fields into multipart parts. |
type="file" |
Opens the local file picker. |
name="picture" |
Names the part your backend reads. |
accept |
Suggests file types; it is not security validation. |
multiple |
Allows several selected files. |
2. Add fields and constraints
<form action="/upload" method="post" enctype="multipart/form-data">
<label for="picture">Picture</label>
<input id="picture" name="picture" type="file" accept="image/*" required>
<label for="caption">Caption (optional)</label>
<input id="caption" name="caption" type="text" maxlength="120">
<button type="submit">Upload</button>
</form>
For several files, add multiple: <input name="pictures" type="file" accept="image/*" multiple>. The browser does not expose a usable local filesystem path; the server receives the file content.
3. Handle the multipart request
The endpoint must authenticate the caller, enforce limits, verify actual bytes, choose a safe filename, and store the result. Keep uploads outside executable paths where your hosting model permits it.
Node.js with Express
npm install express multer
const express = require('express');
const multer = require('multer');
const path = require('node:path');
const crypto = require('node:crypto');
const fs = require('node:fs');
const app = express();
const uploadDir = path.join(__dirname, 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
const upload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 1 }, fileFilter: (req, file, cb) => cb(null, ['image/jpeg','image/png','image/webp'].includes(file.mimetype)) });
app.post('/upload', upload.single('picture'), (req, res) => {
if (!req.file) return res.status(400).send('A supported picture is required');
const ext = path.extname(req.file.originalname).toLowerCase();
const name = crypto.randomUUID() + ext;
fs.renameSync(req.file.path, path.join(uploadDir, name));
res.status(201).json({ filename: name, caption: req.body.caption || '' });
});
app.listen(3000);
Python with Flask
pip install flask
from flask import Flask, request, jsonify
from pathlib import Path
from uuid import uuid4
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024
UPLOAD = Path('uploads'); UPLOAD.mkdir(exist_ok=True)
ALLOWED = {'image/jpeg', 'image/png', 'image/webp'}
@app.post('/upload')
def upload():
picture = request.files.get('picture')
if not picture or picture.mimetype not in ALLOWED:
return {'error': 'A supported picture is required'}, 400
suffix = Path(secure_filename(picture.filename)).suffix.lower()
name = f'{uuid4().hex}{suffix}'
picture.save(UPLOAD / name)
return jsonify(filename=name, caption=request.form.get('caption', '')), 201
app.run(port=3000)
Frameworks differ, but the contract is the same: read the part named picture, then apply server-side policy before storage.
4. Submit with JavaScript and FormData
const form = document.querySelector('form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const response = await fetch(form.action, { method: 'POST', body: new FormData(form) });
if (!response.ok) throw new Error(`Upload failed (${response.status})`);
console.log(await response.json());
});
Do not set Content-Type yourself. The browser adds the multipart boundary. See MDN’s FormData guide.
5. Test with cURL, Python, and Node.js
cURL
curl -i -X POST http://localhost:3000/upload -F "picture=@./photo.jpg;type=image/jpeg" -F "caption=Profile photo"
Python
import requests
with open('photo.jpg', 'rb') as image:
response = requests.post('http://localhost:3000/upload', files={'picture': ('photo.jpg', image, 'image/jpeg')}, data={'caption': 'Profile photo'}, timeout=30)
response.raise_for_status()
print(response.json())
Node.js
const fs = require('node:fs');
const FormData = require('form-data');
const form = new FormData();
form.append('picture', fs.createReadStream('./photo.jpg'), { filename: 'photo.jpg', contentType: 'image/jpeg' });
const response = await fetch('http://localhost:3000/upload', { method: 'POST', body: form, headers: form.getHeaders() });
console.log(response.status, await response.text());
6. Multiple files, previews, and progress
Name a multiple-file control pictures and iterate over every part server-side. For previews, use URL.createObjectURL(file) and revoke the URL when finished. Use XMLHttpRequest.upload.onprogress when percentage progress is required; keep server limits authoritative.
7. Security checklist
- Require authentication and authorization.
- Set request, file-count, and per-file size limits at the proxy and application.
- Verify magic bytes and decode the image; do not trust
accept, filename, or client MIME type. - Generate random storage names and reject unexpected formats.
- Store outside executable directories.
- Rate-limit abuse, log outcomes, and define retention rules.
- Use TLS and CSRF protection for cookie-authenticated forms.
8. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Only a filename arrives | Missing multipart encoding | Add enctype="multipart/form-data" and multipart middleware. |
request.files is empty |
Field mismatch | Match the input name to the backend key. |
| 400 or 415 | Rejected type or malformed body | Send an allowed type and let the client set the boundary. |
| 413 Payload Too Large | Proxy or app limit | Adjust limits deliberately or resize before upload. |
| Works locally only | Unwritable or ephemeral disk | Use durable object storage and correct permissions. |
| JavaScript fails | Manual Content-Type header | Remove it and pass FormData directly. |
9. Performance, reliability, and cost
Resize images before transfer when originals are unnecessary. Stream multipart data where supported, set timeouts, and make retries safe with an idempotency key or upload token. At high volume, direct-to-object-storage uploads reduce application bandwidth; issue scoped credentials and validate completed objects. Budget for hosting, bandwidth, processing, and storage, then set quotas and lifecycle deletion rules.
Or skip the browser setup
If you need to capture how an upload page looks after deployment, ScreenshotNeo returns a screenshot or PDF from one GET request. See the API documentation.
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)
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}`);
Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
FAQ
Can HTML upload directly to a folder?
No. It submits bytes to an endpoint; server code or storage must save them.
Is accept="image/*" validation?
No. It is only a picker hint.
Why use POST instead of GET?
POST carries multipart bytes in the request body; GET is intended for retrieval and exposes values in the URL.
Can I upload without JavaScript?
Yes. JavaScript is optional for previews, progress, or inline responses.


