TypeScript SDK

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/node
yarn add @sentdm/sentdm zod
yarn add -D @types/node
pnpm add @sentdm/sentdm zod
pnpm add -D @types/node
bun add @sentdm/sentdm zod

For rate limiting (optional):

npm install @upstash/ratelimit @upstash/redis

Project 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 page

SDK 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.com

Testing

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

On this page