Skip to content

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

Terminal window
# Create new Next.js app with TypeScript
npx create-next-app@latest whatsapp-nextjs-app --typescript --tailwind --app
cd whatsapp-nextjs-app
# Install meta-cloud-api
pnpm add meta-cloud-api

2. 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 config

3. Environment Configuration

Create .env.local:

Terminal window
# WhatsApp Cloud API
CLOUD_API_ACCESS_TOKEN=your_access_token
WA_PHONE_NUMBER_ID=your_phone_number_id
WA_BUSINESS_ACCOUNT_ID=your_business_account_id
WHATSAPP_WEBHOOK_VERIFICATION_TOKEN=your_verification_token
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/whatsapp"
# Next.js
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Optional: API Key for client requests
API_SECRET=your_secret_key

WhatsApp 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 client
export 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 configuration
const 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 handler
const Whatsapp = webhookHandler(whatsappConfig);
// Register message handlers
Whatsapp.processor.onText(handleTextMessage);
Whatsapp.processor.onImage(handleImageMessage);
Whatsapp.processor.onInteractive(handleInteractiveMessage);
// Export GET and POST handlers
export 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 definitions
interface SendTextResult {
success: boolean;
messageId?: string;
error?: string;
}
interface SendButtonsResult {
success: boolean;
messageId?: string;
error?: string;
}
// Send text message
export 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 message
export 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 message
export 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 seconds
export 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

Terminal window
# Install Prisma
pnpm add @prisma/client
pnpm add -D prisma
# Initialize Prisma
npx prisma init
# Create migration
npx prisma migrate dev --name init
# Generate client
npx prisma generate

API 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

Terminal window
# Install Vercel CLI
pnpm add -g vercel
# Deploy
vercel
# Set environment variables
vercel env add CLOUD_API_ACCESS_TOKEN
vercel env add WA_PHONE_NUMBER_ID
vercel env add WA_BUSINESS_ACCOUNT_ID
vercel env add WHATSAPP_WEBHOOK_VERIFICATION_TOKEN
vercel env add DATABASE_URL
# Deploy to production
vercel --prod

3. Configure Webhook

Update Meta webhook URL to:

https://your-app.vercel.app/api/webhook

Testing

Unit Tests (__tests__/actions.test.ts)

import { describe, it, expect, vi } from 'vitest';
import { sendTextMessage } from '@/app/actions/whatsapp';
// Mock WhatsApp client
vi.mock('@/lib/whatsapp/client', () => ({
whatsappClient: {
messages: {
text: vi.fn().mockResolvedValue({
messages: [{ id: 'wamid.test123' }],
}),
},
},
}));
// Mock Prisma
vi.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

  1. Use Server Actions for mutations - Keep client components light
  2. Implement proper error boundaries - Handle errors gracefully
  3. Use Suspense for async components - Better loading states
  4. Leverage caching with revalidate - Optimize performance
  5. Store sensitive data in environment variables - Never expose keys
  6. Use TypeScript for type safety - Catch errors early
  7. Implement database transactions - Ensure data consistency
  8. 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 code

Database Connection Issues

// Check DATABASE_URL format
// PostgreSQL: postgresql://user:password@host:5432/db
// MySQL: mysql://user:password@host:3306/db

Webhook Timing Out

// Ensure fast response
export 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