ScreenshotNeo

BlogHow-to

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.

By the ScreenshotNeo team1 October 20266 min read

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.