A Next.js page needs to trigger multi-step background work (validate, query a slow data warehouse, build a file, notify the user) without holding the Server Action's response open and without losing the job if the Node process restarts mid-run. The shape of the Resonate solution is a generator workflow registered once at module load under an explicit string name; the Server Action invokes it via resonate.run(id, fn, args) and returns the id to the client; a separate GET /api/status/:id route resolves the same id via resonate.get(id) and reports completion state. The example-nextjs-integration-ts repo shows this in 6 source files (4 under app/, 2 under lib/).
The shape of the solution
The Resonate instance is constructed and the workflow is registered once at module load. Node's module cache makes the instance a per-process singleton shared by every Server Action and API route in the app.
import { Resonate } from "@resonatehq/sdk";
import { generateReport } from "./workflow";
// ...
const resonate = new Resonate();
// Pass an explicit name — Next.js minification strips function.name at build time
resonate.register("generateReport", generateReport);
export { resonate };
// from example-nextjs-integration-ts/lib/resonate.ts:1-18The Server Action builds a request id, hands it to resonate.run, and returns the id to the client without awaiting the workflow result.
"use server";
// ...
import { resonate } from "../lib/resonate";
import { generateReport, type ReportRequest } from "../lib/workflow";
export async function submitReport(formData: FormData): Promise<{ id: string }> {
const type = formData.get("type") as ReportRequest["type"];
const period = formData.get("period") as string;
const req: ReportRequest = {
id: `rpt_${Date.now()}`,
type: type ?? "sales",
period: period ?? "2025-Q4",
};
// Fire-and-forget: workflow runs in background.
// The report ID is the promise ID — polling uses it for status.
resonate.run(`report/${req.id}`, generateReport, req).catch(console.error);
return { id: req.id };
}
// from example-nextjs-integration-ts/app/actions.ts:1-44The workflow itself is a generator. Each yield* ctx.run(step, ...args) is a durable checkpoint.
export function* generateReport(
ctx: Context,
req: ReportRequest,
): Generator<any, ReportResult, any> {
yield* ctx.run(validateRequest, req);
const rowCount = yield* ctx.run(queryDataWarehouse, req);
const fileUrl = yield* ctx.run(generateFile, req, rowCount);
yield* ctx.run(notifyUser, req, fileUrl);
return {
reportId: req.id,
type: req.type,
period: req.period,
rowCount,
fileUrl,
completedAt: new Date().toISOString(),
};
}
// from example-nextjs-integration-ts/lib/workflow.ts:72-92The status route resolves the same id and asks the handle whether it has completed.
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const handle = await resonate.get(`report/${id}`);
const done = await handle.done();
if (!done) {
return NextResponse.json({ status: "processing" });
}
const result = (await handle.result()) as ReportResult;
return NextResponse.json({ status: "done", result });
} catch {
return NextResponse.json({ status: "not_found" }, { status: 404 });
}
}
// from example-nextjs-integration-ts/app/api/status/[id]/route.ts:13-32The client component calls the Server Action, gets the id back, and polls the status route every 500ms for up to 60 attempts (app/page.tsx:33-45).
The durable primitives in play
new Resonate()— instantiates the SDK. With nourlargument and noRESONATE_URLenv var the constructor selectsLocalNetwork, an in-process implementation, so no separate server is required to run the example.lib/resonate.ts:14; SDK selection atresonate-sdk-ts/src/resonate.ts:171.resonate.register("generateReport", generateReport)— registers the generator under an explicit string name. The repo passes the name explicitly because the Next.js production build minifiesfunction.name.lib/resonate.ts:16; two-arg overload signature atresonate-sdk-ts/src/resonate.ts:240-246.resonate.run(id, generateReport, req)— starts a durable execution keyed on the id. A second call with the same id reattaches to the existing promise rather than starting new work. Used fire-and-forget here via.catch(console.error).app/actions.ts:41; SDK overloads + impl atresonate-sdk-ts/src/resonate.ts:283-288.resonate.get(id)— looks up an existing durable promise by id, used by the status route so no in-memory request-to-handle map is needed.app/api/status/[id]/route.ts:20; SDK atresonate-sdk-ts/src/resonate.ts:479-490.handle.done()— non-blocking boolean check on completion.app/api/status/[id]/route.ts:21; interface atresonate-sdk-ts/src/resonate.ts:31-35.handle.result()— resolves with the workflow's return value (here, theReportResultreturned from the generator).app/api/status/[id]/route.ts:27.yield* ctx.run(stepFn, ...args)— durable checkpoint for one step. On replay, completed checkpoints short-circuit to their stored value; the step body is not re-executed.lib/workflow.ts:76, 78, 80, 82;runis the LFC alias — interface atresonate-sdk-ts/src/context.ts:213-215, runtime bindingrun = this.lfc.bind(this)atresonate-sdk-ts/src/context.ts:283.
What the SDK handles vs. what you write
You write: the Server Action body, the API route handler, the generator function generateReport, the four plain async step functions (validateRequest, queryDataWarehouse, generateFile, notifyUser), and the promise id format (`report/${req.id}`) used by both routes.
The SDK handles: persisting the durable promise when resonate.run is called, checkpointing each ctx.run step result so a replay short-circuits through resolved steps, retrying a thrown step without re-running earlier steps, deduplicating a second resonate.run call with the same id to the existing handle, and exposing the same handle to a different route via resonate.get(id) against the in-process registry. The Server Action never awaits workflow completion; the API route never holds a connection open waiting for it.
The step functions (lib/workflow.ts:34, 41, 50, 63) are plain TypeScript — no decorator, no event schema, no framework wrapper. The only Resonate-aware code in the app is lib/resonate.ts (registration) and the yield* ctx.run calls inside generateReport.
Failure modes covered
- Server Action returns before the workflow finishes.
submitReportcallsresonate.run(...).catch(console.error)and immediately returns{ id: req.id }(app/actions.ts:41-43). The HTTP response does not block on the four steps. - Client polls before the workflow finishes.
handle.done()resolves tofalsewithout awaiting completion (app/api/status/[id]/route.ts:21-25); the route returns{ "status": "processing" }and frees the connection. - Client polls a non-existent id.
resonate.get(...)rejects; the catch arm returns404 { "status": "not_found" }(app/api/status/[id]/route.ts:29-31). - Client gives up polling after 30s.
app/page.tsx:34-45polls 60 times at 500ms then transitions to anerrorUI state. The durable promise is unaffected — the workflow continues running; a reload can resume polling by callingGET /api/status/:idagain with the same id. - Node process restarts mid-workflow. Each
yield* ctx.run(step, ...)is a durable checkpoint, so on replay the generator skips through resolved checkpoints and resumes from the first incomplete step (lib/workflow.ts:76-82; README atREADME.md:81). In pure embedded mode (new Resonate()with no url) the durable store is in-memory; resilience across a hard process crash requires connecting to a Resonate server so checkpoints land in an external store. - Production build minifies
function.name. Registering with an explicit string name (lib/resonate.ts:16) decouples the registry key from the minified function name; an unnamed registration would not resolve at replay time.
When to reach for this pattern
- If a Next.js Server Action needs to trigger work that lasts longer than the form-submission response window, and the client can be content with a returned id and a poll URL.
- If the trigger and the status check live in the same Next.js app and you do not want an external queue, broker, or worker process just to track in-flight jobs.
- If the workflow has steps whose results feed the next step (e.g., a row count from a warehouse query is the input to file generation) and you want straight-line generator code instead of a state machine.
- If the workflow id needs to be resolvable from a different route handler than the one that started it —
resonate.get(id)reads the same in-process registry from any route. - If the workflow eventually needs cross-process durability without rewriting the route or workflow code, only the
Resonateconstructor changes — pass aurl(or setRESONATE_URL) to attach a Resonate server.
Sources
- Example repo: github.com/resonatehq-examples/example-nextjs-integration-ts
- TypeScript SDK repo: github.com/resonatehq/resonate-sdk-ts
- SDK primitives cited:
Resonateconstructor andLocalNetworkdefault:src/resonate.tsResonate.register,Resonate.run,Resonate.get,ResonateHandle.done,ResonateHandle.result: same fileContext.run(LFC alias):src/context.ts
- Docs:
