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.
Async/await is a cleaner way to work with promises in JavaScript. Mark a function with async, then put await in front of a promise to pause that function until the promise settles, and use try/catch to handle errors. Your asynchronous code ends up reading top to bottom like normal code. In this async/await tutorial I start from the basics and work up to running tasks in parallel, using fetch safely, retrying and the mistakes I made along the way.
All examples are ES modules (.mjs files) that I ran with Node.js 20, and the comments at the end of each block show the real output. They work the same way in modern browsers, apart from the bits that rely on top-level await, which needs a module script.
JavaScript runs your code on a single thread. If it stopped and waited every time it asked a server for data, the whole page would freeze. So slow operations like network requests and timers are asynchronous: they start now and finish later, and a promise represents that future result.
Before async/await, you handled promises with chains of .then() calls, and before promises, with nested callbacks. Both work, but long chains get hard to follow. Async/await is built on top of promises, so it isn't a different system, just a nicer syntax for the same thing.
// A promise that resolves after `ms` milliseconds
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function greet(name) {
await wait(500); // pause this function, not the whole program
return `Hello, ${name}!`;
}
console.log("1. before");
greet("Mahir").then((message) => console.log("3.", message));
console.log("2. after (greet is still waiting)");
// Output:
// 1. before
// 2. after (greet is still waiting)
// 3. Hello, Mahir!
Look at the order of the output. await pauses only greet. The rest of the program keeps running, which is why "2. after" prints before the greeting arrives. This is the most important idea in the whole tutorial: await doesn't block JavaScript, it pauses the current async function.
async function getNumber() {
return 42;
}
const result = getNumber();
console.log(result instanceof Promise); // an async function always returns a promise
console.log(await result); // await unwraps it (top-level await in a module)
// Output:
// true
// 42
Even though getNumber returns a plain 42, calling it gives you a promise. To get the value, you await it (or use .then()). Inside an ES module you can use await at the top level like this; in a regular script, await only works inside an async function.
When an awaited promise rejects, await throws the error, so you catch it with an ordinary try/catch. finally runs either way, which makes it a good place to hide a loading spinner.
function loadProfile(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id === 1) resolve({ id, name: "Nusrat" });
else reject(new Error(`No profile with id ${id}`));
}, 200);
});
}
async function showProfile(id) {
try {
const profile = await loadProfile(id);
console.log("Loaded:", profile.name);
} catch (error) {
console.log("Failed:", error.message);
} finally {
console.log("Done with id", id);
}
}
await showProfile(1);
await showProfile(2);
// Output:
// Loaded: Nusrat
// Done with id 1
// Failed: No profile with id 2
// Done with id 2
The most common real use of async/await is loading data with fetch. Here's the pattern I use, tested against the free JSONPlaceholder API:
async function getUser(id) {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`, {
signal: AbortSignal.timeout(5000), // give up after 5 seconds
});
// fetch only rejects on network errors, so check the HTTP status yourself
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json(); // also returns a promise
}
try {
const user = await getUser(1);
console.log(user.name, "-", user.email);
await getUser(9999); // this user does not exist
} catch (error) {
console.log("Request failed:", error.message);
}
// Output:
// Leanne Graham - Sincere@april.biz
// Request failed: HTTP 404
Two details catch almost everyone out:
fetch doesn't reject on HTTP errors. A 404 or 500 response still resolves. It only rejects if the request couldn't be made at all. That's why I check response.ok and throw my own error.response.json() is asynchronous too. Reading the body takes time, so it returns another promise. Returning it from an async function is fine, because the caller's await unwraps it.AbortSignal.timeout(5000) cancels the request if it takes longer than five seconds, so a hanging server can't leave your page waiting forever. It works in current browsers and in Node 18+.
In my Quizwright project, the page sends its form data to a single server route with a plain fetch, and the server does the AI call so the API key never reaches the browser. I wrote about that in how I kept my AI API key off the browser.
This is where async/await code often gets slower than it needs to be. Each await waits for the one before it. If two requests don't depend on each other, start them both, then wait for both:
const wait = (ms, value) =>
new Promise((resolve) => setTimeout(() => resolve(value), ms));
const seconds = (start) => ((Date.now() - start) / 1000).toFixed(1);
// Sequential: each await waits for the previous one
let start = Date.now();
const a = await wait(1000, "posts");
const b = await wait(1000, "projects");
console.log("sequential:", a, b, "in", seconds(start), "s");
// Parallel: start both first, then wait for both
start = Date.now();
const [c, d] = await Promise.all([wait(1000, "posts"), wait(1000, "projects")]);
console.log("parallel:", c, d, "in", seconds(start), "s");
// Output:
// sequential: posts projects in 2.0 s
// parallel: posts projects in 1.0 s
Two one-second tasks took two seconds in sequence and one second in parallel. Promise.all takes an array of promises and resolves with an array of results, in the same order you passed them in, no matter which finishes first. If any of them rejects, Promise.all rejects straight away.
On a dashboard that loads several widgets, one broken API shouldn't blank the whole page. Promise.allSettled waits for everything and tells you how each one went:
const ok = (value) => Promise.resolve(value);
const fail = (message) => Promise.reject(new Error(message));
const results = await Promise.allSettled([
ok("weather loaded"),
fail("news API is down"),
ok("quote loaded"),
]);
for (const r of results) {
if (r.status === "fulfilled") console.log("✔", r.value);
else console.log("✘", r.reason.message);
}
// Output:
// ✔ weather loaded
// ✘ news API is down
// ✔ quote loaded
There's also Promise.race (the first to settle wins) and Promise.any (the first to succeed wins), but all and allSettled are the ones I use most.
Networks and APIs fail sometimes. A small retry helper with a growing delay handles short glitches:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function withRetry(task, attempts = 3, delayMs = 300) {
for (let i = 1; i <= attempts; i++) {
try {
return await task(i); // return await, so errors are caught here
} catch (error) {
console.log(`Attempt ${i} failed: ${error.message}`);
if (i === attempts) throw error;
await wait(delayMs * i); // wait a little longer each time
}
}
}
// A fake task that fails twice, then works
const flaky = async (attempt) => {
if (attempt < 3) throw new Error("server busy");
return "saved!";
};
console.log(await withRetry(flaky));
// Output:
// Attempt 1 failed: server busy
// Attempt 2 failed: server busy
// saved!
Notice return await task(i) inside the try. Without the await, a rejected promise would be returned straight to the caller and the catch block here would never run. Keep retries small. Retrying a request that failed because the input was invalid just wastes time.
forEach ignores the promises its callback returns, so it doesn't wait:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const ids = [1, 2, 3];
// Wrong: forEach does not wait for async callbacks
ids.forEach(async (id) => {
await wait(100);
console.log("forEach finished", id);
});
console.log("forEach returned already");
await wait(200); // let the forEach callbacks finish before the next demo
// Right: for...of waits for each one in order
for (const id of ids) {
await wait(100);
console.log("for...of finished", id);
}
console.log("for...of is really done");
// Output:
// forEach returned already
// forEach finished 1
// forEach finished 2
// forEach finished 3
// for...of finished 1
// for...of finished 2
// for...of finished 3
// for...of is really done
"forEach returned already" prints first, before any callback has finished. Use for...of when tasks must run one after another (the retry loop in my Gemini structured output with Zod post awaits inside a plain loop the same way), or await Promise.all(ids.map(async (id) => { ... })) when they can run in parallel. If map() is new to you, read JavaScript map, filter and reduce explained.
If you call an async function that throws and never await it or attach .catch(), you get an unhandled rejection. In Node.js, that crashes the process by default. I checked: calling save() without handling it ended the script with exit code 1.
async function save() {
throw new Error("disk full");
}
// Missing await/catch: in Node this crashes the process with an unhandled rejection.
// save();
// Handle it with try/catch around await...
try {
await save();
} catch (e) {
console.log("caught with try/catch:", e.message);
}
// ...or with .catch() when you don't await
save().catch((e) => console.log("caught with .catch():", e.message));
// Output:
// caught with try/catch: disk full
// caught with .catch(): disk full
Without await, you get the promise, not the value. If you see Promise { <pending> } in the console or [object Promise] on the page, a missing await is almost always the reason.
As the timing example showed, back-to-back awaits add up. If the second request doesn't need the result of the first, use Promise.all.
Without that check, your code happily tries to read an error page as data, and the real problem shows up later as a confusing error somewhere else.
They do the same thing, because async/await is built on promises. I find async/await easier to read, especially with error handling and loops, but .then() is still fine for short one-off chains, and you can mix the two.
No. It pauses only the async function it's in. Other code, event handlers and rendering keep running while it waits.
Only at the top level of an ES module, like a .mjs file in Node or a <script type="module"> in the browser. Everywhere else it must be inside an async function.
Split the list into small batches and await Promise.all each batch inside a loop. It's simple, and it stops you from flooding a server with hundreds of requests at once:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function upload(file) {
await wait(100); // pretend this is a network request
return `${file} uploaded`;
}
const files = ["a.png", "b.png", "c.png", "d.png", "e.png"];
const batchSize = 2;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const results = await Promise.all(batch.map(upload)); // 2 at a time
console.log(results.join(", "));
}
// Output:
// a.png uploaded, b.png uploaded
// c.png uploaded, d.png uploaded
// e.png uploaded
Each batch waits for the slowest item in it, so a dedicated concurrency library is faster for big jobs, but for a handful of uploads this is all I need.
Async/await lets you write asynchronous JavaScript that reads like normal code: async on the function, await on the promise, try/catch for errors. Remember that await only pauses its own function, check response.ok after fetch, use Promise.all for independent tasks, and avoid await inside forEach.
For a deeper look at how promises work underneath, MDN's guide on how to use promises is excellent.
// 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.