# Code Steps: TypeScript notification logic for every channel

> ode Steps lets you write notification logic in TypeScript for any channel - email, SMS, push, chat, in-app    - and deploy it with one CLI command.

Canonical: https://novu.co/blog/code-steps-typescript-notification-logic/

Markdown: https://novu.co/blog/code-steps-typescript-notification-logic.md

Last updated: 2026-04-09T22:18:00.000Z

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.

Authors: Emil Pearce

Published: 2026-04-09T22:18:00.000Z

Category: Product updates

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`:

```tsx
// 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.

## 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:

```bash
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:

```tsx
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:

```tsx
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),
    }),
  }
)
```

## Skipping steps based on runtime logic

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

```tsx
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](<https://docs.novu.co/platform/workflow/add-and-configure-steps/code-steps>)

[Get started with Code Steps](<https://dashboard.novu.co/auth/sign-up?utm_source=blog&utm_medium=organic&utm_campaign=code-steps-launch>)
