Framework Integration
Use
to build notification workflows
in code, alongside your application source. Workflows live in your repo, content is rendered using libraries you already use (React Email, Vue Email, Svelte Email), and a single HTTP endpoint (the Bridge) lets Novu Cloud execute them with just-in-time data from your services.
Use this skill when building workflows
in code. For workflows authored in the Novu Dashboard, just trigger them via
— no Framework needed.
When to Use the Framework
| Use Framework | Use Dashboard Workflows |
|---|
| Workflows must live in source control / GitOps | Non-technical peers own all the content |
| Need just-in-time data from your DB / APIs | All data fits in the trigger payload |
| Render emails with React/Vue/Svelte Email | Block editor is enough |
| Execute custom code (LLMs, third-party APIs) | Pure send-only flows |
| Need typed payload + step controls | Quick prototype |
The two approaches coexist — a single environment can have both code-defined and dashboard-defined workflows.
How It Works
- You define workflows in code with from .
- You expose a single HTTP route in your app — the Bridge Endpoint.
- You sync the bridge URL to Novu Cloud (via or GitHub Action).
- Novu Cloud calls your bridge over an authenticated tunnel during workflow execution to fetch step content with the latest data.
Trigger ──► Novu Cloud Worker ──► Your Bridge (/api/novu) ──► Provider (SendGrid, FCM, …)
Quick Start
1. Bootstrap a project
bash
npx novu init --secret-key=<YOUR_NOVU_SECRET_KEY>
This creates a sample bridge app with a workflow, env file, and a working
route.
2. Or add to an existing app
bash
npm install @novu/framework zod @react-email/components react-email
bash
NOVU_SECRET_KEY=<YOUR_NOVU_SECRET_KEY>
3. Define a workflow
typescript
import { workflow } from "@novu/framework";
import { z } from "zod";
export const welcomeWorkflow = workflow(
"welcome-email",
async ({ step, payload, subscriber }) => {
await step.email("send-email", async (controls) => {
return {
subject: controls.subject,
body: `Welcome ${subscriber.firstName ?? payload.userName}!`,
};
}, {
controlSchema: z.object({
subject: z.string().default("Welcome to {{payload.appName}}"),
}),
});
},
{
payloadSchema: z.object({
userName: z.string(),
appName: z.string().default("Acme"),
}),
name: "Welcome Email",
description: "Sent when a new user signs up",
tags: ["onboarding"],
}
);
4. Mount the Bridge Endpoint
Pick the wrapper that matches your framework — see
Bridge Endpoint Setup below.
5. Run the Local Studio
bash
npx novu@latest dev --port <YOUR_APP_PORT>
Open
to preview workflows, edit controls, and trigger test events. The Studio creates a public tunnel automatically so Novu Cloud can reach your local bridge.
Workflow Anatomy
Designing the workflow itself? See
for channel selection, severity,
, digest defaults, step conditions, and the 9 reference templates (order confirmation, payment failed, account suspended, comment, trial expiring, password reset, webhook fan-out, fetch-then-notify). The Framework SKILL covers
how to express those decisions in code;
covers
what to decide.
typescript
workflow(workflowId, handler, options);
| Param | Type | Description |
|---|
| | Unique identifier in your environment |
| ({ step, payload, subscriber }) => Promise<void>
| Workflow body — calls steps in order |
| | Schema, name, description, tags, preferences |
Workflow Options
| Option | Type | Purpose |
|---|
| ZodSchema | JsonSchema | ClassValidatorClass
| Validates the trigger payload, infers TS type for |
| | Human-readable name shown in Dashboard / |
| | Description shown in Dashboard |
| | Categorize for filtering / Inbox tabs |
| 'low' | 'medium' | 'high'
| Visual prioritization in the Inbox. Leave unset for most workflows. |
| | Bypasses subscriber preferences, skips digest, runs without delays. Reserve for must-deliver events. |
| | Default channel preferences and flag |
Workflow Context
The handler receives
{ step, payload, subscriber }
:
- — channel and action step builders (, , , …)
- — strongly-typed data passed at trigger time, validated against
- —
{ subscriberId, firstName?, lastName?, locale?, data?, ... }
of the recipient
Channel Steps
All channel steps share the same shape:
typescript
await step.<channel>(stepId, resolver, options?);
| Step | Output Required | Notable Outputs | Returns Result |
|---|
| , | , , | No |
| | — | No |
| (or ), | , , | No |
| | — (override per-provider) | No |
| | , , , , , | { seen, read, lastSeenDate, lastReadDate }
|
Email Step
typescript
await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<WelcomeEmail name={subscriber.firstName} />),
from: "hello@acme.com",
replyTo: "support@acme.com",
}));
In-App Step (rich payload)
typescript
await step.inApp("inbox", async () => ({
subject: "Welcome to Acme!",
body: "We are excited to have you on board.",
avatar: "https://acme.com/avatar.png",
redirect: { url: "/welcome", target: "_self" },
primaryAction: {
label: "Get Started",
redirect: { url: "/get-started", target: "_self" },
},
data: { entityType: "user", entityId: payload.userId },
}));
The In-App step
returns { seen, read, lastSeenDate, lastReadDate }
— use it to drive the
of subsequent steps.
SMS / Push / Chat
typescript
await step.sms("verification", async () => ({
body: `Your code is ${payload.code}`,
}));
await step.push("new-message", async () => ({
title: "New message",
body: payload.preview,
data: { messageId: payload.id },
}));
await step.chat("notify", async () => ({
body: `:rocket: Deploy ${payload.id} succeeded`,
}));
Action Steps
Pause workflow execution before the next step.
typescript
await step.delay("wait-a-day", async () => ({
unit: "days",
amount: 1,
}));
Supported
values:
,
,
,
,
,
.
Aggregate multiple triggers into a single notification over a window.
typescript
const { events } = await step.digest("daily", async () => ({
unit: "days",
amount: 1,
digestKey: payload.projectId, // optional — group by custom key
}));
await step.email("summary", async () => ({
subject: `${events.length} updates today`,
body: render(<DigestEmail events={events} />),
}));
Use
instead of
/
for cron-based digests. Each digest event has
.
Only one digest per workflow — chain a second workflow via
if you need a two-stage digest.
Call an external HTTP endpoint as part of the workflow — webhook fan-out or just-in-time data fetch.
typescript
const plan = await step.http("fetch-plan", async () => ({
method: "GET",
url: `https://api.example.com/users/${payload.userId}/plan`,
responseBodySchema: {
type: "object",
properties: { planName: { type: "string" }, renewalDate: { type: "string" } },
required: ["planName", "renewalDate"],
} as const,
}));
await step.email("notify", async () => ({
subject: `Your ${plan.planName} plan`,
body: `Renews on ${plan.renewalDate}.`,
}));
Webhook-style:
typescript
await step.http("webhook", async () => ({
method: "POST",
url: payload.webhookUrl,
headers: [{ key: "Content-Type", value: "application/json" }],
body: [
{ key: "event", value: "payment_failed" },
{ key: "subscriberId", value: subscriber.subscriberId },
],
continueOnFailure: true,
}));
When a subsequent step references HTTP response data, the HTTP step
must declare a
. Only properties declared in the schema are addressable as
{{ steps.<http-step-id>.<property> }}
.
Run arbitrary code and persist its result for later steps.
typescript
const task = await step.custom("fetch-task", async () => {
const t = await db.fetchTask(payload.taskId);
return { id: t.id, title: t.title, complete: t.complete };
}, {
outputSchema: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
complete: { type: "boolean" },
},
required: ["id", "complete"],
} as const,
});
await step.email("reminder", async () => ({
subject: `Reminder: ${task.title}`,
body: "Please complete your task.",
}), {
skip: () => task.complete,
});
The custom step result is
only usable inside subsequent step
,
, and
functions — not in step controls.
Step Options
typescript
await step.email(stepId, resolver, {
controlSchema, // Zod | JSON Schema | Class-Validator class
skip, // (controls) => boolean | Promise<boolean>
providers, // per-provider override callbacks
disableOutputSanitization, // boolean — for raw HTML in Inbox
});
Conditionally skip a step. Receives the resolved controls.
typescript
await step.email("follow-up", resolver, {
skip: () => inAppNotification.read === true,
});
(Per-Step Provider Overrides)
Customize the request sent to the underlying provider — e.g. Slack
or SendGrid
.
typescript
await step.email("alert", resolver, {
providers: {
sendgrid: ({ controls, outputs }) => ({
from: "alerts@acme.com",
cc: ["ops@acme.com"],
_passthrough: {
body: { ip_pool_name: "transactional" },
headers: { "X-Custom": "value" },
},
}),
},
});
deep-merges into the final provider request — typed provider keys take precedence over
.
disableOutputSanitization
Allow raw HTML / unescaped characters in the output (e.g.
in In-App
):
typescript
await step.inApp("link", async () => ({
body: "Check it out",
data: { link: "/p/123?active=true&env=prod" },
}), { disableOutputSanitization: true });
Payload Schema
The payload is the data passed at trigger time. Define a schema to get typed
and runtime validation.
With Zod
typescript
import { z } from "zod";
workflow("comment", handler, {
payloadSchema: z.object({
postId: z.number(),
authorName: z.string(),
comment: z.string().max(200),
}),
});
With JSON Schema
typescript
workflow("comment", handler, {
payloadSchema: {
type: "object",
properties: {
postId: { type: "number" },
authorName: { type: "string" },
comment: { type: "string", maxLength: 200 },
},
required: ["postId", "comment"],
additionalProperties: false,
} as const,
});
The
is required for TS to infer the payload type from JSON Schema.
With Class Validator
typescript
import { IsString, IsNumber } from "class-validator";
class CommentPayload {
@IsNumber() postId!: number;
@IsString() authorName!: string;
@IsString() comment!: string;
}
workflow("comment", handler, { payloadSchema: CommentPayload });
Requires
,
class-validator-jsonschema
,
. See
references/schema-validation.md
.
Step Controls — No-Code for Your Team
Controls are step-level inputs your non-technical peers can edit in the Novu Dashboard UI without touching code. They're validated by a schema you define (Zod / JSON Schema / Class-Validator).
typescript
await step.email("welcome", async (controls) => ({
subject: controls.subject,
body: render(<EmailTemplate hideBanner={controls.hideBanner} />),
}), {
controlSchema: z.object({
hideBanner: z.boolean().default(false),
subject: z.string().default("Hi {{subscriber.firstName | capitalize}}"),
}),
});
Variables in Controls
Control values support
LiquidJS templating:
- — any subscriber attribute
- — any payload field defined in
{{payload.invoiceDate | date: '%a, %b %d, %y'}}
— Liquid filters
{{subscriber.firstName | append: ': ' | append: payload.status | capitalize}}
— chained filters
Type
in the Dashboard UI to autocomplete available variables.
Controls vs Payload
| Controls | Payload |
|---|
| Edited by | Non-technical peers in Dashboard | Developers in code |
| Schema | per step | per workflow |
| Persistence | Stored in Novu Cloud per environment | Sent at trigger time |
| Use case | Subject, copy, styling, behaviour toggles | Dynamic per-trigger data |
Workflow Preferences
Define default channel preferences in code. See
for the full preference resolution model.
typescript
workflow("system-alert", handler, {
preferences: {
all: { enabled: true, readOnly: false },
channels: {
email: { enabled: true },
sms: { enabled: false },
inApp: { enabled: true },
},
},
});
- makes the workflow critical — subscribers cannot disable it.
- is the fallback for any channel not in .
- Default if omitted: , for all channels.
Bridge Endpoint Setup
The Bridge is a single HTTP route (
by default) where Novu Cloud calls your app to:
- Discover registered workflows ()
- Resolve step content for a given subscriber + payload ()
- Verify HMAC signatures on requests
Each framework ships a
wrapper that handles parsing, HMAC verification, and response shaping.
Next.js (App Router)
typescript
import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "@/novu/workflows";
export const { GET, POST, OPTIONS } = serve({
workflows: [welcomeWorkflow],
});
Next.js (Pages Router)
typescript
import { serve } from "@novu/framework/next";
import { welcomeWorkflow } from "../../novu/workflows";
export default serve({ workflows: [welcomeWorkflow] });
Express
typescript
import express from "express";
import { serve } from "@novu/framework/express";
import { welcomeWorkflow } from "./novu/workflows";
const app = express();
app.use(express.json()); // required
app.use("/api/novu", serve({ workflows: [welcomeWorkflow] }));
app.listen(4000);
NestJS
typescript
import { Module } from "@nestjs/common";
import { NovuModule } from "@novu/framework/nest";
import { welcomeWorkflow } from "./novu/workflows";
@Module({
imports: [
NovuModule.register({
apiPath: "/api/novu",
workflows: [welcomeWorkflow],
}),
],
})
export class AppModule {}
For dependency injection, use
— see
references/bridge-endpoint.md
.
Remix
typescript
import { serve } from "@novu/framework/remix";
import { welcomeWorkflow } from "../novu/workflows";
const handler = serve({ workflows: [welcomeWorkflow] });
export { handler as action, handler as loader };
SvelteKit
typescript
import { serve } from "@novu/framework/sveltekit";
import { welcomeWorkflow } from "$lib/novu/workflows";
export const { GET, POST, OPTIONS } = serve({ workflows: [welcomeWorkflow] });
Nuxt
typescript
import { serve } from "@novu/framework/nuxt";
import { welcomeWorkflow } from "~/novu/workflows";
export default defineEventHandler(serve({ workflows: [welcomeWorkflow] }));
H3
typescript
import { createApp, eventHandler, toNodeListener } from "h3";
import { createServer } from "node:http";
import { serve } from "@novu/framework/h3";
import { welcomeWorkflow } from "./novu/workflows";
const app = createApp();
app.use("/api/novu", eventHandler(serve({ workflows: [welcomeWorkflow] })));
createServer(toNodeListener(app)).listen(4000);
AWS Lambda
typescript
import { serve } from "@novu/framework/lambda";
import { welcomeWorkflow } from "./novu/workflows";
export const novu = serve({ workflows: [welcomeWorkflow] });
Custom (any framework)
typescript
import { NovuRequestHandler, ServeHandlerOptions } from "@novu/framework";
export const serve = (options: ServeHandlerOptions) =>
new NovuRequestHandler({
frameworkName: "my-framework",
...options,
handler: (req, res) => ({ /* method, headers, body, url, transformResponse */ }),
}).createHandler();
See
references/bridge-endpoint.md
for the full custom handler signature.
Local Studio
Live preview of your workflows with a public tunnel for Novu Cloud to reach your machine.
bash
npx novu@latest dev
# Defaults: --port 4000 --route /api/novu --studio-port 2022
CLI Flags
| Flag | Default | Purpose |
|---|
| , | | Your app's port |
| , | | Bridge route path |
| , | | Bridge origin |
| , | https://dashboard.novu.co
| Dashboard URL — use https://eu.dashboard.novu.co
for EU |
| , | | Studio UI port |
| , | auto | Self-hosted tunnel URL (e.g. ngrok) |
| , | | Skip the Studio UI |
bash
npx novu@latest dev --port 3002 --dashboard-url https://eu.dashboard.novu.co
The Studio:
- Auto-creates a stable tunnel URL like
https://<id>.novu.sh/api/novu
- Lets you edit Step Controls and Payload to preview different states
- Runs against
process.env.NODE_ENV=development
— HMAC verification is off to allow Studio access
- Has a "Sync" button to push state to Cloud (use CI/CD for real deployments)
Triggering Workflows
Code-defined workflows are triggered the same way as Dashboard workflows — using
from your trigger surface (server, queue worker, webhook handler):
typescript
import { Novu } from "@novu/api";
const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY });
await novu.trigger({
workflowId: "welcome-email",
to: { subscriberId: "user-123", email: "jane@acme.com" },
payload: { userName: "Jane", appName: "Acme" },
});
You can also trigger a workflow from inside a
of another workflow:
typescript
await step.custom("trigger-summary", async () => {
return await summaryWorkflow.trigger({
to: subscriber.subscriberId,
payload: { events: events.map(e => e.payload) },
});
});
See
for full trigger options (bulk, broadcast, topics, overrides, transactionId, cancel).
React Email (and friends)
Render emails using your existing component library.
React Email
bash
npm install @react-email/components react-email
tsx
import { Body, Container, Head, Html, render } from "@react-email/components";
export const WelcomeEmail = ({ name }: { name: string }) => (
<Html>
<Head />
<Body>
<Container>Hello {name}, welcome!</Container>
</Body>
</Html>
);
export const renderWelcome = (name: string) => render(<WelcomeEmail name={name} />);
typescript
await step.email("welcome", async () => ({
subject: "Welcome",
body: renderWelcome(payload.userName),
}));
Vue Email, Svelte Email, and Remix + React Email are also supported. See
references/email-templates.md
.
Translations (i18n)
For Framework-based workflows, translation lives in your code (not in the Novu Translation system, which targets Dashboard workflows). Use any i18n library (e.g. i18next) and resolve content from
inside the resolver.
typescript
import { workflow } from "@novu/framework";
import i18n from "./i18n";
export const localizedWorkflow = workflow(
"welcome-localized",
async ({ step, subscriber }) => {
await step.email("email", async (controls) => {
const t = i18n.getFixedT([subscriber.locale ?? controls.defaultLocale]);
return {
subject: t("welcomeEmailSubject", { username: subscriber.firstName }),
body: render(<Welcome subject={t("subject")} body={t("body")} />),
};
}, {
controlSchema: z.object({
defaultLocale: z.string().default("en_US"),
}),
});
},
);
See
references/translations.md
for a complete i18next + React Email example.
Tags
Tag a workflow to group it with related notifications (used by Inbox tabs and Dashboard filtering):
typescript
workflow("login-alert", handler, { tags: ["security"] });
workflow("password-change", handler, { tags: ["security"] });
In the Inbox, render a "Security" tab with
tabs={[{ label: "Security", filter: { tags: ["security"] } }]}
(see
).
Deployment
Sync via CLI
Push your workflows to Novu Cloud:
bash
npx novu@latest sync \
--bridge-url https://api.acme.com/api/novu \
--secret-key $NOVU_SECRET_KEY \
--api-url https://api.novu.co # use https://eu.api.novu.co for EU
GitHub Actions
yaml
name: Sync Novu Workflows
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: novuhq/actions-novu-sync@v2
with:
secret-key: ${{ secrets.NOVU_SECRET_KEY }}
bridge-url: ${{ secrets.NOVU_BRIDGE_URL }}
api-url: https://api.novu.co
GitOps Workflow
- Develop locally with the Studio against your own machine.
- Open a PR — CI runs against the Development environment to test e2e.
- Merge to — CI runs against Production.
GitLab CI, Jenkins, CircleCI, Bitbucket, Azure DevOps, and Travis CI all work via the CLI.
Production & Security
- Bridge URL must be publicly reachable over HTTPS. Novu Cloud auto-scales — no IP allowlist is published.
- HMAC verification is on by default when
NODE_ENV !== "development"
. The wrapper handles this — you don't need to write any code. Each request includes a header () that's verified against .
- Disable HMAC for local dev automatically via . Don't disable it in production.
- Vercel Preview URLs are protected by default — enable Protection Bypass for Automation and pass the bypass token as
?x-vercel-protection-bypass=<token>
in your bridge URL.
Custom Client
Override defaults globally:
typescript
import { Client as NovuFrameworkClient } from "@novu/framework";
import { serve } from "@novu/framework/next";
export const { GET, POST, OPTIONS } = serve({
client: new NovuFrameworkClient({
secretKey: process.env.NOVU_SECRET_KEY,
strictAuthentication: false, // disables HMAC — only for local dev
}),
workflows: [/* … */],
});
Environment variables read by the Client:
- — your secret key
- — defaults to (use for EU)
Common Pitfalls
- Bridge URL must be publicly reachable — won't work for Novu Cloud. Use the Studio tunnel locally; deploy publicly for production.
- is the trigger identifier — same id you'll pass to
novu.trigger({ workflowId })
. Use kebab-case and keep it stable.
- Step s must be unique within a workflow — duplicates throw at registration.
- on JSON Schema — without it, TS infers instead of literal types and becomes .
- Only one per workflow — chain a second workflow via for two-stage digest patterns.
- Digest / delay results from one trigger don't influence other triggers — they're per workflow run.
- Custom step results aren't usable in step controls — only in subsequent step , , or callbacks.
- Sync after every workflow change — Novu Cloud needs to know about new/renamed workflows and updated control schemas. Add to your CI/CD.
- HMAC fails locally if
NODE_ENV !== "development"
— set it to for the Studio to reach your bridge, or disable strict auth in your .
- Don't store the in the client bundle — it's server-only. Keep workflows + bridge route inside server code, not in any module.
- Provider override is unvalidated — typos won't error at compile time. Use known typed provider keys whenever possible.
- Changing a delay/digest step's content does not affect already-scheduled events — content is captured at the time of the original trigger.
- Workflow handlers must be deterministic across retries — Novu re-invokes the bridge to resolve step content. Avoid side-effects outside (custom is the only step whose result is durably persisted).
- requires Node.js ≥ 20.
Code Style Tips
- One file per workflow under
src/novu/workflows/<workflow-id>.ts
, re-exported from a barrel src/novu/workflows/index.ts
.
- Prefer Zod schemas — best autocomplete and inference. Use JSON Schema only when you need features Zod doesn't expose (, , ).
- Co-locate React Email templates next to the workflow that uses them (
src/novu/workflows/welcome/template.tsx
).
- Wrap shared logic into helpers (
fetchUser(payload.userId)
) for reuse.
- For NestJS, use with a so workflow definitions can inject services.
References
- Bridge Endpoint Setup — every framework wrapper, custom , NestJS DI
- Workflow & Step API — full options, all step types, conditional logic patterns
- Schema Validation — Zod, JSON Schema, Class Validator deep dive
- Email Templates — React, Vue, Svelte Email integrations
- Translations — i18next-based localized workflows
- Local Studio & CLI — every flag, tunnel modes, headless mode
- Deployment — , GitHub Action, GitOps recipe, EU region
- Production & Security — HMAC, public bridge requirements, Vercel preview bypass
- Examples Cookbook — multi-step onboarding, digest, delay-then-skip, LLM-powered digest