Next.js Integration
Complete Next.js 14+ App Router integration with Server Actions, Route Handlers, Edge Runtime, and type-safe validation using Zod.
This example uses @sentdm/sentdm with Next.js App Router patterns including Server Components, Server Actions, and Route Handlers.
Installation
Install the required dependencies:
npm install @sentdm/sentdm zod
npm install -D @types/nodeyarn add @sentdm/sentdm zod
yarn add -D @types/nodepnpm add @sentdm/sentdm zod
pnpm add -D @types/nodebun add @sentdm/sentdm zodFor rate limiting (optional):
npm install @upstash/ratelimit @upstash/redisProject Structure
Recommended project structure for Next.js applications with Sent:
app/
├── api/
│ ├── messages/
│ │ └── route.ts # Route Handler for messages
│ └── webhooks/
│ └── sent/
│ └── route.ts # Webhook handler
├── actions/
│ └── messages.ts # Server Actions
├── components/
│ ├── message-form.tsx # Client Component form
│ └── message-list.tsx # Server Component
├── lib/
│ ├── sent/
│ │ ├── client.ts # SDK client configuration
│ │ └── schemas.ts # Zod validation schemas
│ └── utils.ts # Helper functions
└── page.tsx # Main pageSDK Client Configuration
Create a centralized SDK client with proper configuration:
// lib/sent/client.ts
import SentDm from '@sentdm/sentdm';
const apiKey = process.env.SENT_DM_API_KEY;
if (!apiKey && process.env.NODE_ENV === 'production') {
throw new Error('SENT_DM_API_KEY is required in production');
}
export const sentClient = new SentDm({
apiKey: apiKey || 'test-key',
maxRetries: 2,
timeout: 30 * 1000,
logLevel: process.env.NODE_ENV === 'development' ? 'debug' : 'warn',
});
export function isSentError(error: unknown): error is SentDm.APIError {
return error instanceof SentDm.APIError;
}
export function handleSentError(error: unknown) {
if (isSentError(error)) {
return { message: error.message, status: error.status, code: error.name };
}
if (error instanceof Error) {
return { message: error.message, status: 500, code: 'InternalError' };
}
return { message: 'An unexpected error occurred', status: 500, code: 'UnknownError' };
}Validation Schemas
Define Zod schemas for type-safe validation:
// lib/sent/schemas.ts
import { z } from 'zod';
export const phoneNumberSchema = z
.string()
.min(1, 'Phone number is required')
.regex(/^\+[1-9]\d{1,14}$/, 'Phone number must be in E.164 format');
export const sendMessageSchema = z.object({
phoneNumber: phoneNumberSchema,
templateId: z.string().uuid('Invalid template ID format'),
templateName: z.string().min(1, 'Template name is required'),
parameters: z.record(z.string()).optional(),
channels: z.array(z.enum(['sms', 'whatsapp', 'email'])).optional(),
testMode: z.boolean().optional(),
});
export const batchMessageSchema = z.object({
recipients: z.array(
z.object({ phoneNumber: phoneNumberSchema, parameters: z.record(z.string()).optional() })
).min(1).max(100),
templateId: z.string().uuid(),
templateName: z.string(),
channels: z.array(z.enum(['sms', 'whatsapp', 'email'])).optional(),
});
export const webhookEventSchema = z.object({
type: z.enum(['message.status.updated', 'message.delivered', 'message.failed', 'message.read']),
data: z.object({
id: z.string(),
status: z.string().optional(),
error: z.object({ message: z.string(), code: z.string().optional() }).optional(),
}),
});
export type SendMessageInput = z.infer<typeof sendMessageSchema>;
export type BatchMessageInput = z.infer<typeof batchMessageSchema>;
export type WebhookEvent = z.infer<typeof webhookEventSchema>;
export interface MessageResponse { messageId: string; status: string; }
export interface MessageError { message: string; status: number; code?: string; field?: string; }
export type SendMessageOutcome =
| { success: true; data: MessageResponse }
| { success: false; error: MessageError };Server Actions
Server Actions for form submissions with progressive enhancement:
// app/actions/messages.ts
'use server';
import { revalidatePath } from 'next/cache';
import { sentClient, handleSentError } from '@/lib/sent/client';
import { sendMessageSchema } from '@/lib/sent/schemas';
import type { SendMessageOutcome } from '@/lib/sent/schemas';
export async function sendMessage(formData: FormData): Promise<SendMessageOutcome> {
try {
const rawData = {
phoneNumber: formData.get('phoneNumber'),
templateId: formData.get('templateId'),
templateName: formData.get('templateName'),
parameters: JSON.parse((formData.get('parameters') as string) || '{}'),
channels: formData.get('channels') ? JSON.parse(formData.get('channels') as string) : undefined,
testMode: formData.get('testMode') === 'true',
};
const validated = sendMessageSchema.safeParse(rawData);
if (!validated.success) {
const firstError = validated.error.errors[0];
return { success: false, error: { message: firstError.message, status: 400, code: 'ValidationError', field: firstError.path.join('.') } };
}
const { phoneNumber, templateId, templateName, parameters, channels, testMode } = validated.data;
const response = await sentClient.messages.send({
to: [phoneNumber],
template: { id: templateId, name: templateName, parameters: parameters || {} },
channels,
testMode,
});
const message = response.data.messages[0];
revalidatePath('/messages');
return { success: true, data: { messageId: message.id, status: message.status } };
} catch (error) {
console.error('Failed to send message:', error);
return { success: false, error: handleSentError(error) };
}
}
export async function sendWelcomeMessage(phoneNumber: string, name: string): Promise<SendMessageOutcome> {
try {
const response = await sentClient.messages.send({
to: [phoneNumber],
template: { id: process.env.WELCOME_TEMPLATE_ID!, name: 'welcome', parameters: { name } },
channels: ['whatsapp'],
});
revalidatePath('/contacts');
return { success: true, data: { messageId: response.data.messages[0].id, status: response.data.messages[0].status } };
} catch (error) {
console.error('Failed to send welcome message:', error);
return { success: false, error: handleSentError(error) };
}
}Route Handlers (API Routes)
Create API routes with proper typing and validation:
// app/api/messages/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sentClient, handleSentError } from '@/lib/sent/client';
import { sendMessageSchema, batchMessageSchema } from '@/lib/sent/schemas';
const corsHeaders = {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export async function OPTIONS() {
return NextResponse.json({}, { headers: corsHeaders });
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
if (body.recipients) return handleBatchMessage(body, corsHeaders);
return handleSingleMessage(body, corsHeaders);
} catch (error) {
const { message, status, code } = handleSentError(error);
return NextResponse.json({ success: false, error: { message, code } }, { status, headers: corsHeaders });
}
}
async function handleSingleMessage(body: unknown, headers: Record<string, string>) {
const validated = sendMessageSchema.safeParse(body);
if (!validated.success) {
return NextResponse.json({ success: false, error: { message: 'Validation failed', code: 'ValidationError', details: validated.error.errors } }, { status: 400, headers });
}
const { phoneNumber, templateId, templateName, parameters, channels, testMode } = validated.data;
const response = await sentClient.messages.send({
to: [phoneNumber],
template: { id: templateId, name: templateName, parameters: parameters || {} },
channels,
testMode,
});
const message = response.data.messages[0];
return NextResponse.json({ success: true, data: { messageId: message.id, status: message.status, recipient: phoneNumber } }, { headers });
}
async function handleBatchMessage(body: unknown, headers: Record<string, string>) {
const validated = batchMessageSchema.safeParse(body);
if (!validated.success) {
return NextResponse.json({ success: false, error: { message: 'Validation failed', code: 'ValidationError', details: validated.error.errors } }, { status: 400, headers });
}
const { recipients, templateId, templateName, channels } = validated.data;
const results = await Promise.allSettled(
recipients.map(async (recipient) => {
const response = await sentClient.messages.send({
to: [recipient.phoneNumber],
template: { id: templateId, name: templateName, parameters: recipient.parameters || {} },
channels,
});
return { phoneNumber: recipient.phoneNumber, messageId: response.data.messages[0].id, status: response.data.messages[0].status };
})
);
const successful = results.filter((r): r is PromiseFulfilledResult<unknown> => r.status === 'fulfilled').map((r) => r.value);
const failed = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map((r, index) => ({ phoneNumber: recipients[index]?.phoneNumber, error: r.reason instanceof Error ? r.reason.message : 'Unknown error' }));
return NextResponse.json({ success: true, data: { total: recipients.length, successful: successful.length, failed: failed.length, results: successful, errors: failed } }, { headers });
}Server Components
Fetch and display data in Server Components:
// app/components/message-list.tsx
import { sentClient } from '@/lib/sent/client';
import { unstable_cache } from 'next/cache';
const getTemplates = unstable_cache(async () => {
const response = await sentClient.templates.list();
return response.data;
}, ['templates'], { revalidate: 300, tags: ['templates'] });
export default async function MessageList() {
let templates;
try {
templates = await getTemplates();
} catch (error) {
console.error('Failed to load templates:', error);
return <div className="rounded-lg border border-red-200 bg-red-50 p-4"><p className="text-red-800">Failed to load templates. Please try again later.</p></div>;
}
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Available Templates</h2>
<div className="grid gap-4">
{templates.map((template) => (
<div key={template.id} className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">{template.name}</h3>
<span className={`rounded-full px-2 py-1 text-xs ${template.status === 'approved' ? 'bg-green-100 text-green-800' : template.status === 'pending' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800'}`}>{template.status}</span>
</div>
<p className="mt-1 text-sm text-gray-600">ID: {template.id}</p>
</div>
))}
</div>
</div>
);
}Client Components
Interactive form with Server Actions:
// app/components/message-form.tsx
'use client';
import { useState } from 'react';
import { sendMessage } from '../actions/messages';
import type { SendMessageOutcome } from '@/lib/sent/schemas';
interface MessageFormProps { templates: Array<{ id: string; name: string; status: string }>; }
export default function MessageForm({ templates }: MessageFormProps) {
const [result, setResult] = useState<SendMessageOutcome | null>(null);
const [isPending, setIsPending] = useState(false);
async function handleSubmit(formData: FormData) {
setIsPending(true);
setResult(null);
try {
const outcome = await sendMessage(formData);
setResult(outcome);
} finally {
setIsPending(false);
}
}
return (
<form action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="phoneNumber" className="block text-sm font-medium">Phone Number</label>
<input type="tel" id="phoneNumber" name="phoneNumber" placeholder="+1234567890" required className="mt-1 block w-full rounded-md border px-3 py-2" />
<p className="mt-1 text-xs text-gray-500">Format: +1234567890 (E.164)</p>
</div>
<div>
<label htmlFor="templateId" className="block text-sm font-medium">Template</label>
<select id="templateId" name="templateId" required className="mt-1 block w-full rounded-md border px-3 py-2">
<option value="">Select a template</option>
{templates.filter((t) => t.status === 'approved').map((template) => (
<option key={template.id} value={template.id}>{template.name}</option>
))}
</select>
<input type="hidden" name="templateName" value="welcome" />
</div>
<div>
<label htmlFor="parameters" className="block text-sm font-medium">Parameters (JSON)</label>
<textarea id="parameters" name="parameters" placeholder='{"name": "John"}' rows={3} className="mt-1 block w-full rounded-md border px-3 py-2" />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="testMode" name="testMode" value="true" className="rounded" />
<label htmlFor="testMode" className="text-sm">Test mode (validate without sending)</label>
</div>
<button type="submit" disabled={isPending} className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50">{isPending ? 'Sending...' : 'Send Message'}</button>
{result && (
<div className={`rounded-lg p-4 ${result.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{result.success ? (
<><p className="font-medium">Message sent successfully!</p><p className="text-sm">Message ID: {result.data.messageId}</p></>
) : (
<><p className="font-medium">Failed to send message</p><p className="text-sm">{result.error.message}</p></>
)}
</div>
)}
</form>
);
}Middleware for API Routes
Create middleware for authentication and rate limiting:
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'),
analytics: true,
});
export async function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) return NextResponse.next();
if (request.nextUrl.pathname.startsWith('/api/messages')) {
const apiKey = request.headers.get('x-api-key');
const internalRequest = request.headers.get('x-internal-request') === 'true' || !request.headers.get('origin');
if (!internalRequest && apiKey !== process.env.INTERNAL_API_KEY) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!internalRequest) {
const ip = request.ip ?? '127.0.0.1';
const { success, limit, reset, remaining } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429, headers: { 'X-RateLimit-Limit': limit.toString(), 'X-RateLimit-Remaining': remaining.toString(), 'X-RateLimit-Reset': reset.toString() } });
}
}
}
return NextResponse.next();
}
export const config = { matcher: ['/api/:path*'] };Webhook Handler
Handle incoming webhooks with signature verification:
// app/api/webhooks/sent/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createHmac } from 'crypto';
import { webhookEventSchema } from '@/lib/sent/schemas';
const WEBHOOK_SECRET = process.env.SENT_DM_WEBHOOK_SECRET;
function verifySignature(payload: string, signature: string, secret: string): boolean {
const expectedSignature = createHmac('sha256', secret).update(payload).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
} catch { return false; }
}
export async function POST(request: NextRequest) {
try {
const signature = request.headers.get('x-webhook-signature');
if (!signature) return NextResponse.json({ received: false, error: 'Missing signature' }, { status: 401 });
if (!WEBHOOK_SECRET) return NextResponse.json({ received: false, error: 'Webhook not configured' }, { status: 500 });
const rawBody = await request.text();
if (!verifySignature(rawBody, signature, WEBHOOK_SECRET)) {
return NextResponse.json({ received: false, error: 'Invalid signature' }, { status: 401 });
}
let event;
try { event = JSON.parse(rawBody); } catch { return NextResponse.json({ received: false, error: 'Invalid JSON' }, { status: 400 }); }
const validated = webhookEventSchema.safeParse(event);
if (!validated.success) return NextResponse.json({ received: false, error: 'Invalid event format' }, { status: 400 });
await handleWebhookEvent(validated.data);
return NextResponse.json({ received: true, processed: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json({ received: false, error: 'Internal server error' }, { status: 500 });
}
}
async function handleWebhookEvent(event: { type: string; data: { id: string; status?: string; error?: { message: string } } }) {
console.log(`Processing webhook: ${event.type}`, { messageId: event.data.id });
switch (event.type) {
case 'message.status.updated': await updateMessageStatus(event.data.id, event.data.status); break;
case 'message.delivered': await markMessageDelivered(event.data.id); break;
case 'message.failed': await handleMessageFailure(event.data.id, event.data.error); break;
case 'message.read': await markMessageRead(event.data.id); break;
default: console.warn(`Unhandled webhook event type: ${event.type}`);
}
}
async function updateMessageStatus(messageId: string, status?: string) { console.log(`Updating message ${messageId} status to ${status}`); }
async function markMessageDelivered(messageId: string) { console.log(`Marking message ${messageId} as delivered`); }
async function handleMessageFailure(messageId: string, error?: { message: string }) { console.error(`Message ${messageId} failed:`, error?.message); }
async function markMessageRead(messageId: string) { console.log(`Marking message ${messageId} as read`); }Edge Runtime Support
For lower latency, use Edge Runtime:
// app/api/edge-messages/route.ts
import { NextRequest, NextResponse } from 'next/server';
import SentDm from '@sentdm/sentdm';
export const runtime = 'edge';
export const preferredRegion = 'iad1';
const client = new SentDm({
apiKey: process.env.SENT_DM_API_KEY || '',
maxRetries: 1,
timeout: 10 * 1000,
});
export async function POST(request: NextRequest) {
try {
const { phoneNumber, templateId, templateName, parameters } = await request.json();
if (!phoneNumber || !templateId) {
return NextResponse.json({ error: 'Phone number and template ID are required' }, { status: 400 });
}
const response = await client.messages.send({
to: [phoneNumber],
template: { id: templateId, name: templateName || 'default', parameters: parameters || {} },
});
const message = response.data.messages[0];
return NextResponse.json({ success: true, data: { messageId: message.id, status: message.status } });
} catch (error) {
if (error instanceof SentDm.APIError) {
return NextResponse.json({ error: error.message, code: error.name }, { status: error.status });
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}Environment Variables
# .env.local
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here
# Optional: Template IDs
WELCOME_TEMPLATE_ID=7ba7b820-9dad-11d1-80b4-00c04fd430c8
ORDER_CONFIRMATION_TEMPLATE_ID=order-template-id
# Optional: Rate limiting (Upstash)
UPSTASH_REDIS_REST_URL=https://your-url.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token
# Optional: Internal API key for external requests
INTERNAL_API_KEY=your_internal_api_key
# Optional: CORS origin
ALLOWED_ORIGIN=https://yourdomain.comTesting
Use Vitest to test Server Actions and components:
// app/actions/messages.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { sendMessage, sendWelcomeMessage } from './messages';
import { sentClient } from '@/lib/sent/client';
vi.mock('@/lib/sent/client', () => ({
sentClient: { messages: { send: vi.fn() } },
handleSentError: vi.fn((error) => ({ message: error instanceof Error ? error.message : 'Unknown error', status: 500, code: 'InternalError' })),
}));
describe('sendMessage', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('should send message successfully', async () => {
vi.mocked(sentClient.messages.send).mockResolvedValue({ data: { messages: [{ id: 'msg_123', status: 'pending' }] } });
const formData = new FormData();
formData.append('phoneNumber', '+1234567890');
formData.append('templateId', 'template-123');
formData.append('templateName', 'welcome');
formData.append('parameters', '{}');
const result = await sendMessage(formData);
expect(result.success).toBe(true);
expect(result.data).toEqual({ messageId: 'msg_123', status: 'pending' });
});
it('should return validation error for invalid phone', async () => {
const formData = new FormData();
formData.append('phoneNumber', 'invalid');
formData.append('templateId', 'template-123');
formData.append('templateName', 'welcome');
const result = await sendMessage(formData);
expect(result.success).toBe(false);
expect(result.error.code).toBe('ValidationError');
});
});Test Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], include: ['**/*.spec.ts', '**/*.spec.tsx'] },
resolve: { alias: { '@': path.resolve(__dirname, './') } },
});// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
unstable_cache: (fn: Function) => fn,
}));
vi.mock('server-only', () => ({}));Next Steps
- Handle errors in your application
- Configure webhooks to receive delivery status
- Learn about best practices for production deployments
- Explore testing strategies for your messaging features