Send a confirmation email
Here's the trick of the Workflow SDK: directives don't change your code. They change what the runtime does with your code.
Your first step today is sendOrderConfirmation. It calls Resend. It sends an email. We could write it as a plain async function and it would work fine, until Resend hiccups. Then the request fails, the function returns, and Marge Pepperoni never gets her confirmation. The usual fix is the usual ceremony: try, catch, setTimeout, retry counter, hope.
Or we can write three letters.
Add "use step" to the top of the function. The function body is identical. Same Resend call, same params. But now the runtime has a contract with that function: this is a unit of work, track it, retry it on failure, log the inputs and outputs.
The directive doesn't change what your function does. It changes what happens around it.
Outcome
Create workflows/steps/send-order-confirmation.ts as your first step, then wire it into /api/orders so placing an order sends a real Resend email.
Fast Track
- Create
workflows/steps/send-order-confirmation.ts. Export an async functionsendOrderConfirmation(order: Order)that calls Resend and throws on failure. Put"use step"as the first statement in the body. - In
app/api/orders/route.ts, import the step andawait sendOrderConfirmation(order)before the redirect. - Place an order. Check your inbox.
Hands-on exercise
Steps live in workflows/steps/ by convention. One step per file keeps things tidy when you get into the dashboard later.
1. Write the step.
Create workflows/steps/send-order-confirmation.ts:
import { FatalError } from "workflow";
import { resend, FROM } from "@/lib/resend";
import { updateStatus } from "@/lib/orders-store";
import type { Order } from "@/lib/pizza";
export async function sendOrderConfirmation(order: Order): Promise<void> {
"use step";
const resp = await resend.emails.send({
from: FROM,
to: [order.email],
subject: `Order confirmed: ${order.size} ${order.pizza}`,
html: `
<p>Hi ${order.customerName},</p>
<p>We got your order for a ${order.size} ${order.pizza} on ${order.crust} crust.</p>
<p>We'll let you know when it's out for delivery to ${order.address}.</p>
<p>Sal</p>
`,
});
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:
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 });
}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:
- The browser redirects to
/orders/<some-uuid>and shows the order details. - A confirmation email lands in your inbox from
onboarding@resend.dev.
If you don't see the email, check the dev server output. Resend logs go through the standard console, and a missing or invalid RESEND_API_KEY shows up as a 401 from their API.
Try ordering a few times. Different pizzas. Different sizes. The email subject should reflect what you ordered. Marge would want her Carbonara confirmation to say "Carbonara," not "Margherita."
Commit
feat(workflow): add sendOrderConfirmation step
Done-When
workflows/steps/send-order-confirmation.tsexists with"use step"at the top of the function body- The step throws a
FatalErrorwhen Resend returns an error app/api/orders/route.tsimports and awaitssendOrderConfirmation(order)- Placing an order from the UI delivers an actual email to the address you entered
Solution
workflows/steps/send-order-confirmation.ts:
import { FatalError } from "workflow";
import { resend, FROM } from "@/lib/resend";
import { updateStatus } from "@/lib/orders-store";
import type { Order } from "@/lib/pizza";
export async function sendOrderConfirmation(order: Order): Promise<void> {
"use step";
const resp = await resend.emails.send({
from: FROM,
to: [order.email],
subject: `Order confirmed: ${order.size} ${order.pizza}`,
html: `
<p>Hi ${order.customerName},</p>
<p>We got your order for a ${order.size} ${order.pizza} on ${order.crust} crust.</p>
<p>We'll let you know when it's out for delivery to ${order.address}.</p>
<p>Sal</p>
`,
});
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.
Was this helpful?