Skip to content

Code Steps: write any notification logic in TypeScript, for any channel

Every notification step - email, SMS, push, chat, in-app - can now be managed as TypeScript in your codebase. One CLI command to deploy, dashboard controls for your team.

Novu Code Steps cover image showing the npx novu step publish CLI command at the center, connected to five notification channels: Email (step.email()), In-App (step.inApp()), SMS (step.sms()), Chat (step.chat()), and Push (step.push())
Author:Emil Pearce
Emil Pearce

The problem with notification content isn't specific to email.

Every channel - email, SMS, push, chat, in-app - has the same fundamental issue: the content lives in a dashboard, outside of version control, disconnected from your codebase.

You can't PR a subject line change. You can't run a notification template through CI. You can't fetch data from your own API at send time or apply conditional logic based on what a user actually did.

Template engines give you variables. Your application logic gives you everything else. Notification platforms have historically forced you to choose one.

Code Steps closes that gap. Every channel step in a Novu Workflow can now be managed as TypeScript in your codebase and deployed as a serverless function.

Novu calls your code at send time with the subscriber data, trigger payload and any dashboard-defined controls. Your code returns the output.

Why template engines break down

Every major notification platform ships with a template engine. Handlebars, Liquid, Mustache - the details differ but the model is the same: you write a template with {{variable}} placeholders, pass in data at send time, and the engine interpolates.

This works for simple cases. It falls apart the moment your email needs to do anything real.

No logic. Template engines can't branch on computed values, call external APIs, or derive content from multiple data sources. Every dynamic section has to be pre-computed and stuffed into the payload before triggering the workflow - which means your application layer ends up owning content decisions that belong in the template.

No type safety. There's no contract between your trigger call and your template. Pass the wrong key, spell a variable wrong, change your data model - the template silently renders empty strings or breaks at send time, not at compile time.

No reuse. Email design systems - shared headers, footers, button components, color tokens - don't exist in template engines. Every template is its own island. With React Email, you build a component library and compose it across templates.

No local developer experience. Dashboard template editors have no concept of hot reload, local preview with real data, or integration with your IDE. React Email has all of this.

React Email solves all of this. The only problem is that most notification platforms couldn't use it natively - until now.

How Code Steps works

Each step in a Novu Workflow now has two modes: Editor (visual, managed in the dashboard) and Custom Code (TypeScript, managed in your codebase). You choose per-step - a single workflow can mix both.

You write a step handler file using the channel-specific builder from @novu/framework/step-resolver:

// novu/onboarding/welcome-email.step.tsx
import { step } from '@novu/framework/step-resolver'
import { render } from '@react-email/components'
import WelcomeEmail from '../../emails/welcome'

export default step.email(
  'welcome-email',
  async (controls, { payload, subscriber }) => ({
    subject: `Welcome, ${subscriber.firstName}!`,
    body: await render(
      <WelcomeEmail
        firstName={subscriber.firstName}
        ctaUrl={payload.ctaUrl}
        controls={controls}
      />
    ),
  })
)

Switch any step to Custom Code mode and the dashboard shows a pre-filled CLI command. Copy it and run it - the CLI scaffolds your handler file and deploys it as a serverless function on the Novu Cloud infrastructure.

Novu dashboard showing an In-App step editor switched to Custom Code mode, displaying the pre-filled npx novu step publish CLI command and a Waiting for first deployment state with an empty preview panel on the right.
Switch any step to Custom Code mode and the dashboard shows the publish command, pre-filled and ready to run.

Getting started

Switch any email step to Custom Code mode. Copy the pre-filled CLI command from the step editor and add the --template flag:

npx novu step publish \
  --workflow onboarding \
  --step welcome-email \
  --template ./emails/welcome.tsx \
  --secret-key nv-sk-...

The CLI scaffolds the full handler file at novu/onboarding/welcome-email.step.tsx. Edit it, then re-run npx novu step publish to deploy.

When ready for production, use the Publish changes button in the Novu dashboard - the CLI publishes to dev only.

Dynamic content at send time

Because your handler is a real TypeScript function, it can call your own APIs at send time:

export default step.email(
  'weekly-digest',
  async (controls, { payload, subscriber }) => {
    const res = await fetch(
      `https://api.acme.com/activity/${subscriber.subscriberId}?limit=5`
    )
    const { recentItems } = await res.json()

    return {
      subject: `${subscriber.firstName}, here's what happened this week`,
      body: await render(
        <WeeklyDigest firstName={subscriber.firstName} items={recentItems} />
      ),
    }
  }
)

recentItems doesn't need to exist in the trigger payload - it's fetched at send time, per subscriber, from your own API.

This is the gap template engines have never closed.

The team collaboration problem

Define a controlSchema and the Novu dashboard renders editable fields for each value. Developers set the structure and defaults - marketing edits copy without touching code:

export default step.email(
  'promotional-email',
  async (controls, { subscriber }) => ({
    subject: controls.subjectLine,
    body: await render(
      <PromoEmail
        firstName={subscriber.firstName}
        headline={controls.headline}
        ctaText={controls.ctaText}
        showBanner={controls.showBanner}
      />
    ),
  }),
  {
    controlSchema: z.object({
      subjectLine: z.string().default('Check out what\'s new'),
      headline: z.string().default('New features just dropped'),
      ctaText: z.string().default('See what\'s new'),
      showBanner: z.boolean().default(true),
    }),
  }
)
Novu dashboard showing a published In-App Code Step with a controlSchema-defined ButtonText field editable in the dashboard. The right panel previews the live in-app notification with the Let's begin button rendered in real time.
Once published, controls defined in your handler appear as editable fields in the dashboard - no code change needed to update copy.

Skipping steps based on runtime logic

Use the skip function to conditionally skip a step at send time:

export default step.sms(
  'trial-reminder',
  async (controls, { payload }) => ({
    body: `${controls.message} - your trial ends ${payload.expiryDate}`,
  }),
  {
    skip: (controls, { payload }) => payload.hasConverted === true,
    controlSchema: z.object({
      message: z.string().default('Your trial is almost up'),
    }),
  }
)

When skip returns true, the step is treated as executed but skipped. The decision is per-send, per-subscriber, based on real runtime data.

The deployment model

npx novu step publish bundles all handler files in your novu/ directory into a single Cloudflare Worker and deploys it. At send time, Novu calls your worker, validates the output, and passes it to your provider.

  • Bundle size limit: 10 MB. Use --workflow and --step flags to publish specific handlers if needed.
  • CLI publishes to non-production only. Use the Publish changes button in the Novu dashboard to promote to production.
  • Re-publishing is idempotent. No content change = no deploy.

Every channel, same model

Channel | Builder | Required output

Email | step.email() | subject, body

SMS | step.sms() | body

Push | step.push() | subject, body

Chat | step.chat() | body

In-App | step.inApp() | subject, body

Same CLI command, same deployment model, same controlSchema pattern across every channel. Mix Custom Code steps and Editor steps freely within the same Workflow.

Two sources of truth, one delivery system

Workflow configuration lives in Novu. Content logic lives in your application.

npx novu step publish is the bridge between them. The Publish changes button is the gate that controls what reaches production.

This is what programmable notification infrastructure means in practice - not a visual editor that lets you write code, not an SDK that requires you to define everything in code, but a system where each side owns what it does best.

Code Steps is available now. Switch any step to Custom Code mode in the Novu dashboard and copy the pre-filled command to connect your first handler.

For TypeScript types in your editor, install @novu/framework as a dev dependency - the CLI bundles its own copy for deployment, so this is optional but recommended.

Read the full Code Steps documentation

Get started with Code Steps

Read More

You’re five minutes away from your first Novu-backed notification

Create a free account, send your first notification, all before your coffee gets cold... no credit card required.