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:
- Create a new Organization (or use the default one)
- Open Settings → API Keys
- Copy your Secret Key (it starts with
novu_secret_) - 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| Package | Purpose |
|---|---|
@novu/framework | Workflow-as-code engine and bridge endpoint helper |
@novu/react | Prebuilt Inbox component and useNotifications hook |
@novu/api | Server-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:3000Add 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 Inboxstep.delay()pauses execution for 10 minutes — Novu persists state across the delaystep.email()runs after the delay, sending a formatted email- The
payloadSchemavalidates 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/novuAfter 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 devThe 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.tsOpen 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.