--- name: novu-framework-integration description: 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. inputs: - name: NOVU_SECRET_KEY description: "Server-side API key from https://dashboard.novu.co/api-keys. Used by @novu/framework and the Bridge Endpoint." required: true type: secret --- # 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`](../trigger-notification) — 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 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= ``` 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= ``` ### 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](#bridge-endpoint-setup) below. ### 5. Run the Local Studio ```bash npx novu@latest dev --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/`](../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); ``` | Param | Type | Description | | --- | --- | --- | | `workflowId` | `string` | Unique identifier in your environment | | `handler` | `({ step, payload, subscriber }) => Promise` | Workflow body — calls steps in order | | `options` | `WorkflowOptions` | Schema, name, description, tags, preferences | ### Workflow Options | Option | Type | Purpose | | --- | --- | --- | | `payloadSchema` | `ZodSchema \| JsonSchema \| ClassValidatorClass` | Validates the trigger payload, infers TS type for `payload` | | `name` | `string` | Human-readable name shown in Dashboard / `` | | `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.(stepId, resolver, options?); ``` | Step | Output Required | Notable Outputs | Returns 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(), 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(), })); ``` 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.. }}`. ### `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 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`](./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(), }), { controlSchema: z.object({ hideBanner: z.boolean().default(false), subject: z.string().default("Hi {{subscriber.firstName | capitalize}}"), }), }); ``` ### Variables in Controls Control values support [LiquidJS](https://liquidjs.com/filters/overview.html) 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 | | Controls | Payload | | --- | --- | --- | | Edited by | Non-technical peers in Dashboard | Developers in code | | Schema | `controlSchema` per step | `payloadSchema` 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 [`manage-preferences`](../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`](./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`](./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 | Flag | Default | Purpose | | --- | --- | --- | | `-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` | auto | Self-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://.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`](../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 }) => ( Hello {name}, welcome! ); export const renderWelcome = (name: string) => render(); ``` ```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`](./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(), }; }, { controlSchema: z.object({ defaultLocale: z.string().default("en_US"), }), }); }, ); ``` See [`references/translations.md`](./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`](../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](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection) and pass the bypass token as `?x-vercel-protection-bypass=` 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/.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](./references/bridge-endpoint.md) — every framework wrapper, custom `serve`, NestJS DI - [Workflow & Step API](./references/workflow-and-steps.md) — full options, all step types, conditional logic patterns - [Schema Validation](./references/schema-validation.md) — Zod, JSON Schema, Class Validator deep dive - [Email Templates](./references/email-templates.md) — React, Vue, Svelte Email integrations - [Translations](./references/translations.md) — i18next-based localized workflows - [Local Studio & CLI](./references/studio-and-cli.md) — every flag, tunnel modes, headless mode - [Deployment](./references/deployment.md) — `npx novu sync`, GitHub Action, GitOps recipe, EU region - [Production & Security](./references/security.md) — HMAC, public bridge requirements, Vercel preview bypass - [Examples Cookbook](./references/examples.md) — multi-step onboarding, digest, delay-then-skip, LLM-powered digest