Full-Stack Form Handling and Validation

· 8 min read · Updated March 20, 2026 · intermediate
javascript node forms validation

Introduction

Forms are the primary way users interact with web applications. Whether it’s a login form, a registration page, or a contact form, every form needs validation on both the client side (for a responsive user experience) and the server side (for security). This 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() — returns true or false without showing any UI
  • element.reportValidity() — returns true or false and 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:

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:

FlagMeaning
valueMissingRequired field is empty
typeMismatchValue doesn’t match the input type (e.g., not an email)
patternMismatchValue fails the pattern regex
tooShortBelow minlength
tooLongExceeds maxlength
rangeUnderflowBelow min (number, date, etc.)
rangeOverflowAbove max
stepMismatchValue doesn’t comply with step attribute
customErrorSet via setCustomValidity()
validtrue 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.

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:

<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:

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.

Zod is a popular schema validation library with TypeScript support and zero dependencies. It works well with Express.

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

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. The path option attaches the error to a specific field.

Handling Validation in a Route

Zod’s .parse() throws on failure. Use .safeParse() to get a result object instead:

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.

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.

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:

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:

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.

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)

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:

  1. User loads the form — server generates a CSRF token and stores it in the session, then renders it into the form.
  2. Browser submits the form — includes the CSRF token in the hidden field.
  3. Server checks req.body._csrf === req.session.csrfToken before any other processing.
  4. Zod validates the parsed body. If validation fails, return structured errors the frontend can display.
  5. If all checks pass, proceed with registration.

Key Gotchas

  • form.submit() bypasses browser constraint validation. Always use a submit event listener with reportValidity().
  • After calling setCustomValidity('error') on a field, you must call setCustomValidity('') 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=Lax does 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