writing/tutorial/2026/05
TutorialMay 15, 2026·28 min read

Building a Complete In-App Notification System with Novu and Next.js 15

Learn how to build a production-ready in-app notification system using Novu v2 and Next.js 15. This tutorial covers workflow-as-code, the Novu React Inbox component, email channels, real-time WebSocket updates, and deployment.

Modern SaaS applications need reliable, multi-channel notifications — in-app alerts, emails, push messages, all coordinated and tracked. Building this from scratch means juggling WebSocket servers, email providers, notification schemas, and unread counters. Novu solves all of this with a single open-source platform.

With Novu v2, you define notification workflows directly in TypeScript, co-located with your application code. There is no drag-and-drop interface to click through — your workflows are version-controlled, type-safe, and testable just like any other business logic. Novu handles the infrastructure: real-time delivery via WebSocket, multi-channel fan-out, retry logic, and a prebuilt React Inbox component that drops into any Next.js app.

In this tutorial, you will build a task management app with a fully functional notification system: users receive instant in-app notifications when tasks are assigned to them, with fallback emails if they are offline. You will use Novu's workflow-as-code engine, bridge API endpoint, and the Inbox React component.

Prerequisites

Before you start, ensure you have:

  • Node.js 20+ installed
  • A working Next.js 15 project with App Router and TypeScript
  • Basic familiarity with React Server Components and API Routes
  • A Novu account — sign up for free at novu.co (no credit card required)

What You Will Build

By the end of this tutorial, your app will have:

  • A notification bell in the navbar showing an unread count badge
  • A Novu Inbox panel that opens when clicking the bell, listing all notifications with mark-as-read support
  • A task-assigned workflow that sends an in-app alert instantly and an email after a 10-minute delay
  • A Server Action that triggers the notification when a task is created or assigned

Step 1: Create Your Novu Account and Project

Navigate to novu.co and create a free account. After signing in:

  1. Create a new Organization (or use the default one)
  2. Open SettingsAPI Keys
  3. Copy your Secret Key (it starts with novu_secret_)
  4. Copy your App Identifier (it starts with app_)

Keep these values ready — you will add them to your environment variables in the next step.

Step 2: Install Novu Dependencies

In your Next.js project root, install the three Novu packages:

npm install @novu/framework @novu/react @novu/api
PackagePurpose
@novu/frameworkWorkflow-as-code engine and bridge endpoint helper
@novu/reactPrebuilt Inbox component and useNotifications hook
@novu/apiServer-side API client for triggering notifications

Step 3: Configure Environment Variables

Create or update your .env.local file with the keys from your Novu dashboard:

# Server-side only — never expose this to the browser
NOVU_SECRET_KEY=novu_secret_your_key_here
 
# Safe to expose to the browser (prefixed with NEXT_PUBLIC_)
NEXT_PUBLIC_NOVU_APP_ID=app_your_identifier_here
 
# Your app's public URL (needed for notification deep links)
NEXT_PUBLIC_APP_URL=http://localhost:3000

Add the non-prefixed variables to your hosting provider's secret environment settings before deploying to production.

Step 4: Define Your Notification Workflow

Novu v2's workflow-as-code lets you define notification logic as TypeScript functions. Create a dedicated file for your workflows:

// lib/novu/workflows.ts
import { workflow } from '@novu/framework';
import { z } from 'zod';
 
export const taskAssignedWorkflow = workflow(
  'task-assigned',
  async ({ step, payload }) => {
    // Instant in-app notification
    await step.inApp('in-app-alert', async () => ({
      subject: `New task: ${payload.taskTitle}`,
      body: `${payload.assignerName} assigned you a task with ${payload.priority} priority.`,
    }));
 
    // Wait 10 minutes, then send an email if the user has not logged in
    await step.delay('wait-before-email', async () => ({
      amount: 10,
      unit: 'minutes',
    }));
 
    await step.email('email-fallback', async () => ({
      subject: `Action required: ${payload.taskTitle}`,
      body: `
        <h2>You have a new task</h2>
        <p><strong>${payload.assignerName}</strong> assigned you:</p>
        <h3>${payload.taskTitle}</h3>
        <p>Priority: ${payload.priority}</p>
        <p>${payload.taskDescription}</p>
        <a href="${payload.taskUrl}" style="color:#7C3AED">View Task</a>
      `,
    }));
  },
  {
    payloadSchema: z.object({
      taskTitle: z.string(),
      taskDescription: z.string().optional().default(''),
      assignerName: z.string(),
      priority: z.enum(['low', 'medium', 'high']),
      taskUrl: z.string().url(),
    }),
  }
);

Key points about this workflow:

  • The workflow ID 'task-assigned' is what you reference when triggering
  • step.inApp() delivers an instant notification to the user's Novu Inbox
  • step.delay() pauses execution for 10 minutes — Novu persists state across the delay
  • step.email() runs after the delay, sending a formatted email
  • The payloadSchema validates trigger payloads at runtime using Zod, catching mistakes before they reach your workflow

Step 5: Create the Novu Bridge Endpoint

Novu uses a bridge endpoint in your Next.js app to discover and execute your workflows. This is a standard Next.js API route:

// app/api/novu/route.ts
import { serve } from '@novu/framework/next';
import { taskAssignedWorkflow } from '@/lib/novu/workflows';
 
export const { GET, POST, PUT } = serve({
  workflows: [taskAssignedWorkflow],
});

After creating this file, sync your workflows with Novu Cloud. During local development, use a tunnel to expose your local server:

# Option 1: Use ngrok
ngrok http 3000
 
# Then sync with the tunnel URL
npx novu@latest sync --bridge-url https://your-ngrok-url.ngrok.io/api/novu

After a successful sync, your task-assigned workflow appears in the Novu Cloud dashboard. Re-run the sync command every time you add or modify workflows. In CI/CD, add the sync step to your deployment pipeline.

Step 6: Add the Inbox Component to Your App

The Novu Inbox is a drop-in React component that renders a notification bell with an unread badge and a dropdown panel. Add it to your navbar:

// components/NotificationInbox.tsx
'use client';
 
import { NovuProvider, Inbox } from '@novu/react';
 
interface NotificationInboxProps {
  subscriberId: string;
}
 
export function NotificationInbox({ subscriberId }: NotificationInboxProps) {
  return (
    <NovuProvider
      applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APP_ID!}
      subscriberId={subscriberId}
    >
      <Inbox
        appearance={{
          variables: {
            colorPrimary: '#7C3AED',
            colorBackground: '#1F2937',
            colorForeground: '#F9FAFB',
            borderRadius: '0.5rem',
          },
        }}
      />
    </NovuProvider>
  );
}

The subscriberId is your user's unique identifier — typically their database ID. Novu creates a subscriber record automatically on the first notification trigger, so you do not need to pre-register users.

Now integrate it into your navbar server component:

// components/Navbar.tsx
import { auth } from '@/lib/auth';
import { NotificationInbox } from './NotificationInbox';
 
export async function Navbar() {
  const session = await auth();
 
  return (
    <nav className="flex items-center justify-between px-6 py-4 border-b">
      <span className="text-xl font-bold">TaskFlow</span>
      <div className="flex items-center gap-4">
        {session?.user && (
          <NotificationInbox subscriberId={session.user.id} />
        )}
      </div>
    </nav>
  );
}

Step 7: Trigger Notifications from Server Actions

Create a Novu client utility for server-side use:

// lib/novu/client.ts
import { Novu } from '@novu/api';
 
export const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY! });

Then trigger the workflow from a Server Action when a task is assigned:

// app/actions/tasks.ts
'use server';
 
import { novu } from '@/lib/novu/client';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
 
export async function assignTask(taskId: string, assigneeId: string) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');
 
  const task = await db.task.update({
    where: { id: taskId },
    data: { assigneeId },
    include: {
      assignee: true,
      createdBy: true,
    },
  });
 
  // Do not notify the user if they assigned the task to themselves
  if (assigneeId !== session.user.id) {
    await novu.trigger({
      name: 'task-assigned',
      to: {
        subscriberId: assigneeId,
        email: task.assignee.email,
        firstName: task.assignee.name ?? undefined,
      },
      payload: {
        taskTitle: task.title,
        taskDescription: task.description ?? '',
        assignerName: session.user.name ?? 'A teammate',
        priority: task.priority,
        taskUrl: `${process.env.NEXT_PUBLIC_APP_URL}/tasks/${taskId}`,
      },
    });
  }
 
  revalidatePath('/tasks');
  return task;
}

The to object serves a dual purpose: it identifies the subscriber and syncs their contact data on every trigger, so Novu always has the latest email address for the email step.

Step 8: Build a Custom Notification UI with the Hook

If the prebuilt Inbox does not match your design system, use the useNotifications hook to build a fully custom UI while keeping all the real-time plumbing:

// components/CustomNotificationPanel.tsx
'use client';
 
import { useNotifications } from '@novu/react';
import { BellIcon } from 'lucide-react';
import { useState } from 'react';
 
export function CustomNotificationPanel() {
  const [open, setOpen] = useState(false);
  const {
    notifications,
    unreadCount,
    markAllAsRead,
    markAsRead,
    isLoading,
  } = useNotifications();
 
  return (
    <div className="relative">
      <button
        onClick={() => setOpen(!open)}
        className="relative p-2 rounded-full hover:bg-gray-100"
      >
        <BellIcon className="w-5 h-5" />
        {unreadCount > 0 && (
          <span className="absolute top-0 right-0 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
            {unreadCount}
          </span>
        )}
      </button>
 
      {open && (
        <div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-xl border z-50">
          <div className="flex justify-between items-center p-4 border-b">
            <span className="font-semibold">Notifications</span>
            <button
              onClick={markAllAsRead}
              className="text-sm text-purple-600 hover:underline"
            >
              Mark all read
            </button>
          </div>
 
          {isLoading ? (
            <div className="p-4 text-center text-gray-500">Loading...</div>
          ) : notifications.length === 0 ? (
            <div className="p-4 text-center text-gray-500">No notifications yet</div>
          ) : (
            <ul className="max-h-96 overflow-y-auto">
              {notifications.map((n) => (
                <li
                  key={n.id}
                  onClick={() => markAsRead(n.id)}
                  className={`p-4 border-b cursor-pointer hover:bg-gray-50 ${
                    !n.isRead ? 'bg-purple-50' : ''
                  }`}
                >
                  <p className="font-medium text-sm">{n.subject}</p>
                  <p className="text-xs text-gray-500 mt-1">{n.body}</p>
                </li>
              ))}
            </ul>
          )}
        </div>
      )}
    </div>
  );
}

Wrap this component inside NovuProvider exactly as you did with the prebuilt Inbox.

Step 9: Add Notification Preferences

Let users control which channels they receive notifications on with the built-in Preferences component:

// components/NotificationSettings.tsx
'use client';
 
import { NovuProvider, Preferences } from '@novu/react';
 
export function NotificationSettings({ subscriberId }: { subscriberId: string }) {
  return (
    <NovuProvider
      applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APP_ID!}
      subscriberId={subscriberId}
    >
      <Preferences />
    </NovuProvider>
  );
}

Render this component on a user settings page. Novu automatically surfaces per-workflow, per-channel toggles. When a user disables email for the task-assigned workflow, the email step is silently skipped on the next trigger — no code changes required.

Step 10: Test Your Notification System

Test with Novu Local Studio

Start the Local Studio alongside your dev server:

# Terminal 1
npm run dev
 
# Terminal 2
npx novu@latest dev

The Local Studio opens at http://localhost:2022 and lets you:

  • Trigger test notifications with custom payloads
  • Inspect each workflow step's rendered output
  • Preview email templates before connecting an email provider
  • Monitor execution logs in real time

Integration Script

For programmatic testing, create a quick trigger script:

// scripts/test-notification.ts
import { novu } from '../lib/novu/client';
 
async function main() {
  const result = await novu.trigger({
    name: 'task-assigned',
    to: {
      subscriberId: 'test-user-123',
      email: 'test@yourapp.com',
    },
    payload: {
      taskTitle: 'Review the landing page copy',
      taskDescription: 'Please check the hero section wording.',
      assignerName: 'Test Script',
      priority: 'medium',
      taskUrl: 'http://localhost:3000/tasks/test-001',
    },
  });
 
  console.log('Triggered:', result);
}
 
main().catch(console.error);
npx tsx scripts/test-notification.ts

Open your app — the notification bell should show a new unread item in real time without a page refresh.

Troubleshooting

Inbox shows no notifications after triggering

Verify that the subscriberId passed to NovuProvider exactly matches the to.subscriberId in the trigger call. Even a case difference (User_123 vs user_123) creates a different subscriber.

novu sync fails with a connection error

Your bridge URL must be reachable from Novu Cloud. Use ngrok or Cloudflare Tunnel to expose your local server, then pass the public URL to --bridge-url.

Email step is skipped in production

Connect an email provider in the Novu dashboard under SettingsIntegrations (Resend, SendGrid, Postmark, and others are supported). Without an active email integration, email steps are silently skipped.

Real-time updates stop working after a browser refresh

The WebSocket connection is re-established automatically. If you see a long delay, check that NEXT_PUBLIC_NOVU_APP_ID is set correctly in your production environment variables.

Next Steps

  • Digest notifications — use step.digest() to batch multiple task updates into a single daily summary email, reducing notification fatigue
  • Conditional routing — skip the email step if the subscriber read the in-app notification within the delay window
  • Multiple workflows — add workflows for task comments, due-date reminders, and team mentions
  • Push notifications — connect Firebase Cloud Messaging in Novu's integrations to extend your system to mobile

Conclusion

You now have a production-grade notification system running on Novu v2 and Next.js 15. Your users get real-time in-app alerts powered by WebSockets, a polished Inbox UI with zero CSS written, and email fallbacks for offline users — all defined in a type-safe TypeScript workflow that lives in your repository. Novu's workflow-as-code model makes notifications a first-class citizen of your codebase: versioned, testable, and easy to extend as your app grows.