Next.js App Router Integration
Build modern, type-safe WhatsApp applications with Next.js App Router and meta-cloud-api. This guide covers server actions, API routes, and client components.
Official Documentation: Next.js App Router
Overview
Next.js App Router (Next.js 13+) introduces Server Components, Server Actions, and a new file-based routing system. This guide demonstrates how to leverage these features with the WhatsApp Cloud API.
Project Setup
1. Create Next.js Project
# Create new Next.js app with TypeScriptnpx create-next-app@latest whatsapp-nextjs-app --typescript --tailwind --app
cd whatsapp-nextjs-app
# Install meta-cloud-apipnpm add meta-cloud-api2. Project Structure
whatsapp-nextjs-app/├── app/│ ├── api/│ │ └── webhook/│ │ └── route.ts # Webhook API route│ ├── actions/│ │ └── whatsapp.ts # Server actions│ ├── components/│ │ ├── MessageForm.tsx # Send message form│ │ └── MessageList.tsx # Display messages│ ├── dashboard/│ │ └── page.tsx # Dashboard page│ ├── layout.tsx # Root layout│ └── page.tsx # Home page├── lib/│ ├── whatsapp/│ │ ├── client.ts # WhatsApp client│ │ └── handlers/│ │ ├── text.ts # Text handler│ │ ├── image.ts # Image handler│ │ └── interactive.ts # Interactive handler│ ├── db/│ │ └── prisma.ts # Prisma client│ └── utils.ts # Utility functions├── prisma/│ └── schema.prisma # Database schema├── .env.local # Environment variables└── next.config.js # Next.js config3. Environment Configuration
Create .env.local:
# WhatsApp Cloud APICLOUD_API_ACCESS_TOKEN=your_access_tokenWA_PHONE_NUMBER_ID=your_phone_number_idWA_BUSINESS_ACCOUNT_ID=your_business_account_idWHATSAPP_WEBHOOK_VERIFICATION_TOKEN=your_verification_token
# DatabaseDATABASE_URL="postgresql://user:password@localhost:5432/whatsapp"
# Next.jsNEXT_PUBLIC_APP_URL=http://localhost:3000
# Optional: API Key for client requestsAPI_SECRET=your_secret_keyWhatsApp Client Setup
Create Client (lib/whatsapp/client.ts)
import WhatsApp from 'meta-cloud-api';
if (!process.env.CLOUD_API_ACCESS_TOKEN) { throw new Error('CLOUD_API_ACCESS_TOKEN is required');}
if (!process.env.WA_PHONE_NUMBER_ID) { throw new Error('WA_PHONE_NUMBER_ID is required');}
export const whatsappClient = new WhatsApp({ accessToken: process.env.CLOUD_API_ACCESS_TOKEN, phoneNumberId: Number(process.env.WA_PHONE_NUMBER_ID), businessAcctId: process.env.WA_BUSINESS_ACCOUNT_ID,});
// Type-safe clientexport type WhatsAppClient = typeof whatsappClient;Webhook Handling with App Router
Webhook API Route (app/api/webhook/route.ts)
import { webhookHandler } from 'meta-cloud-api/webhook/nextjs-app';import { handleTextMessage, handleImageMessage, handleInteractiveMessage,} from '@/lib/whatsapp/handlers';
// WhatsApp configurationconst whatsappConfig = { accessToken: process.env.CLOUD_API_ACCESS_TOKEN!, phoneNumberId: Number(process.env.WA_PHONE_NUMBER_ID!), businessAcctId: process.env.WA_BUSINESS_ACCOUNT_ID, webhookVerificationToken: process.env.WHATSAPP_WEBHOOK_VERIFICATION_TOKEN!,};
// Create webhook handlerconst Whatsapp = webhookHandler(whatsappConfig);
// Register message handlersWhatsapp.processor.onText(handleTextMessage);Whatsapp.processor.onImage(handleImageMessage);Whatsapp.processor.onInteractive(handleInteractiveMessage);
// Export GET and POST handlersexport const { GET, POST } = Whatsapp.webhook;Message Handlers
Text Handler (lib/whatsapp/handlers/text.ts)
import type { WebhookMessage, WhatsApp } from 'meta-cloud-api';import { prisma } from '@/lib/db/prisma';
export async function handleTextMessage( whatsapp: WhatsApp, message: WebhookMessage) { console.log(`📨 Text from ${message.from}: ${message.text?.body}`);
try { // Save to database await prisma.message.create({ data: { messageId: message.id, from: message.from, type: 'text', content: message.text?.body || '', timestamp: new Date(parseInt(message.timestamp) * 1000), }, });
// Mark as read await whatsapp.messages.markAsRead({ messageId: message.id });
// Process and respond const response = processCommand(message.text?.body || '');
if (response) { await whatsapp.messages.text({ to: message.from, body: response, context: { message_id: message.id }, }); }
} catch (error) { console.error('Error handling text:', error); }}
function processCommand(text: string): string | null { const lower = text.toLowerCase().trim();
if (lower === '/start') { return 'Welcome! 👋\n\nAvailable commands:\n/help - Show help\n/status - Check status'; }
if (lower === '/help') { return 'Commands:\n• /start - Start bot\n• /help - Show help\n• /status - Bot status'; }
if (lower === '/status') { return '✅ Bot is running!'; }
return `Echo: ${text}`;}Interactive Handler (lib/whatsapp/handlers/interactive.ts)
import type { WebhookMessage, WhatsApp } from 'meta-cloud-api';import { prisma } from '@/lib/db/prisma';
export async function handleInteractiveMessage( whatsapp: WhatsApp, message: WebhookMessage) { const interactive = message.interactive; if (!interactive) return;
console.log(`🎯 Interactive from ${message.from}`);
try { // Save interaction to database await prisma.interaction.create({ data: { messageId: message.id, from: message.from, type: interactive.type, data: JSON.stringify(interactive), timestamp: new Date(parseInt(message.timestamp) * 1000), }, });
// Handle button reply if (interactive.type === 'button_reply') { await handleButtonReply( whatsapp, message.from, interactive.button_reply?.id || '' ); }
// Handle list reply if (interactive.type === 'list_reply') { await handleListReply( whatsapp, message.from, interactive.list_reply?.id || '' ); }
await whatsapp.messages.markAsRead({ messageId: message.id });
} catch (error) { console.error('Error handling interactive:', error); }}
async function handleButtonReply(whatsapp: WhatsApp, to: string, buttonId: string) { const actions: Record<string, () => Promise<void>> = { 'get_quote': async () => { await whatsapp.messages.text({ to, body: '💰 Here is your quote...', }); }, 'contact_sales': async () => { await whatsapp.messages.text({ to, body: '📞 Our sales team will contact you soon!', }); }, 'view_products': async () => { await sendProductList(whatsapp, to); }, };
const action = actions[buttonId]; if (action) { await action(); }}
async function handleListReply(whatsapp: WhatsApp, to: string, listId: string) { await whatsapp.messages.text({ to, body: `You selected: ${listId}`, });}
async function sendProductList(whatsapp: WhatsApp, to: string) { await whatsapp.messages.interactive({ to, type: 'list', body: { text: 'Choose a product:' }, action: { button: 'View Products', sections: [ { title: 'Popular Products', rows: [ { id: 'product_1', title: 'Product 1', description: 'Description here', }, { id: 'product_2', title: 'Product 2', description: 'Description here', }, ], }, ], }, });}Server Actions
Server Actions allow you to call server-side functions from client components without creating API routes.
Create Actions (app/actions/whatsapp.ts)
'use server';
import { whatsappClient } from '@/lib/whatsapp/client';import { prisma } from '@/lib/db/prisma';import { revalidatePath } from 'next/cache';
// Type definitionsinterface SendTextResult { success: boolean; messageId?: string; error?: string;}
interface SendButtonsResult { success: boolean; messageId?: string; error?: string;}
// Send text messageexport async function sendTextMessage( to: string, message: string): Promise<SendTextResult> { try { // Validate input if (!to || !message) { return { success: false, error: 'Missing required fields' }; }
// Send message const response = await whatsappClient.messages.text({ to, body: message, });
// Save to database await prisma.sentMessage.create({ data: { messageId: response.messages[0].id, to, type: 'text', content: message, }, });
// Revalidate dashboard page revalidatePath('/dashboard');
return { success: true, messageId: response.messages[0].id, }; } catch (error: any) { console.error('Send text error:', error); return { success: false, error: error.response?.data?.error?.message || error.message, }; }}
// Send image messageexport async function sendImageMessage( to: string, imageUrl: string, caption?: string): Promise<SendTextResult> { try { const response = await whatsappClient.messages.image({ to, image: { link: imageUrl, caption, }, });
await prisma.sentMessage.create({ data: { messageId: response.messages[0].id, to, type: 'image', content: imageUrl, }, });
revalidatePath('/dashboard');
return { success: true, messageId: response.messages[0].id, }; } catch (error: any) { console.error('Send image error:', error); return { success: false, error: error.response?.data?.error?.message || error.message, }; }}
// Send button messageexport async function sendButtonMessage( to: string, text: string, buttons: Array<{ id: string; title: string }>): Promise<SendButtonsResult> { try { const response = await whatsappClient.messages.interactive({ to, type: 'button', body: { text }, action: { buttons: buttons.map(btn => ({ type: 'reply', reply: { id: btn.id, title: btn.title, }, })), }, });
await prisma.sentMessage.create({ data: { messageId: response.messages[0].id, to, type: 'interactive', content: text, }, });
revalidatePath('/dashboard');
return { success: true, messageId: response.messages[0].id, }; } catch (error: any) { console.error('Send button error:', error); return { success: false, error: error.response?.data?.error?.message || error.message, }; }}
// Get message history (Server Component)export async function getMessageHistory(phoneNumber?: string, limit = 50) { try { const messages = await prisma.message.findMany({ where: phoneNumber ? { from: phoneNumber } : {}, orderBy: { timestamp: 'desc' }, take: limit, });
return messages; } catch (error) { console.error('Get history error:', error); return []; }}Client Components
Message Form (app/components/MessageForm.tsx)
'use client';
import { useState, useTransition } from 'react';import { sendTextMessage, sendButtonMessage } from '@/app/actions/whatsapp';
export function MessageForm() { const [isPending, startTransition] = useTransition(); const [phoneNumber, setPhoneNumber] = useState(''); const [message, setMessage] = useState(''); const [result, setResult] = useState<string>('');
const handleSendText = () => { startTransition(async () => { const response = await sendTextMessage(phoneNumber, message);
if (response.success) { setResult(`✅ Message sent! ID: ${response.messageId}`); setMessage(''); } else { setResult(`❌ Error: ${response.error}`); } }); };
const handleSendButtons = () => { startTransition(async () => { const response = await sendButtonMessage( phoneNumber, 'Choose an option:', [ { id: 'option_1', title: 'Option 1' }, { id: 'option_2', title: 'Option 2' }, ] );
if (response.success) { setResult(`✅ Buttons sent! ID: ${response.messageId}`); } else { setResult(`❌ Error: ${response.error}`); } }); };
return ( <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md"> <h2 className="text-2xl font-bold mb-4">Send WhatsApp Message</h2>
<div className="space-y-4"> <div> <label className="block text-sm font-medium mb-2"> Phone Number </label> <input type="text" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="15551234567" className="w-full px-3 py-2 border rounded-md" disabled={isPending} /> </div>
<div> <label className="block text-sm font-medium mb-2"> Message </label> <textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Enter your message..." rows={4} className="w-full px-3 py-2 border rounded-md" disabled={isPending} /> </div>
<div className="flex gap-2"> <button onClick={handleSendText} disabled={isPending || !phoneNumber || !message} className="flex-1 bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400" > {isPending ? 'Sending...' : 'Send Text'} </button>
<button onClick={handleSendButtons} disabled={isPending || !phoneNumber} className="flex-1 bg-green-600 text-white py-2 rounded-md hover:bg-green-700 disabled:bg-gray-400" > Send Buttons </button> </div>
{result && ( <div className="p-3 bg-gray-100 rounded-md text-sm"> {result} </div> )} </div> </div> );}Message List (app/components/MessageList.tsx)
'use client';
import { useEffect, useState } from 'react';
interface Message { id: string; from: string; type: string; content: string; timestamp: Date;}
export function MessageList({ initialMessages }: { initialMessages: Message[] }) { const [messages, setMessages] = useState(initialMessages);
// Optional: Poll for new messages useEffect(() => { const interval = setInterval(async () => { const response = await fetch('/api/messages'); const data = await response.json(); setMessages(data.messages); }, 5000);
return () => clearInterval(interval); }, []);
return ( <div className="max-w-2xl mx-auto p-6"> <h2 className="text-2xl font-bold mb-4">Recent Messages</h2>
<div className="space-y-4"> {messages.map((msg) => ( <div key={msg.id} className="p-4 bg-white rounded-lg shadow border" > <div className="flex justify-between items-start mb-2"> <span className="font-semibold">{msg.from}</span> <span className="text-sm text-gray-500"> {new Date(msg.timestamp).toLocaleString()} </span> </div> <div className="text-gray-700">{msg.content}</div> <div className="text-xs text-gray-400 mt-2"> Type: {msg.type} </div> </div> ))}
{messages.length === 0 && ( <p className="text-center text-gray-500">No messages yet</p> )} </div> </div> );}Server Components
Dashboard Page (app/dashboard/page.tsx)
import { Suspense } from 'react';import { getMessageHistory } from '@/app/actions/whatsapp';import { MessageForm } from '@/app/components/MessageForm';import { MessageList } from '@/app/components/MessageList';
export default async function DashboardPage() { // Fetch data in Server Component const messages = await getMessageHistory();
return ( <div className="min-h-screen bg-gray-50 py-8"> <div className="container mx-auto px-4"> <h1 className="text-4xl font-bold text-center mb-8"> WhatsApp Dashboard </h1>
<div className="grid md:grid-cols-2 gap-8"> <div> <MessageForm /> </div>
<div> <Suspense fallback={<LoadingSkeleton />}> <MessageList initialMessages={messages} /> </Suspense> </div> </div> </div> </div> );}
function LoadingSkeleton() { return ( <div className="animate-pulse space-y-4"> {[1, 2, 3].map((i) => ( <div key={i} className="p-4 bg-gray-200 rounded-lg h-24" /> ))} </div> );}
// Optional: Revalidate every 30 secondsexport const revalidate = 30;Database Integration with Prisma
Prisma Schema (prisma/schema.prisma)
datasource db { provider = "postgresql" url = env("DATABASE_URL")}
generator client { provider = "prisma-client-js"}
model Message { id String @id @default(cuid()) messageId String @unique from String type String content String timestamp DateTime createdAt DateTime @default(now())
@@index([from]) @@index([timestamp])}
model SentMessage { id String @id @default(cuid()) messageId String @unique to String type String content String createdAt DateTime @default(now())
@@index([to])}
model Interaction { id String @id @default(cuid()) messageId String @unique from String type String data String timestamp DateTime createdAt DateTime @default(now())
@@index([from])}Prisma Client (lib/db/prisma.ts)
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined;};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = prisma;}Initialize Database
# Install Prismapnpm add @prisma/clientpnpm add -D prisma
# Initialize Prismanpx prisma init
# Create migrationnpx prisma migrate dev --name init
# Generate clientnpx prisma generateAPI Routes (Additional)
Get Messages API (app/api/messages/route.ts)
import { NextRequest, NextResponse } from 'next/server';import { prisma } from '@/lib/db/prisma';
export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams; const phoneNumber = searchParams.get('phone'); const limit = parseInt(searchParams.get('limit') || '50');
const messages = await prisma.message.findMany({ where: phoneNumber ? { from: phoneNumber } : {}, orderBy: { timestamp: 'desc' }, take: limit, });
return NextResponse.json({ messages }); } catch (error) { return NextResponse.json( { error: 'Failed to fetch messages' }, { status: 500 } ); }}Deployment to Vercel
1. Prepare for Deployment
Update next.config.js:
/** @type {import('next').NextConfig} */const nextConfig = { experimental: { serverActions: true, },};
module.exports = nextConfig;2. Deploy
# Install Vercel CLIpnpm add -g vercel
# Deployvercel
# Set environment variablesvercel env add CLOUD_API_ACCESS_TOKENvercel env add WA_PHONE_NUMBER_IDvercel env add WA_BUSINESS_ACCOUNT_IDvercel env add WHATSAPP_WEBHOOK_VERIFICATION_TOKENvercel env add DATABASE_URL
# Deploy to productionvercel --prod3. Configure Webhook
Update Meta webhook URL to:
https://your-app.vercel.app/api/webhookTesting
Unit Tests (__tests__/actions.test.ts)
import { describe, it, expect, vi } from 'vitest';import { sendTextMessage } from '@/app/actions/whatsapp';
// Mock WhatsApp clientvi.mock('@/lib/whatsapp/client', () => ({ whatsappClient: { messages: { text: vi.fn().mockResolvedValue({ messages: [{ id: 'wamid.test123' }], }), }, },}));
// Mock Prismavi.mock('@/lib/db/prisma', () => ({ prisma: { sentMessage: { create: vi.fn(), }, },}));
describe('WhatsApp Actions', () => { it('should send text message', async () => { const result = await sendTextMessage('15551234567', 'Test message');
expect(result.success).toBe(true); expect(result.messageId).toBe('wamid.test123'); });
it('should handle errors', async () => { const result = await sendTextMessage('', '');
expect(result.success).toBe(false); expect(result.error).toBeDefined(); });});Best Practices
- Use Server Actions for mutations - Keep client components light
- Implement proper error boundaries - Handle errors gracefully
- Use Suspense for async components - Better loading states
- Leverage caching with revalidate - Optimize performance
- Store sensitive data in environment variables - Never expose keys
- Use TypeScript for type safety - Catch errors early
- Implement database transactions - Ensure data consistency
- Monitor webhook performance - Use Vercel Analytics
Troubleshooting
Server Actions Not Working
// Ensure 'use server' directive at top of file'use server';
import { whatsappClient } from '@/lib/whatsapp/client';// ... rest of codeDatabase Connection Issues
// Check DATABASE_URL format// PostgreSQL: postgresql://user:password@host:5432/db// MySQL: mysql://user:password@host:3306/dbWebhook Timing Out
// Ensure fast responseexport async function POST(request: Request) { // Process webhook asynchronously const body = await request.json();
// Return 200 immediately setTimeout(async () => { await processWebhook(body); }, 0);
return new Response('OK', { status: 200 });}Example Repository
View the complete Next.js App Router example: nextjs-app-router-example