novu-framework-integration

Original🇺🇸 English
Translated

Build code-first notification workflows with @novu/framework. Use when defining workflows in TypeScript (Zod / JSON Schema / Class Validator), composing channel steps (email, SMS, push, chat, in-app) with action steps (delay, digest, custom), exposing Step Controls for non-technical teammates, rendering React/Vue/Svelte Email templates, hosting the Bridge Endpoint inside Next.js, Express, NestJS, Remix, Nuxt, SvelteKit, H3, or AWS Lambda, syncing to Novu Cloud via CLI / GitHub Actions, securing production with HMAC, or implementing translations, hydration, multi-channel orchestration, and LLM-powered notification logic in code.

18installs
Added on

NPX Install

npx skill4agent add novuhq/skills novu-framework-integration

Framework Integration

Use
@novu/framework
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
trigger-notification
— no Framework needed.

When to Use the Framework

Use FrameworkUse Dashboard Workflows
Workflows must live in source control / GitOpsNon-technical peers own all the content
Need just-in-time data from your DB / APIsAll data fits in the trigger payload
Render emails with React/Vue/Svelte EmailBlock editor is enough
Execute custom code (LLMs, third-party APIs)Pure send-only flows
Need typed payload + step controlsQuick prototype
The two approaches coexist — a single environment can have both code-defined and dashboard-defined workflows.

How It Works

  1. You define workflows in code with
    workflow(...)
    from
    @novu/framework
    .
  2. You expose a single
    /api/novu
    HTTP route in your app — the Bridge Endpoint.
  3. You sync the bridge URL to Novu Cloud (via
    npx novu sync
    or GitHub Action).
  4. 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
/api/novu
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
http://localhost:2022
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
design-workflow/
for channel selection, severity,
critical
, 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;
design-workflow/
covers what to decide.
typescript
workflow(workflowId, handler, options);
ParamTypeDescription
workflowId
string
Unique identifier in your environment
handler
({ step, payload, subscriber }) => Promise<void>
Workflow body — calls steps in order
options
WorkflowOptions
Schema, name, description, tags, preferences

Workflow Options

OptionTypePurpose
payloadSchema
ZodSchema | JsonSchema | ClassValidatorClass
Validates the trigger payload, infers TS type for
payload
name
string
Human-readable name shown in Dashboard /
<Inbox />
description
string
Description shown in Dashboard
tags
string[]
Categorize for filtering / Inbox tabs
severity
'low' | 'medium' | 'high'
Visual prioritization in the Inbox. Leave unset for most workflows.
critical
boolean
Bypasses subscriber preferences, skips digest, runs without delays. Reserve for must-deliver events.
preferences
WorkflowPreferences
Default channel preferences and
readOnly
flag

Workflow Context

The handler receives
{ step, payload, subscriber }
:
  • step
    — channel and action step builders (
    step.email
    ,
    step.delay
    ,
    step.digest
    , …)
  • payload
    — strongly-typed data passed at trigger time, validated against
    payloadSchema
  • subscriber
    { subscriberId, firstName?, lastName?, locale?, data?, ... }
    of the recipient

Channel Steps

All channel steps share the same shape:
typescript
await step.<channel>(stepId, resolver, options?);
StepOutput RequiredNotable OutputsReturns Result
step.email
subject
,
body
attachments
,
from
,
replyTo
No
step.sms
body
No
step.push
title
(or
subject
),
body
data
,
image
,
icon
No
step.chat
body
— (override per-provider)No
step.inApp
body
subject
,
avatar
,
redirect
,
primaryAction
,
secondaryAction
,
data
{ 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
skip
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

step.delay

Pause workflow execution before the next step.
typescript
await step.delay("wait-a-day", async () => ({
  unit: "days",
  amount: 1,
}));
Supported
unit
values:
seconds
,
minutes
,
hours
,
days
,
weeks
,
months
.

step.digest

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
cron: "0 0 * * *"
instead of
unit
/
amount
for cron-based digests. Each digest event has
{ id, time, payload }
. Only one digest per workflow — chain a second workflow via
step.custom
if you need a two-stage digest.

step.http

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
responseBodySchema
. Only properties declared in the schema are addressable as
{{ steps.<http-step-id>.<property> }}
.

step.custom

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
resolver
,
providers
, and
skip
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
});

skip

Conditionally skip a step. Receives the resolved controls.
typescript
await step.email("follow-up", resolver, {
  skip: () => inAppNotification.read === true,
});

providers
(Per-Step Provider Overrides)

Customize the request sent to the underlying provider — e.g. Slack
blocks
or SendGrid
cc
.
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" },
      },
    }),
  },
});
_passthrough
deep-merges into the final provider request — typed provider keys take precedence over
_passthrough
.

disableOutputSanitization

Allow raw HTML / unescaped characters in the output (e.g.
&
in In-App
data.link
):
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
payload
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
as const
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
,
class-validator-jsonschema
,
reflect-metadata
. 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:
  • {{subscriber.firstName}}
    — any subscriber attribute
  • {{payload.userId}}
    — any payload field defined in
    payloadSchema
  • {{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

ControlsPayload
Edited byNon-technical peers in DashboardDevelopers in code
Schema
controlSchema
per step
payloadSchema
per workflow
PersistenceStored in Novu Cloud per environmentSent at trigger time
Use caseSubject, copy, styling, behaviour togglesDynamic per-trigger data

Workflow Preferences

Define default channel preferences in code. See
manage-preferences
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 },
    },
  },
});
  • all.readOnly: true
    makes the workflow critical — subscribers cannot disable it.
  • all.enabled
    is the fallback for any channel not in
    channels
    .
  • Default if omitted:
    enabled: true
    ,
    readOnly: false
    for all channels.

Bridge Endpoint Setup

The Bridge is a single HTTP route (
/api/novu
by default) where Novu Cloud calls your app to:
  • Discover registered workflows (
    GET
    )
  • Resolve step content for a given subscriber + payload (
    POST
    )
  • Verify HMAC signatures on requests
Each framework ships a
serve
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
NovuModule.registerAsync
— 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
Then open
http://localhost:2022
(Chrome only).

CLI Flags

FlagDefaultPurpose
-p
,
--port
4000
Your app's port
-r
,
--route
/api/novu
Bridge route path
-o
,
--origin
http://localhost
Bridge origin
-d
,
--dashboard-url
https://dashboard.novu.co
Dashboard URL — use
https://eu.dashboard.novu.co
for EU
-sp
,
--studio-port
2022
Studio UI port
-t
,
--tunnel
autoSelf-hosted tunnel URL (e.g. ngrok)
-H
,
--headless
false
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
@novu/api
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
step.custom
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
trigger-notification
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
subscriber.locale
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
inbox-integration
).

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

  1. Develop locally with the Studio against your own machine.
  2. Open a PR — CI runs
    npx novu sync
    against the Development environment to test e2e.
  3. Merge to
    main
    — CI runs
    npx novu sync
    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
    serve
    wrapper handles this — you don't need to write any code. Each request includes a
    Novu-Signature
    header (
    t=timestamp,v1=signature
    ) that's verified against
    NOVU_SECRET_KEY
    .
  • Disable HMAC for local dev automatically via
    NODE_ENV=development
    . 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:
  • NOVU_SECRET_KEY
    — your secret key
  • NOVU_API_URL
    — defaults to
    https://api.novu.co
    (use
    https://eu.api.novu.co
    for EU)

Common Pitfalls

  1. Bridge URL must be publicly reachable
    localhost
    won't work for Novu Cloud. Use the Studio tunnel locally; deploy publicly for production.
  2. workflowId
    is the trigger identifier
    — same id you'll pass to
    novu.trigger({ workflowId })
    . Use kebab-case and keep it stable.
  3. Step
    id
    s must be unique within a workflow
    — duplicates throw at registration.
  4. as const
    on JSON Schema
    — without it, TS infers
    string
    instead of literal types and
    payload
    becomes
    unknown
    .
  5. Only one
    step.digest
    per workflow
    — chain a second workflow via
    step.custom
    for two-stage digest patterns.
  6. Digest / delay results from one trigger don't influence other triggers — they're per workflow run.
  7. Custom step results aren't usable in step controls — only in subsequent step
    resolver
    ,
    providers
    , or
    skip
    callbacks.
  8. Sync after every workflow change — Novu Cloud needs to know about new/renamed workflows and updated control schemas. Add
    npx novu sync
    to your CI/CD.
  9. HMAC fails locally if
    NODE_ENV !== "development"
    — set it to
    development
    for the Studio to reach your bridge, or disable strict auth in your
    Client
    .
  10. Don't store the
    secretKey
    in the client bundle
    — it's server-only. Keep workflows + bridge route inside server code, not in any
    "use client"
    module.
  11. Provider override
    _passthrough
    is unvalidated
    — typos won't error at compile time. Use known typed provider keys whenever possible.
  12. Changing a delay/digest step's content does not affect already-scheduled events — content is captured at the time of the original trigger.
  13. Workflow handlers must be deterministic across retries — Novu re-invokes the bridge to resolve step content. Avoid side-effects outside
    step.custom
    (custom is the only step whose result is durably persisted).
  14. @novu/framework
    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 (
    oneOf
    ,
    if/then/else
    ,
    $ref
    ).
  • Co-locate React Email templates next to the workflow that uses them (
    src/novu/workflows/welcome/template.tsx
    ).
  • Wrap shared
    step.custom
    logic into helpers (
    fetchUser(payload.userId)
    ) for reuse.
  • For NestJS, use
    NovuModule.registerAsync
    with a
    NotificationService
    so workflow definitions can inject services.

References

  • Bridge Endpoint Setup — every framework wrapper, custom
    serve
    , 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
    npx novu sync
    , 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