Next.js
Email & Notifications
Email & Notifications
Email and notification systems are critical for user engagement and communication. This lesson covers sending emails, implementing notification systems, using React Email for beautiful templates, and managing background jobs.
Email Sending Options
Several approaches for sending emails in Next.js:
/* 1. Nodemailer - Self-hosted SMTP */
Pros: Full control, no API limits
Cons: Configuration complexity, deliverability concerns
/* 2. SendGrid - Transactional email service */
Pros: Reliable delivery, analytics, templates
Cons: Pricing, API rate limits
/* 3. Resend - Modern email API */
Pros: Developer-friendly, React Email support
Cons: Newer service, smaller ecosystem
/* 4. AWS SES - Amazon email service */
Pros: Cost-effective, high volume
Cons: AWS complexity, verification requirements
Nodemailer Setup
Send emails using SMTP with Nodemailer:
Install and Configure Nodemailer
# Install Nodemailer
npm install nodemailer
# Environment variables
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
EMAIL_FROM=noreply@yourapp.com
Email Service Utility
// lib/email.ts
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: false, // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
export async function sendEmail({
to,
subject,
html,
text,
}: {
to: string;
subject: string;
html?: string;
text?: string;
}) {
try {
const info = await transporter.sendMail({
from: process.env.EMAIL_FROM,
to,
subject,
text,
html,
});
console.log('Email sent:', info.messageId);
return { success: true, messageId: info.messageId };
} catch (error) {
console.error('Email error:', error);
throw error;
}
}
Send Email API Route
// app/api/send-email/route.ts
import { NextRequest } from 'next/server';
import { sendEmail } from '@/lib/email';
export async function POST(request: NextRequest) {
try {
const { to, subject, message } = await request.json();
await sendEmail({
to,
subject,
text: message,
html: `<p>${message}</p>`,
});
return Response.json({ success: true });
} catch (error) {
return Response.json(
{ error: 'Failed to send email' },
{ status: 500 }
);
}
}
Resend Integration
Resend provides a modern, developer-friendly email API:
Setup Resend
# Install Resend SDK
npm install resend
# Environment variable
RESEND_API_KEY=re_your_api_key
Resend Email Service
// lib/resend.ts
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendEmailWithResend({
to,
subject,
html,
from = 'onboarding@resend.dev',
}: {
to: string;
subject: string;
html: string;
from?: string;
}) {
try {
const { data, error } = await resend.emails.send({
from,
to,
subject,
html,
});
if (error) {
throw error;
}
return { success: true, id: data.id };
} catch (error) {
console.error('Resend error:', error);
throw error;
}
}
React Email for Beautiful Templates
React Email lets you build responsive email templates with React:
Install React Email
# Install React Email
npm install react-email @react-email/components
# Add email dev script to package.json
{
"scripts": {
"email:dev": "email dev"
}
}
# Preview emails in browser
npm run email:dev
Create Email Template
// emails/WelcomeEmail.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Link,
Preview,
Section,
Text,
} from '@react-email/components';
interface WelcomeEmailProps {
username: string;
loginUrl: string;
}
export default function WelcomeEmail({
username,
loginUrl,
}: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to our platform!</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Welcome, {username}!</Heading>
<Text style={text}>
Thanks for signing up. We're excited to have you on board!
</Text>
<Section style={buttonContainer}>
<Button style={button} href={loginUrl}>
Get Started
</Button>
</Section>
<Text style={text}>
If you have any questions, reply to this email.
</Text>
</Container>
</Body>
</Html>
);
}
const main = {
backgroundColor: '#f6f9fc',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '20px 0 48px',
marginBottom: '64px',
};
const h1 = {
color: '#333',
fontSize: '24px',
fontWeight: 'bold',
margin: '40px 0',
padding: '0',
};
const text = {
color: '#333',
fontSize: '16px',
lineHeight: '26px',
};
const buttonContainer = {
padding: '27px 0 27px',
};
const button = {
backgroundColor: '#5469d4',
borderRadius: '5px',
color: '#fff',
fontSize: '16px',
fontWeight: 'bold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'block',
padding: '12px 20px',
};
Render and Send Email Template
// app/api/send-welcome/route.ts
import { NextRequest } from 'next/server';
import { render } from '@react-email/render';
import WelcomeEmail from '@/emails/WelcomeEmail';
import { sendEmailWithResend } from '@/lib/resend';
export async function POST(request: NextRequest) {
try {
const { email, username } = await request.json();
const emailHtml = render(
WelcomeEmail({
username,
loginUrl: 'https://yourapp.com/login',
})
);
await sendEmailWithResend({
to: email,
subject: 'Welcome to Our Platform!',
html: emailHtml,
});
return Response.json({ success: true });
} catch (error) {
return Response.json(
{ error: 'Failed to send welcome email' },
{ status: 500 }
);
}
}
Notification System Architecture
Design a flexible notification system supporting multiple channels:
Database Schema
// prisma/schema.prisma
model Notification {
id String @id @default(cuid())
userId String
type String // 'info', 'success', 'warning', 'error'
title String
message String
read Boolean @default(false)
actionUrl String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}
model NotificationPreference {
id String @id @default(cuid())
userId String
email Boolean @default(true)
push Boolean @default(true)
sms Boolean @default(false)
user User @relation(fields: [userId], references: [id])
}
Notification Service
// lib/notifications.ts
import { prisma } from '@/lib/prisma';
import { sendEmailWithResend } from '@/lib/resend';
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
export async function createNotification({
userId,
type,
title,
message,
actionUrl,
sendEmail = false,
}: {
userId: string;
type: NotificationType;
title: string;
message: string;
actionUrl?: string;
sendEmail?: boolean;
}) {
// Create in-app notification
const notification = await prisma.notification.create({
data: {
userId,
type,
title,
message,
actionUrl,
},
});
// Check user preferences
const preferences = await prisma.notificationPreference.findUnique({
where: { userId },
});
// Send email if enabled
if (sendEmail && preferences?.email) {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (user?.email) {
await sendEmailWithResend({
to: user.email,
subject: title,
html: `
<h2>${title}</h2>
<p>${message}</p>
${actionUrl ? `<a href="${actionUrl}">View Details</a>` : ''}
`,
});
}
}
return notification;
}
export async function markAsRead(notificationId: string) {
return await prisma.notification.update({
where: { id: notificationId },
data: { read: true },
});
}
export async function getUnreadCount(userId: string) {
return await prisma.notification.count({
where: {
userId,
read: false,
},
});
}
Notification API Routes
// app/api/notifications/route.ts
import { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
const session = await getServerSession();
if (!session?.user?.id) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const notifications = await prisma.notification.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
take: 20,
});
return Response.json({ notifications });
}
// app/api/notifications/[id]/read/route.ts
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession();
if (!session?.user?.id) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
await prisma.notification.update({
where: {
id: params.id,
userId: session.user.id,
},
data: { read: true },
});
return Response.json({ success: true });
}
In-App Notifications Component
Build a notification dropdown for the UI:
// components/NotificationBell.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
interface Notification {
id: string;
type: string;
title: string;
message: string;
read: boolean;
actionUrl?: string;
createdAt: string;
}
export default function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const router = useRouter();
useEffect(() => {
fetchNotifications();
}, []);
const fetchNotifications = async () => {
const response = await fetch('/api/notifications');
const data = await response.json();
setNotifications(data.notifications);
setUnreadCount(data.notifications.filter((n: Notification) => !n.read).length);
};
const markAsRead = async (id: string) => {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
setUnreadCount((prev) => Math.max(0, prev - 1));
};
const handleNotificationClick = (notification: Notification) => {
markAsRead(notification.id);
if (notification.actionUrl) {
router.push(notification.actionUrl);
}
setIsOpen(false);
};
return (
<div style={{ position: 'relative' }}>
<button onClick={() => setIsOpen(!isOpen)}>
🔔
{unreadCount > 0 && (
<span style={{
position: 'absolute',
top: '-5px',
right: '-5px',
background: 'red',
color: 'white',
borderRadius: '50%',
width: '20px',
height: '20px',
fontSize: '12px',
}}>
{unreadCount}
</span>
)}
</button>
{isOpen && (
<div style={{
position: 'absolute',
top: '100%',
right: 0,
width: '350px',
maxHeight: '400px',
overflow: 'auto',
background: 'white',
border: '1px solid #ccc',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
}}>
{notifications.length === 0 ? (
<p style={{ padding: '20px', textAlign: 'center' }}>
No notifications
</p>
) : (
notifications.map((notification) => (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
style={{
padding: '12px',
borderBottom: '1px solid #eee',
cursor: 'pointer',
background: notification.read ? 'white' : '#f0f0f0',
}}
>
<strong>{notification.title}</strong>
<p style={{ margin: '5px 0', fontSize: '14px' }}>
{notification.message}
</p>
<small style={{ color: '#666' }}>
{new Date(notification.createdAt).toLocaleString()}
</small>
</div>
))
)}
</div>
)}
</div>
);
}
Background Jobs with BullMQ
Process emails asynchronously with job queues:
Setup BullMQ
# Install BullMQ and Redis
npm install bullmq ioredis
# Docker Redis (for development)
docker run -d -p 6379:6379 redis
# Environment variable
REDIS_URL=redis://localhost:6379
Email Queue Setup
// lib/queue.ts
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { sendEmailWithResend } from './resend';
const connection = new IORedis(process.env.REDIS_URL!);
export const emailQueue = new Queue('email', { connection });
// Email worker
export const emailWorker = new Worker(
'email',
async (job) => {
const { to, subject, html } = job.data;
await sendEmailWithResend({ to, subject, html });
console.log(`Email sent to ${to}`);
},
{ connection }
);
emailWorker.on('completed', (job) => {
console.log(`Job ${job.id} completed`);
});
emailWorker.on('failed', (job, err) => {
console.error(`Job ${job?.id} failed:`, err);
});
Queue Email Jobs
// app/api/queue-email/route.ts
import { NextRequest } from 'next/server';
import { emailQueue } from '@/lib/queue';
export async function POST(request: NextRequest) {
try {
const { to, subject, html } = await request.json();
await emailQueue.add('send-email', {
to,
subject,
html,
});
return Response.json({ success: true, message: 'Email queued' });
} catch (error) {
return Response.json(
{ error: 'Failed to queue email' },
{ status: 500 }
);
}
}
Scheduled Emails
Send emails at specific times or intervals:
// lib/scheduled-emails.ts
import { emailQueue } from './queue';
export async function scheduleEmail({
to,
subject,
html,
sendAt,
}: {
to: string;
subject: string;
html: string;
sendAt: Date;
}) {
const delay = sendAt.getTime() - Date.now();
await emailQueue.add(
'send-email',
{ to, subject, html },
{ delay }
);
}
// Send daily digest
export async function scheduleDailyDigest(userId: string) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(9, 0, 0, 0); // 9 AM
await emailQueue.add(
'daily-digest',
{ userId },
{
delay: tomorrow.getTime() - Date.now(),
repeat: { pattern: '0 9 * * *' }, // Cron: daily at 9 AM
}
);
}
Email Best Practices: Always validate email addresses. Implement unsubscribe functionality. Use queues for bulk emails. Monitor delivery rates and bounce rates. Provide plain text alternatives. Test emails across different clients.
Exercise: Build a Notification System
- Set up email sending with Resend or Nodemailer
- Create React Email templates for welcome, reset password, and notification emails
- Build a database schema for notifications and preferences
- Implement an in-app notification bell component with unread count
- Create API routes for fetching and marking notifications as read
- Set up BullMQ for background email processing
- Implement scheduled emails (daily digest, reminders)
- Add notification preferences page where users can customize settings
Production Considerations: Implement rate limiting on email sending. Handle bounces and complaints. Store email delivery logs. Comply with anti-spam laws (CAN-SPAM, GDPR). Monitor queue health and retry failed jobs. Use separate queues for different priorities.