Full-Stack Form Handling and Validation
Introduction
Forms are the primary way users interact with web applications. Every form needs validation on both the client side (for a responsive user experience) and the server side (for security). This full-stack form tutorial covers the complete picture: HTML constraint validation, Express.js backend validation with Zod, and security measures including CSRF protection.
Client-side validation with the HTML Constraint Validation API
The browser has a built-in validation API that works without any external libraries. Understanding it gives you precise control over form behavior.
How browser validation works
When a form is submitted, the browser checks each input against its validation attributes. By default, it shows a popup and blocks submission if validation fails. You can intercept this with a submit event listener.
The key methods are:
element.checkValidity()— returnstrueorfalsewithout showing any UIelement.reportValidity()— returnstrueorfalseand displays the browser’s native error message to the user
const form = document.getElementById('registration-form');
form.addEventListener('submit', (event) => {
if (!form.reportValidity()) {
event.preventDefault(); // stop submission
}
});
Calling form.submit() directly bypasses constraint validation entirely. Always use a submit event listener with reportValidity() instead.
Inspecting the validity property
Each input element has a validity object with boolean flags describing exactly what went wrong. These flags give you fine-grained control over which error message to display, rather than relying on the browser’s generic popups:
const emailInput = document.querySelector('input[name="email"]');
if (emailInput.validity.valueMissing) {
console.log('Email is required');
} else if (emailInput.validity.typeMismatch) {
console.log('Email format is invalid');
} else if (emailInput.validity.tooShort) {
console.log(`Email must be at least ${emailInput.getAttribute('minlength')} characters`);
} else if (emailInput.validity.valid) {
console.log('Email is valid');
}
The validity flags are:
| Flag | Meaning |
|---|---|
valueMissing | Required field is empty |
typeMismatch | Value doesn’t match the input type (e.g., not an email) |
patternMismatch | Value fails the pattern regex |
tooShort | Below minlength |
tooLong | Exceeds maxlength |
rangeUnderflow | Below min (number, date, etc.) |
rangeOverflow | Above max |
stepMismatch | Value doesn’t comply with step attribute |
customError | Set via setCustomValidity() |
valid | true if all constraints pass |
Setting custom validation messages
The browser’s default error messages are generic. You can provide your own with setCustomValidity():
const passwordInput = document.querySelector('input[name="password"]');
passwordInput.addEventListener('input', () => {
const value = passwordInput.value;
if (value.length < 8) {
passwordInput.setCustomValidity('Password must be at least 8 characters');
} else if (!/[A-Z]/.test(value)) {
passwordInput.setCustomValidity('Password must contain an uppercase letter');
} else {
// Clear the error — this is required or the field stays invalid
passwordInput.setCustomValidity('');
}
passwordInput.reportValidity();
});
Without that final setCustomValidity(''), the field stays in an invalid state permanently. This is one of the most common pitfalls with the Constraint Validation API — the custom error does not clear itself when the input value changes.
Disabling browser validation
Add novalidate to the <form> element to suppress the browser’s native error bubbles. This lets you build entirely custom validation UI, but also means you take full responsibility for validation feedback:
<form id="registration-form" novalidate>
<!-- custom error display handled by JavaScript -->
</form>
Styling valid and invalid states
CSS pseudo-classes let you style fields based on their validation state. This gives visual feedback without any JavaScript:
input:invalid {
border-color: #e74c3c;
}
input:valid {
border-color: #27ae60;
}
input:invalid:not(:placeholder-shown) {
/* only show error style when field has content */
}
Server-side validation with Express and Zod
Client-side validation improves user experience but never replaces server-side validation. A malicious user can bypass client-side JavaScript entirely, so every field must be re-validated on the server.
Zod is a popular schema validation library with TypeScript support and zero dependencies. It works well with Express for defining and enforcing validation rules in one place.
Setting up Express with JSON body parsing
const express = require('express');
const app = express();
app.use(express.json()); // built-in since Express 4.16+
Defining a Zod schema
The schema declares the shape and constraints for your form data. Zod’s chainable API makes complex rules readable:
const { z } = require('zod');
const RegisterSchema = z.object({
email: z.string().email('Enter a valid email address'),
username: z.string().min(3).max(30).trim(),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/\d/, 'Password must contain a digit'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
The .refine() method adds cross-field validation — rules that depend on multiple fields — which simple per-field checks cannot express. The path option attaches the error to a specific field so the frontend knows where to display it.
Handling validation in a route
Zod’s .parse() throws on failure. Use .safeParse() to get a result object instead, which is cleaner for API error handling:
app.post('/api/register', (req, res) => {
const result = RegisterSchema.safeParse(req.body);
if (!result.success) {
// Map Zod errors to a cleaner structure
const errors = result.error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
return res.status(400).json({ errors });
}
// result.data is fully typed and validated
const { email, username, password } = result.data;
// Save user to database here...
res.status(201).json({ message: 'User registered successfully' });
});
CSRF protection
Cross-Site Request Forgery (CSRF) is an attack where a malicious site tricks a user’s browser into sending a request to your application while the user is logged in. Because browsers include cookies automatically, the attack succeeds even though the malicious site never sees the response.
SameSite cookies
The first line of defense is the SameSite attribute on session cookies:
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'lax' // 'strict' blocks all cross-origin requests
});
SameSite=Strict— cookies only sent on same-origin requests. Very safe but can break legitimate navigation from other sites.SameSite=Lax— sent with safe top-level GET navigations from other origins, but not with form POSTs.
Lax alone does not fully protect POST forms. Use it together with the Synchronizer Token Pattern for state-changing operations.
Synchronizer token pattern
Generate a random CSRF token per session, embed it in every form as a hidden field, and verify it on the server. This ensures that only forms your server generated can submit successfully:
const crypto = require('crypto');
// Middleware: attach CSRF token to every request
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
res.locals.csrfToken = req.session.csrfToken;
next();
});
For an Express template (EJS shown), render the token in the form:
<form action="/contact" method="POST">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="text" name="name" required>
<button type="submit">Send</button>
</form>
Then verify on POST — the server compares the token from the form body against the token stored in the session:
app.post('/contact', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
// Process the form...
res.send('Message received');
});
For Express apps, the csrf-sync package automates this pattern instead of writing the middleware manually.
Input sanitization
Sanitization cleans or normalizes input values. Validation checks whether input meets rules. Always sanitize before validating, and do both on the server.
Express-validator combines sanitization and validation in a middleware chain that runs sequentially — each method transforms or checks the value before passing it to the next:
const { body, validationResult } = require('express-validator');
app.post('/api/contact', [
body('name').trim().escape().notEmpty().withMessage('Name is required'),
body('email').trim().isEmail().normalizeEmail(),
body('message').trim().isLength({ min: 10 }).withMessage('Message must be at least 10 characters')
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.send('Message sent');
});
.trim() removes surrounding whitespace. .escape() converts <, >, &, ", and ' to HTML entities. .normalizeEmail() standardizes email addresses to a consistent format. Sanitizing before validating prevents edge cases where whitespace or encoding tricks bypass your rules.
A complete registration form
This example ties together everything: HTML5 constraint validation on the client, Zod validation on the server, and CSRF protection.
Frontend (HTML + JavaScript)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Register</title>
<style>
.field-error { color: #e74c3c; font-size: 0.875em; }
input:invalid { border-color: #e74c3c; }
input:valid { border-color: #27ae60; }
</style>
</head>
<body>
<form id="register-form" novalidate>
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>Email
<input type="email" name="email" required minlength="5">
<span class="field-error" id="email-error"></span>
</label>
<label>Username
<input type="text" name="username" required minlength="3" maxlength="30" pattern="[a-zA-Z0-9]+">
<span class="field-error" id="username-error"></span>
</label>
<label>Password
<input type="password" name="password" required minlength="8"
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$">
<span class="field-error" id="password-error"></span>
</label>
<label>Confirm Password
<input type="password" name="confirmPassword" required>
<span class="field-error" id="confirmPassword-error"></span>
</label>
<button type="submit">Register</button>
</form>
<script>
const form = document.getElementById('register-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const result = await response.json();
displayServerErrors(result.errors);
} else {
window.location.href = '/register/success';
}
});
function displayServerErrors(errors) {
errors.forEach(err => {
const field = err.field.replace(/\[|\]|\./g, '-');
const errorEl = document.getElementById(`${field}-error`);
if (errorEl) errorEl.textContent = err.message;
});
}
</script>
</body>
</html>
Note: the HTML pattern restricts passwords to alphanumeric only. Zod’s validation is authoritative — this HTML constraint is for early feedback only.
Backend (Express + Zod)
The backend ties everything together: session management, CSRF token generation, Zod validation, and structured error responses that the frontend can display next to the relevant fields.
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const { z } = require('zod');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax'
}
}));
// Attach CSRF token to every request
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
res.locals.csrfToken = req.session.csrfToken;
next();
});
const RegisterSchema = z.object({
email: z.string().email('Enter a valid email address'),
username: z.string().min(3).max(30).trim().regex(/^[a-zA-Z0-9]+$/, 'Username must be alphanumeric'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/\d/, 'Password must contain a digit'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
app.get('/register', (req, res) => {
// Render the HTML form with the CSRF token
res.send(`<!DOCTYPE html>
<html>
<body>
<form id="register-form" novalidate>
<input type="hidden" name="_csrf" value="${res.locals.csrfToken}">
<!-- form fields here -->
</form>
</body>
</html>`);
});
app.post('/api/register', (req, res) => {
// Verify CSRF token
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
const result = RegisterSchema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
return res.status(400).json({ errors });
}
const { email, username, password } = result.data;
// Hash password and save to database here...
res.status(201).json({ message: 'User registered' });
});
app.listen(3000, () => console.log('Server running on port 3000'));
The key flow is:
- User loads the form — server generates a CSRF token and stores it in the session, then renders it into the form.
- Browser submits the form — includes the CSRF token in the hidden field.
- Server checks
req.body._csrf === req.session.csrfTokenbefore any other processing. - Zod validates the parsed body. If validation fails, return structured errors the frontend can display.
- If all checks pass, proceed with registration.
Keep the validation story aligned
The cleanest form handling flow happens when the browser and the server agree about the rules. If the client enforces one set of checks and the API enforces a different set, users see errors that feel random. Keeping the same requirements in both places makes the experience feel intentional and lowers the chance that an invalid payload sneaks through during a refactor.
Treat feedback as part of the form
A good form is not only the fields themselves. It is also the copy, the error placement, and the recovery path after something fails. When a form tells the user what went wrong and how to fix it, the rest of the flow gets much smoother. Client-side and server-side validation should be designed together, not as separate chores.
Keep the submit path predictable
Forms are easier to maintain when the submit flow always follows the same shape. Gather the data, validate it, send it, and then show either success or a clear error. That sequence keeps the component easy to reason about and prevents edge cases from hiding in conditional branches. A predictable path also makes it simpler to add analytics, logging, or retry logic later without reworking the whole form.
Key gotchas
form.submit()bypasses browser constraint validation. Always use a submit event listener withreportValidity().- After calling
setCustomValidity('error')on a field, you must callsetCustomValidity('')when the field becomes valid — otherwise it stays stuck in invalid state. - Client-side validation is for user experience only. Every field must be re-validated on the server.
SameSite=Laxdoes not prevent CSRF on POST requests. Use both SameSite cookies and a CSRF token for state-changing operations.- Zod’s
.parse()throws exceptions. Use.safeParse()if you want to handle errors without try/catch blocks.
See Also
- The Fetch API — working with asynchronous code in JavaScript
- JSON.stringify() — parsing and stringifying data, as used when sending form data to an API