Next.js Server Action backed by a durable workflow in TypeScript

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-18

The 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-44

The 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-92

The 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-32

The 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 no url argument and no RESONATE_URL env var the constructor selects LocalNetwork, an in-process implementation, so no separate server is required to run the example. lib/resonate.ts:14; SDK selection at resonate-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 minifies function.name. lib/resonate.ts:16; two-arg overload signature at resonate-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 at resonate-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 at resonate-sdk-ts/src/resonate.ts:479-490.

handle.done() — non-blocking boolean check on completion. app/api/status/[id]/route.ts:21; interface at resonate-sdk-ts/src/resonate.ts:31-35.

handle.result() — resolves with the workflow's return value (here, the ReportResult returned 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; run is the LFC alias — interface at resonate-sdk-ts/src/context.ts:213-215, runtime binding run = this.lfc.bind(this) at resonate-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. submitReport calls resonate.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 to false without 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 returns 404 { "status": "not_found" } (app/api/status/[id]/route.ts:29-31).

Client gives up polling after 30s. app/page.tsx:34-45 polls 60 times at 500ms then transitions to an error UI state. The durable promise is unaffected — the workflow continues running; a reload can resume polling by calling GET /api/status/:id again 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 at README.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 Resonate constructor changes — pass a url (or set RESONATE_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:

Resonate constructor and LocalNetwork default: src/resonate.ts

Resonate.register, Resonate.run, Resonate.get, ResonateHandle.done, ResonateHandle.result: same file

Context.run (LFC alias): src/context.ts

Docs:

TypeScript SDK guide

Get started — examples