Gemini Structured Output With Zod in Next.js
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
HTML gives you built-in form validation without any JavaScript: pick the right type on each <input> (like email, url, number or date) and add attributes such as required, minlength, maxlength, min, max, step and pattern. The browser then blocks submission and shows a message until the values fit. In this guide I go through the input types and validation attributes I use, with examples I checked in a current Chrome build, plus the gotchas that caught me out.
One thing to say right away: browser validation is there to help the user. It's not security. Anyone can turn it off or send a request without your form, so the server has to check everything again. I show how in my PHP form validation and sanitization guide.
Here's a small, complete form. There's no JavaScript, but it already refuses empty names, short passwords, badly formed emails and ages outside the range:
<form action="/signup" method="post">
<label for="name">Full name</label>
<input id="name" name="name" type="text" autocomplete="name" required minlength="2" maxlength="60">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="password">Password (at least 8 characters)</label>
<input id="password" name="password" type="password" autocomplete="new-password" required minlength="8">
<label for="age">Age</label>
<input id="age" name="age" type="number" min="13" max="120" step="1">
<button type="submit">Create account</button>
</form>
A few things are doing quiet work here:
<label> linked with for and id. Clicking the label focuses the field, and screen readers read it out. A placeholder is not a replacement for a label, because it disappears as soon as you type.name is what the server receives. An input without a name isn't sent at all.autocomplete values like email, name and new-password let browsers and password managers fill the form correctly.Choosing the right type gives you three things: a validation rule, a better keyboard on phones and sometimes a native picker.
<label for="site">Website</label>
<input id="site" name="site" type="url" placeholder="https://example.com">
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" autocomplete="tel">
<label for="exam">Exam date</label>
<input id="exam" name="exam" type="date" min="2026-01-01" max="2026-12-31">
<label for="start">Start time</label>
<input id="start" name="start" type="time" step="900">
<label for="hours">Study hours per day</label>
<input id="hours" name="hours" type="range" min="0" max="12" step="0.5" value="3">
<label for="accent">Accent colour</label>
<input id="accent" name="accent" type="color" value="#8b7cff">
<label for="q">Search notes</label>
<input id="q" name="q" type="search">
email checks the basic shape of an address. In my testing, abc was rejected with "Please include an '@'", but a@b was accepted, because the spec allows addresses without a dot (like on an intranet).url needs a scheme. example.com fails; https://example.com passes.tel doesn't validate anything by itself, because phone formats vary so much between countries. It mainly brings up a number pad on phones. Add a pattern if you need a format.number works with min, max and step. With step="1", a value of 13.5 fails with a step error.date and time show native pickers. The value is always sent in a fixed format (2026-11-15 for dates, 14:30 for times), no matter how it's displayed to the user.range always has a value, and it clamps to min/max. When I set it to 13 with max="12", the value became 12.color opens a colour picker and sends a hex value like #8b7cff.search behaves like text but may show a clear button and fits nicely inside a <search> element.The field must have a value. For a group of radio buttons with the same name, putting required on one of them makes the whole group required. On a checkbox, it means "must be ticked", which is what you want for "I agree to the terms".
Limits on the number of characters for text-like inputs. maxlength stops the user from typing more. minlength is checked when the user has edited the field. One thing I found while testing: setting a too-short value from JavaScript didn't count as invalid, because the browser only applies minlength to values the user typed.
For number, range and the date and time types. Dates use the same format as the value: min="2026-01-01". For time, step is in seconds, so step="900" means 15-minute steps.
A regular expression the whole value must match. You don't need ^ and $, because the pattern is always matched against the entire value.
<label for="username">Username</label>
<input id="username" name="username" type="text"
required pattern="[a-z0-9_]{3,16}"
title="3 to 16 characters: lowercase letters, numbers and underscores">
<label for="mobile">Mobile number (11 digits, starting with 01)</label>
<input id="mobile" name="mobile" type="tel" inputmode="numeric"
pattern="01[0-9]{9}" autocomplete="tel">
The title text is shown as a hint alongside the browser's "Please match the requested format" message in many browsers, so use it to explain the rule in plain words. In my test, mahir_01 passed, while Ab and abc! failed. The mobile pattern accepted 01712345678 and rejected 0171.
<fieldset>
<legend>Preferred language</legend>
<input id="lang-py" type="radio" name="lang" value="python" required>
<label for="lang-py">Python</label>
<input id="lang-js" type="radio" name="lang" value="javascript">
<label for="lang-js">JavaScript</label>
</fieldset>
<label for="city">City</label>
<input id="city" name="city" type="text" list="cities">
<datalist id="cities">
<option value="Dhaka"></option>
<option value="Chattogram"></option>
<option value="Sylhet"></option>
</datalist>
<input id="terms" name="terms" type="checkbox" required>
<label for="terms">I agree to the terms</label>
<fieldset> and <legend> group related radio buttons and give the group a name that screen readers announce. <datalist> suggests values but still lets the user type anything. If only those values are allowed, use a <select> instead.
The old :invalid selector matches as soon as the page loads, so an empty required field shows up red before the user has done anything. The newer :user-invalid and :user-valid pseudo-classes only match after the user has interacted with the field, and they're supported in current major browsers:
input:user-invalid {
border-color: #d93025;
outline-color: #d93025;
}
input:user-valid {
border-color: #188038;
}
Some rules can't be written as attributes, like "both passwords must match". The Constraint Validation API lets you plug your own rule into the same system with setCustomValidity():
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Confirm password</title>
</head>
<body>
<form id="signup">
<label for="pw">Password</label>
<input id="pw" name="pw" type="password" required minlength="8">
<label for="pw2">Confirm password</label>
<input id="pw2" name="pw2" type="password" required>
<button type="submit">Sign up</button>
</form>
<script>
const pw = document.getElementById("pw");
const pw2 = document.getElementById("pw2");
function checkMatch() {
if (pw2.value !== pw.value) {
pw2.setCustomValidity("Passwords do not match.");
} else {
pw2.setCustomValidity(""); // empty string = valid again
}
}
pw.addEventListener("input", checkMatch);
pw2.addEventListener("input", checkMatch);
</script>
</body>
</html>
A non-empty message marks the field invalid and blocks submission with your text. An empty string clears the error. I loaded this page in headless Chrome and set the values from a script: with different passwords, validity.customError was true, and once they matched the field was valid again.
Client-side validation can be skipped with dev tools or a direct request. Always validate again on the server. In my Quizwright project, the server route checks every request against a strict schema before it does anything, which I explained in how I kept my AI API key off the browser.
Modern browsers compile pattern with the JavaScript v flag, which is stricter about character classes. In my test, pattern="[a-z0-9-]{3,}" was silently ignored, so even AB passed. Writing it as [a-z0-9\-]{3,} made the pattern work again. If a pattern seems to do nothing, check the console for a regex error.
Placeholders vanish while typing and often have low contrast. Keep a visible <label>.
Phone numbers, OTP codes and card numbers aren't quantities. number adds spinner arrows, accepts input like 1e5, and once the value is treated as a number, leading zeros are easy to lose. Use type="text" or tel with inputmode="numeric" and a pattern.
The field validates perfectly and then never reaches the server.
Add novalidate to the <form> to skip it for the whole form, or formnovalidate to a specific submit button, for example a "Save draft" button.
Yes, with setCustomValidity() in JavaScript, as in the password example. The title attribute adds a hint for pattern errors but doesn't replace the browser's message.
It catches typos like a missing @, but it accepts addresses like a@b. On the server, check the format again, and if the address really matters, send a confirmation email.
required works on both, and minlength and maxlength work on <textarea>.
Start every form with the right input types and labels, then add required, length limits, min/max/step and pattern where they fit. Style errors with :user-invalid, use setCustomValidity() for rules that need code, and always validate again on the server.
MDN's guide to HTML form validation and the Constraint Validation API covers every attribute in detail and is worth bookmarking.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Describe the JSON once in Zod, send z.toJSONSchema() to Gemini as responseJsonSchema, then validate the reply with safeParse and retry once with the errors.
Step by step: a Gemini key from Google AI Studio in .env.local, a server-only helper, a Next.js route handler, Vercel environment variables and a leak test.
A small offline habit tracker in plain HTML and JavaScript: habits saved as JSON in localStorage, streaks from local dates, a 7-day row and a JSON backup.