workflow
Version:
Workflow SDK - Build durable, resilient, and observable workflows
785 lines (582 loc) • 27 kB
text/mdx
---
title: NestJS
description: Set up your first durable workflow in a NestJS application.
type: guide
summary: Set up Workflow SDK in a NestJS app.
prerequisites:
- /docs/getting-started
related:
- /docs/foundations/workflows-and-steps
---
<CopyPrompt
text="In this NestJS app, run `npm i workflow @workflow/nest` and `npm i -D @swc/cli @swc/core`. Configure `nest-cli.json` with `compilerOptions.builder: "swc"` and `deleteOutDir: true`. Run `npx @workflow/nest init`, add `.swcrc` to `.gitignore`, and set package scripts `prebuild: "npx @workflow/nest init --force"` and `start:dev: "npx @workflow/nest init --force && nest start --watch"`. Import `WorkflowModule.forRoot()` from `@workflow/nest` in `src/app.module.ts` (use `{ moduleType: "commonjs", distDir: "dist" }` if compiling CommonJS). Create `src/workflows/user-signup.ts` with `"use workflow"`, `sleep`, and `"use step"` helpers. Add a `POST /signup` controller method that reads `email`, calls `start(handleUserSignup, [email])` from `workflow/api`, and returns JSON. Run `npm run start:dev`, call `curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/signup`, and inspect with `npx workflow web` or `npx workflow inspect runs`."
/>
Set up your first durable workflow in a NestJS app and learn the core Workflow SDK concepts.
<Callout>
NestJS integration is in beta. Deployment to Vercel is supported via the
`workflow-nest build --vercel` command. See [Deploy to Vercel](#deploy-to-vercel) below.
</Callout>
---
<Steps>
<Step>
## Create your NestJS project
Start by creating a new NestJS project using the NestJS CLI.
```bash
npm i -g /cli
nest new my-workflow-app
```
Enter the newly made directory:
```bash
cd my-workflow-app
```
### Install `workflow`
```package-install
npm i workflow /nest
```
### Choose your module format
NestJS projects using `/nest` can compile as either ESM or CommonJS. Choose the setup that matches your SWC output instead of assuming ESM is required.
#### ESM (default)
Use this when your NestJS project is configured as an ES module app.
```json title="package.json" lineNumbers
{
"name": "my-workflow-app",
"type": "module"
}
```
<Callout>
When using ESM with NestJS, local imports must include the `.js` extension (e.g., `import { AppModule } from './app.module.js'`). This applies even though your source files are `.ts`.
</Callout>
#### CommonJS
Use this when your NestJS project compiles CommonJS via SWC.
```typescript title="src/app.module.ts" lineNumbers
import { Module } from '@nestjs/common';
import { WorkflowModule } from '@workflow/nest';
({
imports: [
WorkflowModule.forRoot({
moduleType: 'commonjs',
distDir: 'dist',
}),
],
})
export class AppModule {}
```
<Callout type="info">
`distDir` should match the directory where NestJS writes compiled `.js` files. In the default SWC setup, that is `dist`.
</Callout>
### Configure NestJS to use SWC
NestJS supports SWC as an alternative compiler for faster builds. The Workflow SDK uses an SWC plugin to transform workflow files.
Install the required SWC packages:
```package-install
npm i -D /cli @swc/core
```
Ensure your `nest-cli.json` has SWC as the builder:
```json title="nest-cli.json" lineNumbers
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"builder": "swc",
"deleteOutDir": true
}
}
```
### Initialize SWC configuration
Run the init command to generate the SWC configuration:
```bash
npx /nest init
```
This creates a `.swcrc` file configured with the Workflow SWC plugin for step-mode transformations.
<Callout>
Add `.swcrc` to your `.gitignore` as it contains machine-specific absolute paths that shouldn't be committed.
</Callout>
### Update `package.json`
Add scripts to regenerate the SWC configuration before builds:
```json title="package.json" lineNumbers
{
"scripts": {
"prebuild": "npx @workflow/nest init --force",
"build": "nest build",
"start:dev": "npx @workflow/nest init --force && nest start --watch"
}
}
```
<Details>
<Summary>Set up IntelliSense for TypeScript (optional)</Summary>
To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`:
```json title="tsconfig.json" lineNumbers
{
"compilerOptions": {
// ... rest of your TypeScript config
"plugins": [
{
"name": "workflow" // [!code highlight]
}
]
}
}
```
</Details>
</Step>
<Step>
## Import the WorkflowModule
In your `app.module.ts`, import the `WorkflowModule` and keep the module format you chose above.
```typescript title="src/app.module.ts" lineNumbers
import { Module } from '@nestjs/common';
import { WorkflowModule } from '@workflow/nest';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
({
imports: [WorkflowModule.forRoot()], // [!code highlight]
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
```
<Callout type="info">
If you chose CommonJS above, keep the CommonJS options here as well:
{/* @skip-typecheck - config snippet, full import shown above */}
```typescript
WorkflowModule.forRoot({
moduleType: 'commonjs',
distDir: 'dist',
})
```
The `.js` local import specifiers in this example are the ESM form.
</Callout>
The `WorkflowModule` handles workflow bundle building and provides HTTP routing for workflow execution at `.well-known/workflow/v1/`.
</Step>
<Step>
## Create your first workflow
Create a new file for our first workflow in the `src/workflows` directory:
<Callout>
Workflow files must be inside the `src/` directory so they get compiled with the SWC plugin that enables the `start()` function to work correctly.
</Callout>
<Callout type="info">
If `start()` says it received an invalid workflow function, check both of these first:
1. The workflow function includes `"use workflow"`.
2. The workflow file lives inside `src/` so NestJS compiles it with the SWC plugin.
See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.
</Callout>
```typescript title="src/workflows/user-signup.ts" lineNumbers
import { sleep } from "workflow";
export async function handleUserSignup(email: string) {
"use workflow"; // [!code highlight]
const user = await createUser(email);
await sendWelcomeEmail(user);
await sleep("5s"); // Pause for 5s - doesn't consume any resources
await sendOnboardingEmail(user);
return { userId: user.id, status: "onboarded" };
}
```
We'll fill in those functions next, but first review this code:
- We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**.
- The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long.
## Create your workflow steps
Define the missing functions.
```typescript title="src/workflows/user-signup.ts" lineNumbers
import { FatalError } from "workflow";
// Our workflow function defined earlier
async function createUser(email: string) {
"use step"; // [!code highlight]
console.log(`Creating user with email: ${email}`);
// Full Node.js access - database calls, APIs, etc.
return { id: crypto.randomUUID(), email };
}
async function sendWelcomeEmail(user: { id: string; email: string }) {
"use step"; // [!code highlight]
console.log(`Sending welcome email to user: ${user.id}`);
if (Math.random() < 0.3) {
// By default, steps will be retried for unhandled errors
throw new Error("Retryable!");
}
}
async function sendOnboardingEmail(user: { id: string; email: string }) {
"use step"; // [!code highlight]
if (!user.email.includes("@")) {
// To skip retrying, throw a FatalError instead
throw new FatalError("Invalid Email");
}
console.log(`Sending onboarding email to user: ${user.id}`);
}
```
Taking a look at this code:
- Business logic lives inside **steps**. When a workflow invokes a step, the workflow suspends while the step runs with full Node.js access. The combined handler may execute it inline; queued retries and continuations return through the same flow route.
- If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count).
- Steps can throw a `FatalError` if an error is intentional and should not be retried.
<Callout>
We'll dive deeper into workflows, steps, and other ways to suspend or handle
events in [Foundations](/docs/foundations).
</Callout>
</Step>
<Step>
## Create your controller
To invoke your new workflow, update your controller with a new endpoint:
{/*@skip-typecheck - NestJS decorators require special TypeScript config*/}
```typescript title="src/app.controller.ts" lineNumbers
import { Body, Controller, Post } from '@nestjs/common';
import { start } from 'workflow/api';
import { handleUserSignup } from './workflows/user-signup.js';
()
export class AppController {
('signup')
async signup(() body: { email: string }) {
await start(handleUserSignup, [body.email]);
return { message: 'User signup workflow started' };
}
}
```
<Callout type="info">
If you chose CommonJS above, use the same local import style as the rest of your NestJS app here too. The `.js` extension shown in this example is the ESM form.
</Callout>
This creates a `POST` endpoint at `/signup` that will trigger your workflow.
</Step>
<Step>
## Run in development
To start your development server, run the following command in your terminal:
```bash
npm run start:dev
```
Once your development server is running, you can trigger your workflow by running this command in the terminal:
```bash
curl -X POST -H "Content-Type: application/json" -d '{"email":"hello@example.com"}' http://localhost:3000/signup
```
Check the NestJS development server logs to see your workflow execute as well as the steps that are being processed.
Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.
```bash
# Open the observability Web UI
npx workflow web
# or if you prefer a terminal interface, use the CLI inspect command
npx workflow inspect runs
```

</Step>
<Step>
## Deploy to Vercel
Because NestJS is not a Vercel-native framework, the Workflow SDK produces a
[Build Output API](https://vercel.com/docs/build-output-api) directory for you. This emits the
workflow queue-consumer function (registered with `experimentalTriggers` so Vercel's queue can
discover it) alongside your NestJS app as a catch-all function. Without it, workflow runs stay
`pending` because nothing consumes the queue.
Add a Vercel-specific entry module that default-exports your Nest app's underlying Node handler:
```typescript title="_vercel/entry.ts" lineNumbers
import 'reflect-metadata';
import { NestFactory } from '/core';
import type { NestExpressApplication } from '/platform-express';
import express from 'express';
import { AppModule } from '../dist/app.module.js';
let ready: Promise<express.Express> | undefined;
async function createHandler() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
await app.init();
return app.getHttpAdapter().getInstance();
}
export default async function handler(req: express.Request, res: express.Response) {
ready ??= createHandler();
return (await ready)(req, res);
}
```
No module change is needed for the in-process build: `skipBuild` defaults to `true` when the
`VERCEL` environment variable is set, because the Build Output already contains the bundles and
the deployed filesystem is read-only.
Set your build command so the Build Output is produced after `nest build`:
```json title="package.json" lineNumbers
{
"scripts": {
"vercel-build": "workflow-nest init --force && nest build && workflow-nest build --vercel --dirs src/workflows --entry _vercel/entry.ts"
}
}
```
Deploy as usual. `workflow-nest build --vercel` runs automatically on Vercel (it also detects the
`VERCEL` environment variable), writing `.vercel/output` with the consumer function so your runs
execute instead of staying `pending`.
<Callout type="info">
The app is bundled with esbuild. A package that resolves a dependency at runtime
behind a `try`/`catch` — a database driver reached through an ORM, an optional
logger transport — looks like a hard dependency to the bundler, which fails the
build on the first one your app has not installed. NestJS's own optional peers
are handled for you; pass anything else to `--external`, for example
`--external oracledb,mysql2`. Native addons (`*.node`) are not traced into the
deployed function and are reported as a build warning.
</Callout>
</Step>
</Steps>
---
## Configuration options
The `WorkflowModule.forRoot()` method accepts optional configuration:
{/*@skip-typecheck - Configuration snippet, WorkflowModule not imported*/}
```typescript
WorkflowModule.forRoot({
// Directory to scan for workflow files (default: ['src'])
dirs: ['src'],
// Output directory for generated bundles (default: '.nestjs/workflow')
outDir: '.nestjs/workflow',
// Skip building when bundles are pre-built with `workflow-nest build`
// (default: true when VERCEL is set, false otherwise). Startup fails if the
// bundles are missing.
skipBuild: false,
// SWC module type: 'es6' (default) or 'commonjs'
// Set to 'commonjs' if your NestJS project compiles to CJS via SWC
moduleType: 'es6',
// Directory where NestJS compiles .ts to .js (default: 'dist')
// Only used when moduleType is 'commonjs'
// Should match the outDir in your tsconfig.json
distDir: 'dist',
// Source maps on generated workflow bundles (default: 'inline' in
// development, false in production).
// Accepts the same values as esbuild's sourcemap option: true, false,
// 'inline', 'linked', 'external', 'both'. Set to false for smaller
// function bundles (useful for staying under the Vercel 250 MB function
// size limit) at the cost of stack traces pointing at generated code.
// Can also be set via the WORKFLOW_SOURCEMAP environment variable.
sourcemap: 'inline',
// Route prefix the workflow endpoints are served under. Leave unset to adopt
// app.setGlobalPrefix() automatically; set it when a reverse proxy mounts the
// app on a sub-path NestJS does not know about. See "Global prefixes" below.
basePath: '/api',
// Start the target World's background workers with the app and close them on
// shutdown. Self-hosted Worlds (for example @workflow/world-postgres) need
// this or runs are created and never picked up. Leave off on Vercel.
manageWorldLifecycle: false,
// Load the generated bundles during startup instead of on the first request
// (default: true, or false when VERCEL is set because dedicated functions
// serve the bundles there).
preloadBundles: true,
// Keep the application's body parser away from the workflow routes, so queue
// deliveries are not capped at Express's 100 KB limit and signed webhook
// bodies stay byte-exact (default: true). See "Request bodies and body
// parsers" below.
bypassBodyParser: true,
});
```
Options can also come from other providers with `forRootAsync`:
{/*@skip-typecheck - Configuration snippet, imports shown above*/}
```typescript
WorkflowModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
basePath: config.get('API_PREFIX'),
}),
});
```
---
## Global prefixes and sub-paths
`app.setGlobalPrefix()` moves the workflow routes. The SDK has to generate its
queue callback and webhook URLs under the same prefix, or every delivery 404s and
runs stay `pending`.
`WorkflowModule` reads the global prefix during startup and adopts it, so this
works with no configuration:
{/*@skip-typecheck - Bootstrap snippet*/}
```typescript
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); // workflow URLs become /api/.well-known/workflow/v1/...
await app.listen(3000);
```
Set `basePath` explicitly when the prefix is applied outside NestJS, for example
by a reverse proxy that strips it before the request reaches your app. An
explicit `basePath` wins over the global prefix, and a disagreement between the
two is logged at startup.
For Vercel, pass the same value to the build so the deployed queue-consumer
function generates matching URLs:
```bash
workflow-nest build --vercel --base-path /api
```
<Callout type="info">
`setGlobalPrefix(prefix, { exclude })` that excludes the workflow routes is
honoured too: they stay at the origin root, and the SDK generates unprefixed
URLs to match. `app.enableVersioning()` does not move them at all; see
[Route versioning](#route-versioning).
</Callout>
---
## Request bodies and body parsers
The workflow routes are a byte pipe, in both directions and at whatever size the
data happens to be:
- The queue delivers run inputs, step inputs and step outputs in the body of a
`POST /.well-known/workflow/v1/flow`. Express caps request bodies at 100 KB
and NestJS installs that parser by default, so a workflow that passes an array
of any size around would get `413 request entity too large` on every delivery,
with nothing but a retry loop to show for it.
- A webhook that verifies a signature over its raw body (Stripe, GitHub,
Shopify, Slack) only verifies if the bytes the sender signed arrive unchanged,
and a parser that has already turned the request into an object has destroyed
them.
`WorkflowModule` handles both by keeping the application's body parsers away
from `.well-known/workflow/v1`, and nothing else. On **Express**, workflow
requests are read straight from the request stream, so they are neither
size-limited nor re-serialized, and your own routes keep the parsers and limits
you configured. No bootstrap configuration is needed, and `{ rawBody: true }` is
not required for signed webhooks.
Set `bypassBodyParser: false` to opt out and handle parsing yourself.
<Callout type="warn">
This stream bypass only applies to **uncompressed Express** requests. Two cases
fall back to re-serializing the parsed body with `JSON.stringify`, which does
**not** preserve whitespace or key order and therefore breaks webhook signature
verification (Stripe, GitHub, Shopify, Slack):
- **Fastify.** Fastify's content-type parser always consumes the request
stream before the route runs — `WorkflowModule` cannot stand it aside the way
it does Express's middleware — so the raw bytes are gone by the time the
handler sees them. Fastify's body limit is also enforced per instance rather
than per route; raise it on the adapter (for example
`new FastifyAdapter({ bodyLimit: 16 * 1024 * 1024 })`).
- **Compressed bodies.** A body that arrives with a `content-encoding` always
goes through the parser on Express too, because that is what inflates it.
For signed webhooks in either case, create the app with `{ rawBody: true }`
(`NestFactory.create(AppModule, new FastifyAdapter(), { rawBody: true })` on
Fastify) so the integration can recover the exact bytes the sender signed.
NestJS keeps them on `req.rawBody` on both platforms, so no extra plugin is
needed. Without it, the integration logs a one-time warning and falls back to
the lossy `JSON.stringify` path.
</Callout>
<Callout type="info">
On Vercel the flow and webhook routes are served by their own Build Output
functions rather than by your NestJS app, so this only affects self-hosted
deployments.
</Callout>
---
## Guards, interceptors, and pipes
The workflow routes are served by a controller inside your application, so a
global guard runs for them too. A guard that rejects unauthenticated requests
rejects every queue delivery and webhook with `403`, and runs stop making
progress.
Exempt them with `isWorkflowRequest`:
{/*@skip-typecheck - Illustrates the pattern, not a complete app*/}
```typescript
import { Injectable, type CanActivate, type ExecutionContext } from '/common';
import { isWorkflowRequest } from 'workflow/nest';
()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext) {
if (isWorkflowRequest(context)) return true;
// ...your own checks
}
}
```
The workflow routes authenticate their own callers — queue deliveries are signed
and webhook tokens are single-use secrets — so letting them past an application
guard exposes nothing.
Interceptors and exception filters are safe to leave in place. The handlers
write through `()`, so the exact status and body the workflow runtime
produced reach the caller, which is what the queue and third-party webhook
senders key off.
---
## Route versioning
`app.enableVersioning()` moves every route under a version segment. The workflow
controller is registered as `VERSION_NEUTRAL`, so it stays at
`.well-known/workflow/v1` whichever strategy you enable, and the URLs the SDK
generates keep resolving. A global prefix still applies on top, and is adopted
automatically.
---
## Fastify
`/platform-fastify` is supported alongside `@nestjs/platform-express`,
with two differences.
Fastify enforces its body limit before any content-type parser runs, and the
limit is per instance rather than per route, so it cannot be scoped to the
workflow routes. Raise it on the adapter; `WorkflowModule` warns at startup
while it is still at Fastify's 1 MiB default:
{/*@skip-typecheck - Bootstrap snippet*/}
```typescript
const app = await NestFactory.create(
AppModule,
new FastifyAdapter({ bodyLimit: 16 * 1024 * 1024 })
);
```
Fastify also answers content types it has no parser for with `415` before the
request reaches a controller. Register a catch-all parser if your webhooks send
`application/octet-stream` or another unparsed media type:
{/*@skip-typecheck - Bootstrap snippet*/}
```typescript
app
.getHttpAdapter()
.getInstance()
.addContentTypeParser('*', { parseAs: 'buffer' }, (_request, body, done) =>
done(null, body)
);
```
---
## NestJS dependency injection is not available in workflows and steps
Workflows and steps do not run inside your NestJS application. They are compiled
into separate bundles, so **the Nest injector, your providers, and anything
built on the request context are out of reach from `"use workflow"` and
`"use step"` code.**
Concretely, none of these work inside a step:
- injecting a provider, or resolving one with `app.get(MyService)`
- `()` classes reached through a module-level reference to the app
- request-scoped providers, and `AsyncLocalStorage` context such as `nestjs-cls`
- guards, interceptors, pipes, and filters
- the Nest `Logger`
Two mechanics cause this, and neither has a workaround:
1. A step's bundle gets **its own copy** of any application file it imports.
Module-level state is therefore duplicated, and a class imported into a step
is a different class object from the one your module registered. Nest uses the
class itself as the injection token, so `app.get(MyService)` from a step
raises `UnknownElementException` even when it can reach the app.
2. On Vercel, workflows and steps run in the queue-consumer function, a separate
function from the one serving your NestJS app. There is no shared process to
reach into.
Write steps as plain functions over their arguments, and keep the wiring in your
controllers and providers:
{/*@skip-typecheck - Illustrates the pattern, not a complete app*/}
```typescript
// A step takes what it needs as arguments and builds its own clients.
async function chargeCustomer(customerId: string, cents: number) {
'use step';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
return await stripe.charges.create({ customer: customerId, amount: cents });
}
// The controller stays a normal Nest controller with normal DI.
('billing')
export class BillingController {
constructor(private readonly customers: CustomerService) {}
('charge')
async charge(() body: { id: string }) {
const customer = await this.customers.find(body.id);
// Pass plain, serializable values into the workflow.
await start(billingWorkflow, [customer.id, customer.planCents]);
return { started: true };
}
}
```
Configuration a step needs should come from the environment rather than
`ConfigService`, and shared logic should live in plain modules that a step can
import without pulling in `/common`.
<Callout type="info">
Importing a file that uses `/common` into a step is supported and builds
correctly, it simply gives you a class with no injected dependencies. Prefer
plain functions so the intent is clear.
</Callout>
---
## Production checklist
- Run `workflow-nest build` in your build step and set `skipBuild: true`, or
leave `skipBuild` unset so the bundles are built during startup. With
`skipBuild` set and no bundles present, startup fails with an explicit error
rather than serving broken workflow routes.
- Exempt the workflow routes from any global guard with `isWorkflowRequest`, or
every queue delivery is rejected with `403`.
- Set `basePath` (or rely on the adopted global prefix) so generated URLs match
the routes NestJS serves.
- Set `manageWorldLifecycle: true` for a self-hosted World, and call
`app.enableShutdownHooks()` so the World is closed on a signal.
- On Fastify, raise the adapter's `bodyLimit` above the data your workflows pass
between steps.
- Install exactly one copy of `/core`. With two, NestJS hands
`/nest` neither the global prefix nor the HTTP adapter, which
silently disables prefix handling and the body-parser bypass; a warning is
logged at startup when this happens.
- `skipBuild` defaults to `true` when the `VERCEL` environment variable is set,
so no Vercel-specific branch is needed in your module configuration.
## Troubleshooting
### `start()` says it received an invalid workflow function
If you see this error:
```text
'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive.
```
Check both of these first:
1. The workflow function includes `"use workflow"`.
2. Your NestJS app imports and registers the `WorkflowModule`.
See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes.
## Next steps
- Learn more about the [Foundations](/docs/foundations).
- Check [Errors](/docs/errors) if you encounter issues.
- Explore the [API Reference](/docs/api-reference).