Validate forms with the Constraint Validation API in a Next.js + TypeScript app — built-in constraints in TSX, checking validity with typed elements, custom messages, reporting errors, and how it maps to react-hook-form.
Why: the browser validates a lot with zero JavaScript — just add attributes to your inputs, and the form refuses to submit until they pass, showing a built-in message. Note: in TSX a few attributes are camelCased (minLength) and numbers go in braces. This is the Constraint Validation API; the roadmap's "JavaScript Validation" means scripting on top of these built-in rules.
// The browser enforces all of these on submit, no JS required
export default function SignupForm() {
return (
<form>
<input type="email" required /> {/* must be a valid email */}
<input type="password" minLength={8} /> {/* at least 8 characters */}
<input type="number" min={1} max={10} /> {/* within a range */}
<input pattern="[A-Za-z]+" /> {/* must match a regex */}
<button>Submit</button>
</form>
)
}Why: sometimes you need to check validity yourself — before sending the form with fetch, for example. checkValidity() returns true or false, the validity object says exactly what failed, and validationMessage is the browser's text for it. Pass the element type to querySelector so it is typed, and guard against null.
const input = document.querySelector<HTMLInputElement>('input[type="email"]')
if (input) {
console.log(input.checkValidity()) // false if empty or invalid
console.log(input.validity.valueMissing) // true if required but empty
console.log(input.validity.typeMismatch) // true if not a valid email
console.log(input.validationMessage) // e.g. "Please fill out this field."
}Note: setCustomValidity replaces the browser's default message with your own — and a non-empty message also marks the field invalid. Crucially, you must clear it (set it back to an empty string) once the value becomes valid, or the field stays stuck as invalid.
const password = document.querySelector<HTMLInputElement>('#password')
password?.addEventListener('input', () => {
if (password.value.length < 8) {
password.setCustomValidity('Password must be at least 8 characters')
} else {
password.setCustomValidity('') // clear it — the field is valid again
}
})Where: checkValidity() tests silently; reportValidity() tests AND pops up the browser's message bubble on the first invalid field. Pair these with the CSS :invalid selector to style invalid inputs, for example a red border.
const form = document.querySelector<HTMLFormElement>('form')
form?.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault() // stop the submit
form.reportValidity() // show the browser's messages
}
})
// CSS: input:invalid { border-color: red; }Note: the native constraints still work on a plain React form, and checkValidity is handy for quick cases. Type the submit handler with FormEvent<HTMLFormElement>. But for cross-field rules or schemas shared with the server, most teams reach for react-hook-form plus a zod schema — the same checks with custom UI and TypeScript types from one source of truth.
'use client'
import type { FormEvent } from 'react'
// Native constraints work as-is in TSX:
export default function Signup() {
function handleSubmit(e: FormEvent<HTMLFormElement>) {
if (!e.currentTarget.checkValidity()) {
e.preventDefault()
e.currentTarget.reportValidity()
}
}
return (
<form onSubmit={handleSubmit}>
<input type="email" required />
<button>Sign up</button>
</form>
)
}
// For complex forms: react-hook-form + zod (zodResolver) instead.