Wrap it in a workflow
Notice what your orders route currently does: it awaits the Resend call. The customer's browser sits there spinning while the SMTP handshake completes. If Resend is slow, the order placement is slow. If Resend is down, the order placement fails.
That's the part workflows fix.
When we wrap the step in a workflow and start it with start(), the route returns the moment the workflow is queued. The step runs in the background. The customer gets their redirect immediately. The email arrives whenever Resend is ready to send it. And if Resend has a bad day, the runtime retries without anyone knowing.
"use workflow" is the second directive. It marks a function as the orchestrator, the one allowed to call steps. Steps inside a workflow get the full treatment: automatic retries, an event log, observability. Steps outside a workflow are decorative.
Let's wake ours up.
Outcome
Create workflows/process-order.ts with the processOrder workflow that calls sendOrderConfirmation. Replace the direct step call in /api/orders with start(processOrder, [order]). Watch the first real workflow run in the local dashboard.
Fast Track
- Create
workflows/process-order.ts. Export an asyncprocessOrder(order)with"use workflow"as the first statement. CallsendOrderConfirmation(order)inside it. - In
/api/orders, replace the direct step call and the fakerunIdwithconst run = await start(processOrder, [order]). Userun.runId. - In another terminal, run
pnpm exec workflow web. Place an order. Watch the run land.
Hands-on exercise
1. Write the workflow.
Create workflows/process-order.ts:
import type { Order } from "@/lib/pizza";
import { sendOrderConfirmation } from "./steps/send-order-confirmation";
export async function processOrder(order: Order): Promise<{ orderId: string }> {
"use workflow";
await sendOrderConfirmation(order);
return { orderId: order.id };
}Three things to call out:
"use workflow" is the first statement in the body, mirroring "use step". The runtime looks for it at build time and treats the function as a workflow definition.
The workflow imports steps and awaits them. From the inside it looks like a regular async function calling other async functions. The runtime is doing the heavy lifting: each step runs in its own isolated environment, the result gets persisted, and if the step fails it gets retried automatically.
The return value gets persisted too. When the workflow finishes, anyone holding the runId can fetch the result later.
2. Replace the route.
Open app/api/orders/route.ts. The starter version generates a fake runId and calls the step directly. We're going to do neither.
import { start } from "workflow/api";
import { NextResponse } from "next/server";
import { recordOrder } from "@/lib/orders-store";
import { processOrder } from "@/workflows/process-order";
import type { Order, PizzaName, Size, Crust } from "@/lib/pizza";
type IncomingOrder = {
customerName: string;
email: string;
pizza: PizzaName;
size: Size;
crust: Crust;
address: string;
cardLast4: string;
};
export async function POST(request: Request) {
const body = (await request.json()) as IncomingOrder;
const order: Order = {
id: crypto.randomUUID(),
...body,
placedAt: new Date().toISOString(),
};
const run = await start(processOrder, [order]);
recordOrder(order, run.runId);
return NextResponse.json({ runId: run.runId });
}start(processOrder, [order]) enqueues the workflow with order as its first argument. It returns a Run object. run.runId is available synchronously, with no extra await on that property. The workflow itself runs in the background; we don't wait for it.
We also delete the direct sendOrderConfirmation import. The step is now called from inside the workflow, which is where it belongs.
3. Open the dashboard.
In a second terminal:
pnpm exec workflow webThat boots the local Workflow dashboard at http://localhost:3700. Leave it open.
Try It
Place an order from http://localhost:3000. Two things happen:
The browser response is faster than before. The route no longer waits for Resend, so the redirect happens as soon as the workflow is queued.
In the dashboard at http://localhost:3700, a new run appears. Click into it. You'll see a timeline:
processOrder completed
└─ sendOrderConfirmation completed
input: { id: "0a4f…", pizza: "Carbonara", ... }
output: undefined
attempts: 1
The email still arrives. Same Resend call as before. But now you've got a record of it. Every step input, every step output, every attempt is logged.
Put nope@invalid.fake in the email field and place an order. Watch the run go red in the dashboard. Click in: the step shows the FatalError we threw, with the original Resend error message. That's observability you didn't have to write.
Commit
feat(workflow): wrap order processing in a workflow
Done-When
workflows/process-order.tsexists with"use workflow"and callssendOrderConfirmation/api/orderscallsstart(processOrder, [order])and usesrun.runId- Placing an order from the UI sends an email AND creates a run in the local dashboard
- A failing email (bad address) shows up as a red run with the FatalError visible
Solution
workflows/process-order.ts:
import type { Order } from "@/lib/pizza";
import { sendOrderConfirmation } from "./steps/send-order-confirmation";
export async function processOrder(order: Order): Promise<{ orderId: string }> {
"use workflow";
await sendOrderConfirmation(order);
return { orderId: order.id };
}app/api/orders/route.ts:
import { start } from "workflow/api";
import { NextResponse } from "next/server";
import { recordOrder } from "@/lib/orders-store";
import { processOrder } from "@/workflows/process-order";
import type { Order, PizzaName, Size, Crust } from "@/lib/pizza";
type IncomingOrder = {
customerName: string;
email: string;
pizza: PizzaName;
size: Size;
crust: Crust;
address: string;
cardLast4: string;
};
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(),
};
const run = await start(processOrder, [order]);
recordOrder(order, run.runId);
return NextResponse.json({ runId: run.runId });
}One step. One workflow. One start() call. That's the entire setup. Everything else this course teaches is variations on this skeleton.
Was this helpful?