4 min readResonate HQJust published

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

How a Server Action and a status route share a durable promise id so the page can poll for completion without holding the request open.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

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