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 Settings → API 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 Settings → Integrations (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.