Product Reviews
A modern Next.js app for displaying customer reviews
Manage your guild membership
Choose the membership level that matches your foraging journey
No membership tiers available yet. Check back soon!
(Make sure products are created in Stripe and synced via webhook)
You are currently on the {subscription.prices?.products?.name} plan.
)}{description}
)}Manage your guild membership
You haven't joined a membership tier yet. Visit the Membership page to choose a tier and unlock guild benefits.
Price:{" "} {formatPrice( subscription.prices?.unit_amount, subscription.prices?.currency )} /{subscription.prices?.interval}
Current period:{" "} {formatDate(subscription.current_period_start)} -{" "} {formatDate(subscription.current_period_end)}
{subscription.cancel_at_period_end && (Your subscription will cancel at the end of the current billing period.
)}Signed in as: {user?.email}
Manage your guild membership
You haven't joined a membership tier yet. Visit the Membership page to choose a tier and unlock guild benefits.
Signed in as: {user?.email}
The Field Guide is available to Ranger and Elder members. Upgrade your membership to access our complete database of edible plants, mushrooms, and foraging guides.
Discover edible plants and mushrooms from our curated database
Discover edible plants and mushrooms...
The Field Guide is available to Ranger and Elder members...
Upgrade MembershipExplore our curated database of edible plants and mushrooms
{error}
)} {entry && ({entry.description}
We couldn't load this page. This might be a temporary issue.
{error.digest && (Error ID: {error.digest}
)}An unexpected error occurred. Please try again.
{error.digest && (Error ID: {error.digest}
)}The page you're looking for doesn't exist or has been moved.
Hi ${order.customerName},
We got your order for a ${order.size} ${order.pizza} on ${order.crust} crust.
We'll let you know when it's out for delivery to ${order.address}.
Sal
`, }); if (resp.error) { throw new FatalError(`Resend failed: ${resp.error.message}`); } updateStatus(order.id, "confirmed"); } ``` A few things worth noting. `"use step"` is the first statement in the function body, like `"use strict"`. Anywhere else it does nothing. We use `FatalError` from `workflow` when Resend reports a real failure (like a malformed email address). That class tells the runtime: don't retry, this won't work. We'll come back to this in 4.2. For now: if the call returns an error, we want it to be final. Everything else is a regular Resend call. No new APIs. No special handling. The directive is the only thing that makes this a step. **2. Wire it into the route.** The starter's `/api/orders` stores the order and returns a fake `runId`. Add a call to our new step right before the response: ```ts title="app/api/orders/route.ts" {3,29} import { NextResponse } from "next/server"; import { recordOrder } from "@/lib/orders-store"; import { sendOrderConfirmation } from "@/workflows/steps/send-order-confirmation"; import type { Order, PizzaName, Size, Crust } from "@/lib/pizza"; // ... type IncomingOrder unchanged ... export async function POST(request: Request) { const body = (await request.json()) as IncomingOrder; const order: Order = { id: crypto.randomUUID(), customerName: body.customerName, email: body.email, pizza: body.pizza, size: body.size, crust: body.crust, address: body.address, cardLast4: body.cardLast4, placedAt: new Date().toISOString(), }; // TODO (Lesson 1.3): Replace this stub with start(processOrder, [order]). const fakeRunId = crypto.randomUUID(); recordOrder(order, fakeRunId); await sendOrderConfirmation(order); return NextResponse.json({ runId: fakeRunId }); } ``` \*\*Note: The directive isn't active yet\*\* Calling a `"use step"` function directly, like we just did, runs it as a regular function. No retries. No event log. The directive becomes meaningful in the next lesson when we call this from inside a workflow. Right now we're laying the wiring. ## Try It Open `http://localhost:3000`. Put your real email in the form. Click **Place order**. Two things should happen: 1. The browser redirects to `/orders/Hi ${order.customerName},
We got your order for a ${order.size} ${order.pizza} on ${order.crust} crust.
We'll let you know when it's out for delivery to ${order.address}.
Sal
`, }); if (resp.error) { throw new FatalError(`Resend failed: ${resp.error.message}`); } updateStatus(order.id, "confirmed"); } ``` The function works. The email sends. The directive is sitting there, dormant, waiting for a workflow to bring it to life. That's next. --- title: "Wrap in a workflow" description: "Write the processOrder workflow with \"use workflow\", trigger it from the orders Route Handler using start(), and tour the local Workflow dashboard." canonical_url: "https://vercel.com/academy/workflow-foundations/wrap-it-in-a-workflow" md_url: "https://vercel.com/academy/workflow-foundations/wrap-it-in-a-workflow.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.019Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] ---Hi ${order.customerName},
Your pizza has landed. Hit reply and tell us how it was.
Sal
`, }); if (resp.error) { throw new FatalError(`Resend failed: ${resp.error.message}`); } updateStatus(order.id, "review-sent"); } ``` Same pattern every time. Async function, directive, do the work, persist the status. The Resend step throws `FatalError` on a Resend error, same logic as the confirmation step. **2. Update the workflow.** `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" {5-7,15-18} import { sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); await sleep("30s"); await dispatchDelivery(order); await sleep("30s"); await confirmDelivery(order); await sendReviewRequest(order); return { orderId: order.id }; } ``` A minute of total sleep (`30s` + `30s`), two more emails, a final status of `delivered` then `review-sent`. The workflow now describes the entire happy path of an order. ## Try It Place an order on your production URL. Sit with it. The page polls every couple of seconds, so the status text will tick through the states: ``` 0:00 Order placed 0:01 Confirmation email sent 0:02 In the kitchen 0:32 Out for delivery 1:02 Delivered 1:03 Review request sent ``` You should get two emails total: the confirmation right after placing, and the review request about a minute later. Open the Workflows dashboard and click into the run. The timeline now shows the full sequence: ``` processOrder completed 62.4s ├─ sendOrderConfirmation completed 389ms ├─ acknowledgeKitchen completed 208ms ├─ (sleep 30s) ├─ dispatchDelivery completed 213ms ├─ (sleep 30s) ├─ confirmDelivery completed 78ms └─ sendReviewRequest completed 341ms ``` That's six steps and two sleeps, suspended and resumed twice, no infrastructure managed by you. \*\*Warning: The sleeps are temporary\*\* Real ordering systems don't pretend cook time is 30 seconds. The whole reason hooks exist is so the workflow can pause until the kitchen actually says it's done. In 3.1 we replace these sleeps with hooks. Don't get attached to them. ## Commit ``` feat(workflow): complete happy path with delivery and review ``` ## Done-When - [ ] Three new step files exist: `dispatch-delivery.ts`, `confirm-delivery.ts`, `send-review-request.ts` - [ ] `processOrder` calls all five steps in order with sleeps between the cook and delivery phases - [ ] Placing an order eventually triggers both the confirmation email and the review email - [ ] The dashboard timeline shows the full sequence ending in `delivered` then `review-sent` ## Solution `workflows/process-order.ts`: ```ts title="workflows/process-order.ts" import { sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; export async function processOrder(order: Order): Promise<{ orderId: string }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); await sleep("30s"); await dispatchDelivery(order); await sleep("30s"); await confirmDelivery(order); await sendReviewRequest(order); return { orderId: order.id }; } ``` The full step files appear in the hands-on section above. Read them once, paste them once, and let the workflow narrate the rest. Next section we replace the sleeps with hooks that wait for the actual kitchen and driver to ping us. --- title: "Wait on a hook" description: "Replace the fake cook-time sleep with createHook(). Build a Route Handler that uses resumeHook() to wake the workflow when the kitchen marks an order ready." canonical_url: "https://vercel.com/academy/workflow-foundations/pause-until-the-kitchen-pings-us" md_url: "https://vercel.com/academy/workflow-foundations/pause-until-the-kitchen-pings-us.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.174Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] ---Hi ${order.customerName},
Your ${order.pizza} is taking longer than usual. We're calling the kitchen now.
Sal
`, }); if (resp.error) { throw new FatalError(`Resend failed: ${resp.error.message}`); } updateStatus(order.id, "escalated"); } ``` **2. Race the hook against sleep.** Update `workflows/process-order.ts`. Only the section between the kitchen ack and the dispatch changes: ```ts title="workflows/process-order.ts" {1,8-9,18-36} import { createHook, sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { markOutForDelivery } from "./steps/mark-out-for-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; import { sendEscalationEmail } from "./steps/send-escalation-email"; const KITCHEN_TIMEOUT = "20m"; export async function processOrder(order: Order): Promise<{ status: "delivered" | "escalated"; orderId: string; }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); using kitchenHook = createHook<{ readyAt: string }>({ token: `kitchen:${order.id}`, }); const kitchenResult = await Promise.race([ kitchenHook.then((payload) => ({ kind: "ready" as const, payload })), sleep(KITCHEN_TIMEOUT).then(() => ({ kind: "timeout" as const })), ]); if (kitchenResult.kind === "timeout") { await sendEscalationEmail(order); return { status: "escalated", orderId: order.id }; } await dispatchDelivery(order); using pickupHook = createHook<{ pickedUpAt: string }>({ token: `driver-pickup:${order.id}`, }); await pickupHook; await markOutForDelivery(order); using deliveryHook = createHook<{ deliveredAt: string }>({ token: `driver-delivered:${order.id}`, }); await deliveryHook; await confirmDelivery(order); await sendReviewRequest(order); return { status: "delivered", orderId: order.id }; } ``` Two things to call out. The `.then(...)` calls tag each promise's resolution with a `kind` discriminator. That's what makes the result type narrow correctly in the `if` block. Without the tags, the result is just `unknown | undefined` and you have to squint to tell which side won. `Promise.race` is the documented way to add a timeout to a hook in the Workflow SDK. The runtime treats `sleep` and the hook as ordinary promises here. Whichever resolves first, that's the value of the `await`. The whole pattern is composable in a way that built-in timeouts wouldn't be. The workflow's return type now includes both outcomes: `"delivered"` and `"escalated"`. The caller of `start(processOrder, [order])` can `await run.returnValue` later and react appropriately. ## Try It Drop the timeout to something short while you're testing. Change `KITCHEN_TIMEOUT` to `"30s"` so you don't have to wait 20 minutes. Deploy that change, then place an order. Don't touch the kitchen ops UI. Just wait. 30 seconds in, the run advances past the hook with `kind: "timeout"`. The escalation email arrives. The status moves to "Order escalated." The workflow ends with `{ status: "escalated", orderId }`. In the dashboard: ``` processOrder completed 42.1s ├─ sendOrderConfirmation completed 389ms ├─ acknowledgeKitchen completed 208ms ├─ kitchen hook timed out (30s) └─ sendEscalationEmail completed 341ms ``` Now place a second order. This time, click **Mark ready** in the kitchen UI within 30 seconds. The race resolves to `"ready"`. The escalation email is never sent. The workflow continues to dispatch, pickup, delivery, review. Change `KITCHEN_TIMEOUT` back to `"20m"` before shipping anything you care about. \*\*Note: The Promise.race trick generalizes\*\* This is the same pattern you'd use to time out any waiting workflow step. Race the thing you're waiting on against `sleep(...)`. Tag both sides so the result type narrows. Branch on the tag. It works for hooks, webhooks, even a slow third-party API wrapped in a step. ## Commit ``` feat(workflow): escalate when the kitchen doesn't respond ``` ## Done-When - [ ] `workflows/steps/send-escalation-email.ts` exists with the `"use step"` directive - [ ] `processOrder` races the kitchen hook against `sleep(KITCHEN_TIMEOUT)` using tagged Promises - [ ] The timeout branch calls `sendEscalationEmail` and returns early - [ ] The workflow return type is `{ status: "delivered" | "escalated", orderId: string }` - [ ] You've tested both branches: one timeout, one normal completion ## Solution The escalation step appears in the hands-on section above. The complete workflow: ```ts title="workflows/process-order.ts" import { createHook, sleep } from "workflow"; import type { Order } from "@/lib/pizza"; import { sendOrderConfirmation } from "./steps/send-order-confirmation"; import { acknowledgeKitchen } from "./steps/acknowledge-kitchen"; import { dispatchDelivery } from "./steps/dispatch-delivery"; import { markOutForDelivery } from "./steps/mark-out-for-delivery"; import { confirmDelivery } from "./steps/confirm-delivery"; import { sendReviewRequest } from "./steps/send-review-request"; import { sendEscalationEmail } from "./steps/send-escalation-email"; const KITCHEN_TIMEOUT = "20m"; export async function processOrder(order: Order): Promise<{ status: "delivered" | "escalated"; orderId: string; }> { "use workflow"; await sendOrderConfirmation(order); await acknowledgeKitchen(order); using kitchenHook = createHook<{ readyAt: string }>({ token: `kitchen:${order.id}`, }); const kitchenResult = await Promise.race([ kitchenHook.then((payload) => ({ kind: "ready" as const, payload })), sleep(KITCHEN_TIMEOUT).then(() => ({ kind: "timeout" as const })), ]); if (kitchenResult.kind === "timeout") { await sendEscalationEmail(order); return { status: "escalated", orderId: order.id }; } await dispatchDelivery(order); using pickupHook = createHook<{ pickedUpAt: string }>({ token: `driver-pickup:${order.id}`, }); await pickupHook; await markOutForDelivery(order); using deliveryHook = createHook<{ deliveredAt: string }>({ token: `driver-delivered:${order.id}`, }); await deliveryHook; await confirmDelivery(order); await sendReviewRequest(order); return { status: "delivered", orderId: order.id }; } ``` The workflow is now feature-complete on the happy path. Section 4 is where it stops pretending the unhappy path doesn't exist. --- title: "Retries for free" description: "Introduce flakiness to acknowledgeKitchen, customize maxRetries, and observe automatic retries with backoff in the dashboard." canonical_url: "https://vercel.com/academy/workflow-foundations/the-kitchen-is-flaky" md_url: "https://vercel.com/academy/workflow-foundations/the-kitchen-is-flaky.md" docset_id: "vercel-academy" doc_version: "1.0" last_updated: "2026-06-05T22:31:16.266Z" content_type: "lesson" course: "workflow-foundations" course_title: "Workflow Foundations" prerequisites: [] ---A modern Next.js app for displaying customer reviews
A modern Next.js app for displaying customer reviews
{product.description}
{product.reviews.length} reviews
{review.reviewer}
{review.review}
{product.description}
{product.description}
{product.description}
{product.description}
The product you're looking for doesn't exist.
Back to ProductsBased on {product.reviews.length} customer ratings
{summary}
{product.description}
Based on {product.reviews.length} customer ratings
{isLoading ? ( Generating summary... ) : ( summary )}
{product.description}
{product.description}
Based on {product.reviews.length} customer ratings
Read the reviews below to see what customers are saying about this product.
Based on {product.reviews.length} customer ratings
{summary}
{item.category}
{item.category}