How to Build a Data Capture Web Application
Build a secure, accessible data capture web app with clear fields, server validation, storage, uploads, and a practical Node.js example.
A data capture web application collects information through a browser, validates it on the server, stores it with controlled access, and gives people a clear way to review, correct, or delete their submissions. Start by defining why every field exists and who needs the result. Then build an accessible form, treat every request as untrusted, validate again on the server, and choose storage, retention, authentication, and hosting to match the data’s sensitivity.
This guide uses a small Node.js application with an HTML form and a JSON file so you can run the example without a database service. Replace the file store with a database for production, keep secrets on the server, and adapt the validation rules to your domain.
1. Define the data and its lifecycle
Before writing code, document:
- The task the form enables.
- Each field that is necessary for that task.
- Who may read, edit, export, or delete submissions.
- Where data is processed and stored.
- How long it is needed and how a person can correct or delete it.
- Whether the data includes sensitive, regulated, or location-specific information.
Collect only what the process requires. W3C’s Forms Tutorial recommends asking only for necessary data and explains labels, grouping, instructions, validation, status messages, and multi-page forms: W3C Forms Tutorial. MDN’s privacy guidance covers minimisation, transparent use, user control, and secure transmission and storage: MDN Privacy on the web.
2. Create an accessible HTML form
Use native controls first. Give every control a visible <label>, group related questions with <fieldset> and <legend>, identify required fields in text and markup, and place instructions and errors next to the relevant control. For long workflows, split the form into logical stages and show progress.
<form action="/submissions" method="post" enctype="multipart/form-data" novalidate>
<fieldset>
<legend>Contact details</legend>
<label for="name">Name (required)</label>
<input id="name" name="name" autocomplete="name" required maxlength="100" />
<label for="email">Email (required)</label>
<input id="email" name="email" type="email" autocomplete="email" required maxlength="320" />
</fieldset>
<label for="purpose">What do you need?</label>
<select id="purpose" name="purpose" required>
<option value="">Choose one</option>
<option value="feedback">Feedback</option>
<option value="support">Support request</option>
</select>
<label for="details">Details (required)</label>
<textarea id="details" name="details" required minlength="10" maxlength="5000"></textarea>
<p>We use these details to respond to your request.</p>
<button type="submit">Send submission</button>
<p id="status" role="status" aria-live="polite"></p>
</form>
Do not block password-manager autofill or copy and paste in login and verification fields. W3C explains why those functions are part of accessible authentication: Accessible Authentication (Minimum).
3. Build a minimal Node.js capture endpoint
Create a directory, run npm init -y, install express, and save this as server.js. The example writes validated records to data/submissions.json. It is intentionally simple; use a database, access controls, backups, and retention jobs for production.
const express = require('express');
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const app = express();
const port = process.env.PORT || 3000;
const file = path.join(__dirname, 'data', 'submissions.json');
const allowedPurposes = new Set(['feedback', 'support']);
app.use(express.urlencoded({ extended: false, limit: '20kb' }));
app.use(express.json({ limit: '20kb' }));
app.use(express.static('public'));
function text(value) { return typeof value === 'string' ? value.trim() : ''; }
function validate(input) {
const name = text(input.name);
const email = text(input.email).toLowerCase();
const purpose = text(input.purpose);
const details = text(input.details);
const errors = {};
if (!name || name.length > 100) errors.name = 'Enter a name up to 100 characters.';
if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email) || email.length > 320) errors.email = 'Enter a valid email address.';
if (!allowedPurposes.has(purpose)) errors.purpose = 'Choose a listed purpose.';
if (details.length < 10 || details.length > 5000) errors.details = 'Enter 10 to 5000 characters.';
return { value: { name, email, purpose, details }, errors };
}
async function readAll() {
try { return JSON.parse(await fs.readFile(file, 'utf8')); }
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
}
app.post('/submissions', async (req, res) => {
const { value, errors } = validate(req.body);
if (Object.keys(errors).length) return res.status(400).json({ errors });
const record = { id: crypto.randomUUID(), ...value, createdAt: new Date().toISOString() };
const records = await readAll();
records.push(record);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, JSON.stringify(records, null, 2), { mode: 0o600 });
res.status(201).json({ id: record.id, message: 'Submission received.' });
});
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ error: 'Unable to process the submission.' });
});
app.listen(port, () => console.log(`Listening on http://localhost:${port}`));
Put the form in public/index.html, start with node server.js, and open http://localhost:3000. For a production service, add authentication and authorization for the submission-management interface, CSRF protection where cookie sessions are used, rate limiting, structured audit logs, encrypted transport, secret management, and tested backups.
4. Validate at both browser and server layers
Browser constraints such as required, maxlength, numeric ranges, and input types provide immediate feedback. They are not a security boundary: anyone can send a crafted HTTP request directly. Repeat checks on the server before processing or storing values. MDN distinguishes syntactic validation (format and type) from semantic validation (whether a value is meaningful and allowed) and recommends allowlist checks where practical: MDN Input validation.
| Layer | Example | Purpose |
|---|---|---|
| Browser | type=email, length limits |
Fast correction for normal users |
| Server | Allowed purpose values, length and type checks | Enforce rules on every request |
| Database | NOT NULL, unique keys, foreign keys | Protect integrity if another code path fails |
| Output | Contextual HTML escaping or safe templates | Prevent stored or reflected injection |
Validation does not replace parameterized database queries, output encoding, authorization, rate limits, or malware scanning. Do not reject legitimate names or addresses with arbitrary patterns.
5. Store, expose, and delete submissions safely
- Keep records accessible only to the people and services that need them.
- Use TLS in transit and encryption at rest where appropriate.
- Keep credentials and API keys out of browser JavaScript and source control.
- Implement correction and deletion workflows, then verify that backups and exports follow the same retention policy.
- Log access and administrative changes without copying sensitive field values into general logs.
If you offer an administrator view, authorize every read and mutation on the server. Paginate large result sets, filter with parameterized queries, and return generic errors to clients while recording diagnostic details privately.
6. Handle file uploads as untrusted input
Only add uploads when the workflow needs them. Set an explicit size limit and allowlist the formats you can process. Do not trust the filename or browser-supplied MIME type; inspect content where feasible, generate your own storage name, and prevent path traversal and overwrites. Store files outside the served web root or on a separate host when feasible, and never execute uploaded content. MDN’s guidance covers malicious files, oversized uploads, unwanted content, path confusion, and executable content.
7. Authentication and access design
Decide whether submissions are public, linked to an account, or reviewed by staff. For accounts, use a maintained identity library or provider, hash passwords with a modern password-hashing function, require authorization checks on every record request, and support password-manager autofill and paste. Add recovery and session expiration that match the sensitivity of the data.
8. Choose a stack after requirements are known
There is no universally correct framework or database. Compare options using data sensitivity and jurisdiction, expected load, authentication and authorization, file handling, backups and retention, accessibility support, team experience, and operational maintenance. A hosted form or survey service can reduce implementation work, but you still need to assess its data flow, access controls, retention, export, and deletion behavior.
9. Test the complete workflow
- Keyboard-only completion and visible focus.
- Screen-reader labels, grouped controls, and status messages.
- Valid, missing, extra, oversized, malformed, and unexpected values.
- Direct requests that bypass the browser.
- Duplicate submissions and retries.
- Unauthorized reads, edits, exports, and deletes.
- Upload size, type, filename, and storage-path attacks.
- Backup restore and retention/deletion jobs.
- Slow networks, timeouts, and partial failures.
10. Capture a visual record with your own browser setup
If you need screenshots of submitted records for audits or documentation, a DIY route is Playwright. Install it with npm install playwright and run npx playwright install chromium.
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
await page.goto('http://localhost:3000', { waitUntil: 'networkidle' });
await page.screenshot({ path: 'data-capture-form.png', fullPage: true });
await browser.close();
})();
For a protected admin page, authenticate in the browser context, wait for a selector that proves the records loaded, and avoid saving screenshots that contain more personal data than the purpose requires.
11. Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. One request returns PNG, JPEG, WebP, or PDF. Its cleanup steps accept cookie and consent banners and remove more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers.
See the full parameter list in the ScreenshotNeo documentation.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
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)
Node.js
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}`);
require('node:fs').writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));
You can also capture one CSS-selected element, full pages with lazy images loaded, dark mode, any viewport or device preset, retina scale, PDFs with paper size and margins, custom CSS or JavaScript, clicks, waits, blocked resources, custom headers and cookies, timezone and geolocation, transparent backgrounds, resizing, caching with a chosen TTL, signed image links, asynchronous webhooks, bulk capture of up to 100 URLs per call, and usage data. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients. Every feature is on every plan; 1,000 shots per month are free with no card, and paid plans start at $5 for 3,000 shots.
Create your free ScreenshotNeo account and capture up to 1,000 screenshots each month without a card.
12. Performance, reliability, and cost
- Keep forms small and return a clear response quickly; queue expensive processing such as virus scanning or exports.
- Use database indexes for fields used in authorization and filtering, and paginate administrator views.
- Make retries safe with an idempotency key or a client-generated submission ID.
- Set request, upload, database, and outbound-service timeouts. Record correlation IDs so a failed submission can be traced.
- Back up encrypted data, test restoration, and monitor storage growth against the retention policy.
- For screenshots, cache stable pages with an appropriate TTL and use asynchronous jobs or bulk capture when a request does not need an immediate image. ScreenshotNeo charges only for clean shots; failed loads, bot checks, blank pages, timeouts, and cache hits cost nothing.
13. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Valid form rejected | Client and server rules differ | Share a schema or align both layers; keep server rules authoritative. |
| Data appears twice | Double click or retry after a timeout | Disable the button while submitting and enforce idempotency server-side. |
| Admin can view another user’s record | Missing object-level authorization | Check the authenticated subject against the record on every request. |
| Upload overwrites a file | Original filename used as storage path | Generate a random server-side name and store outside the web root. |
| Browser says success but no record exists | Response sent before durable write | Await the database transaction or queue acknowledgement before returning success. |
| Screenshot is blank | Page needs more time, is blocked, or failed | Wait for a selector or network idle, inspect the page verdict, and handle bot checks or failed loads explicitly. |
| Screenshot includes a popup | Consent or chat widget loaded after capture | Enable the relevant cleanup step or wait for the widget before removing it with custom CSS. |
14. FAQ
Should I validate only in JavaScript?
No. Browser validation improves usability; server validation enforces the rule because requests can bypass the page.
How many fields should a form have?
As few as the task requires. Extra fields increase handling, privacy, and retention obligations.
Do I need a database?
A file can demonstrate the flow, but concurrent production workloads need a database or managed store with backups, access controls, and retention operations.
Should uploads be stored with the application?
Prefer storage outside the served web root or a separate host, with generated names, size and type limits, and content checks.
Can an AI agent capture the finished form?
Yes. ScreenshotNeo’s MCP server exposes screenshot, page-info, and PDF tools to MCP clients such as Claude and Cursor.


