WhatsApp has become one of the most effective means of communication in the world since it’s a platform people not only trust but use constantly.
It’s no wonder, then, that people are now adapting WhatsApp to send surveys as well, for customer follow-ups after a purchase, service feedback for restaurants and retail, and much more. That's because WhatsApp’s high engagement and global accessibility make it a natural fit for collecting real-time feedback.
What if I told you that you can build one for yourself, too?
In this tutorial, you’ll learn how to build a complete WhatsApp survey system using Next.js, Strapi, and Twilio.
To follow through with this tutorial, the following prerequisites should be met:
So, here's the scope of the project.
To start up ngrok, run this command:
ngrok http 3000
Copy the ngrok URL from your terminal, it’ll look something like https://abcd1234.ngrok.io. You’ll need this for Twilio to send webhook requests to your local development server.
This ensures Twilio can forward replies from users directly into your app while testing locally.
Log in to the Twilio Console. From the Account Info panel of the main dashboard, copy your Account SID, Auth Token, and phone number. Again, store them somewhere safe, for the time being.
Next, head over to Explore products and click on Messaging and then Try WhatsApp. This will redirect to the WhatsApp sandbox. Head over to the sandbox settings tab and update the URL with this:
your-ngrok-url/api/receive-response
The receive-response
is the webhook and we'll create that later in the tutorial.
You need to have Strapi installed on your local machine. Navigate to the folder you want your project installed in the terminal and run this command:
npx create-strapi@latest my-strapi-project
Replace my-strapi-project
with the actual project name you intend to use.
Once you've run this command, the terminal will ask if you want to Login/Signup
or skip
this step. Make your pick by using the arrow keys and pressing Enter. If you opt to log in, you will receive a 30-day trial of the Growth plan, which will be automatically applied to your newly formed project. If you neglect this step, your project will revert to the CMS Free plan.
Proceed to set up Strapi the way you want it.
To start the Strapi application, run the following command in the project folder:
npm run develop
You’ll need to sign up to access your Strapi dashboard. After you’re done signing up, you should have a dashboard.
The next step to setting up Strapi is to create a collection type
. Navigate to Content-Type Builder on the side navigation bar of your dashboard. For this project, we'll create two collection types and click on the + sign to create a new collection type. For this project, we'll create two collection types. One for a survey and the other for a response.
For the first collection type, name it Survey-question and click the "Continue" button. This will take you to the next step: selecting the fields appropriate for your collection type. Give it the following fields:
questionText
(Text, required)order
(Number, required)isActive
(Boolean, default: true)Click on "finish" and then save the collection type. Your collection type should look like this:
For the second collection, name it survey-response
. Click the "Continue" button. This will take you to the next step: selecting the fields appropriate for your collection type. Give it the following fields:
phoneNumber
(Text, required)questionId
(Number, required)questionText
(Text)answer
(Text, required)timestamp
(DateTime, default: now)Click on "finish" and then save the collection type. Your collection type should look like this:
Head over to Content Manager>survey-question and populate with a few questions like:
After creating your collection type, navigate to Settings > USERS & PERMISSIONS PLUGIN > Rolesand click on Public. Then scroll down to Permissions, click on survey-question, select find
and findOne,
and click Save.
Also, click on survey-response and select create,
find,
findOne
and click Save.
To create an API token, navigate to Settings > API Tokens. Create a new API token and set it, naming it what you want and giving it full access.
So we've finished setting up Strapi. Let us now set up and develop the frontend.
To install Next.js, navigate back to the project folder in the terminal. Run the following command:
npx create-next-app@latest
Follow the instructions to set up your project, but ensure to use the app router
because that's what we'll be using in our project:
What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like your code inside a `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to use Turbopack for `next dev`? No / Yes
Would you like to customize the import alias (`@/*` by default)? No / Yes
What import alias would you like configured? @/*
Below is a representation of how our project folder will look
src/app/
├── api/
│ ├── send-survey/
│ │ └── route.js
│ └── receive-response/
│ └── route.js
├── send-survey/
│ └── page.js
├── responses/
│ └── page.js
├── questions/
│ └── page.js
└── page.js
Create .env.local in your Next.js project and the Twilio credentials, your Strapi API token as well as the URL like so:
TWILIO_ACCOUNT_SID=your_twilio_account_sid
TWILIO_AUTH_TOKEN=your_twilio_auth_token
TWILIO_WHATSAPP_FROM=whatsapp:+14155238886
STRAPI_API_URL=http://localhost:1337
STRAPI_API_TOKEN=your_strapi_api_token
For this project, we'll be installing Twilio and axios.
Open up a terminal and navigate to your project folder. Run the following code:
npm install twilio axios
You need an API endpoint that will be used to fetch the questions from Strapi and send them via WhatsApp using Twilio.
Create a folder named api
in the root project folder, then inside it, add a send-survey
subfolder. Inside that, create a file named route.js
. Add the following code:
1import { NextResponse } from "next/server";
2import twilio from "twilio";
3
4const requiredEnvVars = {
5 TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID,
6 TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
7 TWILIO_WHATSAPP_FROM: process.env.TWILIO_WHATSAPP_FROM,
8 STRAPI_API_URL: process.env.STRAPI_API_URL,
9 STRAPI_API_TOKEN: process.env.STRAPI_API_TOKEN,
10};
11
12const missingVars = Object.entries(requiredEnvVars)
13 .filter(([key, value]) => !value)
14 .map(([key]) => key);
15
16if (missingVars.length > 0) {
17 console.error(`Missing environment variables: ${missingVars.join(", ")}`);
18}
19
20const client = twilio(
21 process.env.TWILIO_ACCOUNT_SID,
22 process.env.TWILIO_AUTH_TOKEN
23);
24
25export async function POST(request) {
26 try {
27 if (missingVars.length > 0) {
28 console.error(`Missing environment variables: ${missingVars.join(", ")}`);
29 return NextResponse.json(
30 { error: `Missing environment variables: ${missingVars.join(", ")}` },
31 { status: 500 }
32 );
33 }
34
35 const { phoneNumber } = await request.json();
36
37 if (!phoneNumber) {
38 return NextResponse.json(
39 { error: "Phone number is required" },
40 { status: 400 }
41 );
42 }
43
44 const strapiUrl = `${process.env.STRAPI_API_URL}/api/survey-questions?sort=order:asc&pagination[limit]=1`;
45 console.log("Fetching from:", strapiUrl); // Debug log
46
47 const response = await fetch(strapiUrl, {
48 headers: {
49 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
50 },
51 });
52
53 if (!response.ok) {
54 console.error(
55 "Strapi response error:",
56 response.status,
57 response.statusText
58 );
59 return NextResponse.json(
60 {
61 error: `Failed to fetch questions from Strapi: ${response.statusText}`,
62 },
63 { status: 500 }
64 );
65 }
66
67 const data = await response.json();
68 const firstQuestion = data.data[0];
69
70 if (!firstQuestion) {
71 return NextResponse.json(
72 { error: "No questions found" },
73 { status: 404 }
74 );
75 }
76
77 const message = await client.messages.create({
78 from: process.env.TWILIO_WHATSAPP_FROM,
79 to: `whatsapp:${phoneNumber}`,
80 body: `📋 Survey Started!\n\nQuestion 1: ${firstQuestion.questionText}\n\nReply with your answer to continue.`,
81 });
82
83 return NextResponse.json({ success: true, messageSid: message.sid });
84 } catch (error) {
85 console.error("Error sending survey:", error);
86 return NextResponse.json(
87 { error: "Failed to send survey", details: error.message },
88 { status: 500 }
89 );
90 }
91}
In the code above, we define a Next.js API route that kicks off a WhatsApp survey. It begins by verifying that all required environment variables, such as Twilio and Strapi credentials, are set. If anything’s missing, it logs an error and returns a failure response.
Once a valid phone number is received in the request body, the route fetches the first survey question from Strapi. If a question is found, it sends it to the user via WhatsApp using Twilio. The response confirms the message was sent and includes the message SID for reference. This route acts as the entry point for starting any new survey session.
You also need a webhook in which Twilio will call when a user replies to a survey message. This will be used to process their answer, save it to Strapi, and send the next question.
In the src/app/api/
folder, create a folder called receive-response
. Inside it, create a file called route.js
. To explain better, let's break the logic into reusable steps and helper functions:
First, you need to set up imports and environment variables, and also create a function that gets the current question. Add the following:
Step 1: Create Environment Variable and getCurrentQuestionForUser
Function
1import { NextResponse } from "next/server";
2import twilio from "twilio";
3
4const requiredEnvVars = {
5 TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID,
6 TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
7 TWILIO_WHATSAPP_FROM: process.env.TWILIO_WHATSAPP_FROM,
8 STRAPI_API_URL: process.env.STRAPI_API_URL,
9 STRAPI_API_TOKEN: process.env.STRAPI_API_TOKEN,
10};
11
12const missingVars = Object.entries(requiredEnvVars)
13 .filter(([key, value]) => !value)
14 .map(([key]) => key);
15
16if (missingVars.length > 0) {
17 console.error(`Missing environment variables: ${missingVars.join(", ")}`);
18}
19
20const client = twilio(
21 process.env.TWILIO_ACCOUNT_SID,
22 process.env.TWILIO_AUTH_TOKEN
23);
24
25async function getCurrentQuestionForUser(phoneNumber) {
26 try {
27 console.log(`🔍 Getting current question for ${phoneNumber}`);
28 const allQuestionsResponse = await fetch(
29 `${process.env.STRAPI_API_URL}/api/survey-questions?sort=order:asc`,
30 {
31 headers: {
32 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
33 },
34 }
35 );
36
37 if (!allQuestionsResponse.ok) {
38 throw new Error(
39 `Failed to fetch questions: ${allQuestionsResponse.status} ${allQuestionsResponse.statusText}`
40 );
41 }
42
43 const allQuestionsData = await allQuestionsResponse.json();
44 const allQuestions = allQuestionsData.data;
45 console.log(`Found ${allQuestions.length} total questions`);
46
47 console.log(
48 "First question structure:",
49 JSON.stringify(allQuestions[0], null, 2)
50 );
51 console.log(
52 "Question orders:",
53 allQuestions.map((q) => q.order)
54 );
55
56 if (allQuestions.length === 0) {
57 console.log("No questions found in Strapi");
58 return { question: null, isFirstQuestion: false };
59 }
60
61 console.log(`Fetching responses for phone: "${phoneNumber}"`);
62
63 let responsesResponse;
64 let userResponses = [];
65
66 try {
67 const encodedPhone = encodeURIComponent(phoneNumber);
68 const url1 = `${process.env.STRAPI_API_URL}/api/survey-responses?filters[phoneNumber][$eq]=${encodedPhone}&sort=createdAt:asc`;
69 console.log(`🔍 Trying URL 1: ${url1}`);
70
71 responsesResponse = await fetch(url1, {
72 headers: {
73 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
74 },
75 });
76
77 if (responsesResponse.ok) {
78 const responsesData = await responsesResponse.json();
79 userResponses = responsesData.data || [];
80 console.log(`Approach 1 found ${userResponses.length} responses`);
81 }
82 } catch (error) {
83 console.log("Approach 1 failed:", error.message);
84 }
85
86 if (userResponses.length === 0) {
87 try {
88 console.log(
89 "Trying approach 2: Get all responses and filter manually"
90 );
91 const url2 = `${process.env.STRAPI_API_URL}/api/survey-responses?sort=createdAt:asc`;
92
93 responsesResponse = await fetch(url2, {
94 headers: {
95 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
96 },
97 });
98
99 if (responsesResponse.ok) {
100 const allResponsesData = await responsesResponse.json();
101 const allResponses = allResponsesData.data || [];
102
103 console.log(`Total responses in DB: ${allResponses.length}`);
104 console.log(
105 "First response structure:",
106 JSON.stringify(allResponses[0], null, 2)
107 );
108
109 userResponses = allResponses.filter((response) => {
110 const storedPhone =
111 response.phoneNumber || response.data?.phoneNumber;
112 console.log(`Comparing "${storedPhone}" with "${phoneNumber}"`);
113 return storedPhone === phoneNumber;
114 });
115
116 console.log(
117 ` Manual filter found ${userResponses.length} responses for ${phoneNumber}`
118 );
119 }
120 } catch (error) {
121 console.log("Approach 2 failed:", error.message);
122 }
123 }
124
125 console.log(
126 ` Final result: Found ${userResponses.length} existing responses for ${phoneNumber}`
127 );
128
129 console.log(
130 "Full response structure:",
131 JSON.stringify(userResponses[0], null, 2)
132 );
133
134 userResponses.forEach((resp, idx) => {
135 const questionId =
136 resp.attributes?.questionId || resp.questionId || resp.data?.questionId;
137 const answer =
138 resp.attributes?.answer || resp.answer || resp.data?.answer;
139 console.log(` Response ${idx + 1}: Q${questionId} = "${answer}"`);
140 });
141
142 if (userResponses.length === 0) {
143 console.log("First question for this user");
144 return { question: allQuestions[0], isFirstQuestion: true };
145 }
146
147 const answeredQuestionIds = userResponses
148 .map(
149 (response) =>
150 response.questionId ||
151 response.data?.questionId
152 )
153 .filter((id) => id !== undefined);
154 console.log("Answered question IDs:", answeredQuestionIds);
155
156 const nextQuestion = allQuestions.find(
157 (q) => !answeredQuestionIds.includes(q.order)
158 );
159
160 if (nextQuestion) {
161 console.log(`Next question to answer: Q${nextQuestion.order}`);
162 } else {
163 console.log("All questions have been answered!");
164 }
165
166 return { question: nextQuestion, isFirstQuestion: false };
167 } catch (error) {
168 console.error(" Error in getCurrentQuestionForUser:", error);
169 return { question: null, isFirstQuestion: false };
170 }
171}
The getCurrentQuestionForUser
function checks which survey question a user should be asked next. It fetches all questions from Strapi and figures out which questions they've already answered.
Step 2: Create getNextQuestion
Function
When a user answers a question, it should move on to the next, so we need a function that grabs the next question. Right after the getCurrentQuestionForUser
add this:
1async function getNextQuestion(currentOrder) {
2 try {
3 console.log(`🔍 Looking for question after order ${currentOrder}`);
4
5 const response = await fetch(
6 `${process.env.STRAPI_API_URL}/api/survey-questions?filters[order][$gt]=${currentOrder}&sort=order:asc&pagination[limit]=1`,
7 {
8 headers: {
9 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
10 },
11 }
12 );
13
14 if (!response.ok) {
15 throw new Error(
16 `Failed to fetch next question: ${response.status} ${response.statusText}`
17 );
18 }
19
20 const data = await response.json();
21 const nextQuestion = data.data[0] || null;
22
23 if (nextQuestion) {
24 console.log(`Found next question: Q${nextQuestion.order}`);
25 } else {
26 console.log(" No more questions - survey complete");
27 }
28
29 return nextQuestion;
30 } catch (error) {
31 console.error("Error in getNextQuestion:", error);
32 return null;
33 }
34}
The getNextQuestion
function fetches the next survey question after a given order value. It queries Strapi for the next question where order is greater than currentOrder
, sorted in ascending order, and limited to just one result. If found, it returns that question; if not, it logs that the survey is complete.
Step 3: Create SaveResponse
Function
Lastly, you want to save the response of the user when they are done with the survey. Right after the getNextQuestion
, add this:
1async function saveResponse(phoneNumber, question, answer) {
2 try {
3 const questionData = question;
4 const payload = {
5 data: {
6 phoneNumber,
7 questionId: questionData.order,
8 answer,
9 timestamp: new Date().toISOString(),
10 },
11 };
12
13 console.log("Saving payload:", JSON.stringify(payload, null, 2));
14
15 const response = await fetch(
16 `${process.env.STRAPI_API_URL}/api/survey-responses`,
17 {
18 method: "POST",
19 headers: {
20 "Content-Type": "application/json",
21 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
22 },
23 body: JSON.stringify(payload),
24 }
25 );
26
27 if (!response.ok) {
28 const errorText = await response.text();
29 console.error(`Save failed: ${response.status} ${response.statusText}`);
30 console.error("Error details:", errorText);
31 throw new Error(
32 `Failed to save response: ${response.statusText} - ${errorText}`
33 );
34 }
35
36 const result = await response.json();
37 console.log("Response saved successfully:", result.data?.id);
38 return { success: true, data: result };
39 } catch (error) {
40 console.error(" Error in saveResponse:", error);
41 return { success: false, error: error.message };
42 }
43}
The saveResponse
function saves a user's answer to a survey question in Strapi. It builds a payload with the phone number, question order, answer, and a timestamp. It then sends this data as a POST request to the /survey-responses
endpoint.
Step 4: Create Main Function Now let's wire everything inside a main function:
1export async function POST(request) {
2 try {
3 console.log("=== WEBHOOK RECEIVED ===");
4
5 if (missingVars.length > 0) {
6 console.error(`Missing environment variables: ${missingVars.join(", ")}`);
7 return new NextResponse("Configuration Error", { status: 500 });
8 }
9
10 const formData = await request.formData();
11 const from = formData.get("From");
12 const body = formData.get("Body");
13 const phoneNumber = from.replace("whatsapp:", "");
14
15 console.log(`From: ${phoneNumber}`);
16 console.log(` Message: ${body}`);
17 console.log(`Strapi URL: ${process.env.STRAPI_API_URL}`);
18
19 console.log("Testing Strapi connection...");
20 try {
21 const testResponse = await fetch(
22 `${process.env.STRAPI_API_URL}/api/survey-questions`,
23 {
24 headers: {
25 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
26 },
27 }
28 );
29 console.log(
30 `Strapi connection: ${testResponse.status} ${testResponse.statusText}`
31 );
32 } catch (strapiError) {
33 console.error("Strapi connection failed:", strapiError.message);
34 return new NextResponse("Strapi Connection Error", { status: 500 });
35 }
36
37 const currentQuestionInfo = await getCurrentQuestionForUser(phoneNumber);
38 console.log("Current question info:", currentQuestionInfo);
39
40 if (currentQuestionInfo.question) {
41 console.log(
42 `Saving response for question ${currentQuestionInfo.question.order}`
43 );
44
45 const saveResult = await saveResponse(
46 phoneNumber,
47 currentQuestionInfo.question,
48 body
49 );
50 console.log("Save result:", saveResult);
51
52 const nextQuestion = await getNextQuestion(
53 currentQuestionInfo.question.order
54 );
55 console.log("Next question:", nextQuestion);
56
57 if (nextQuestion) {
58 const message = await client.messages.create({
59 from: process.env.TWILIO_WHATSAPP_FROM,
60 to: from,
61 body: `Question ${nextQuestion.order}: ${nextQuestion.questionText}`,
62 });
63 console.log(
64 `Sent question ${nextQuestion.order} to ${phoneNumber} (SID: ${message.sid})`
65 );
66 } else {
67 const message = await client.messages.create({
68 from: process.env.TWILIO_WHATSAPP_FROM,
69 to: from,
70 body: " Thank you for completing our survey! Your feedback is valuable to us.",
71 });
72 console.log(
73 `Survey completed for ${phoneNumber} (SID: ${message.sid})`
74 );
75 }
76 } else {
77 const message = await client.messages.create({
78 from: process.env.TWILIO_WHATSAPP_FROM,
79 to: from,
80 body: "Hi! You don't have an active survey. Please wait for a new survey to be sent to you.",
81 });
82 console.log(` No active survey for ${phoneNumber} (SID: ${message.sid})`);
83 }
84
85 return new NextResponse("OK");
86 } catch (error) {
87 console.error("Error processing response:", error);
88 console.error("Stack trace:", error.stack);
89 return new NextResponse("Error", { status: 500 });
90 }
91}
The POST
function handles incoming WhatsApp messages. It checks the config, gets the user's message and phone number, confirms Strapi is reachable, and then wires together the other functions. It finds the current question, saves the user's answer, fetches the next question, and replies via WhatsApp. Once the survey is done, it sends a thank-you message.
You need a page where a user can enter a WhatsApp number to which the survey questions are to be sent.
In the src/app,
create a folder called send-survey.
Inside it, create a file called page.js
and add the following code:
1"use client";
2import { useState } from "react";
3
4export default function SendSurvey() {
5 const [phoneNumber, setPhoneNumber] = useState("");
6 const [loading, setLoading] = useState(false);
7 const [message, setMessage] = useState("");
8
9 const handleSendSurvey = async (e) => {
10 e.preventDefault();
11 setLoading(true);
12 setMessage("");
13
14 try {
15 const response = await fetch("/api/send-survey", {
16 method: "POST",
17 headers: {
18 "Content-Type": "application/json",
19 },
20 body: JSON.stringify({ phoneNumber }),
21 });
22
23 const data = await response.json();
24
25 if (response.ok) {
26 setMessage("Survey sent successfully!");
27 setPhoneNumber("");
28 } else {
29 setMessage(`Error: ${data.error}`);
30 }
31 } catch (error) {
32 setMessage("Failed to send survey");
33 } finally {
34 setLoading(false);
35 }
36 };
37
38 return (
39 <div className="container mx-auto px-4 py-8">
40 <div className="mb-4">
41 <a href="/" className="text-blue-600 hover:text-blue-800">
42 ← Back to Home
43 </a>
44 </div>
45
46 <h1 className="text-3xl font-bold mb-8">Send WhatsApp Survey</h1>
47
48 <div className="max-w-md mx-auto bg-white p-6 rounded-lg shadow-md">
49 <form onSubmit={handleSendSurvey}>
50 <div className="mb-4">
51 <label
52 htmlFor="phoneNumber"
53 className="block text-sm font-medium text-gray-700 mb-2"
54 >
55 Phone Number (with country code)
56 </label>
57 <input
58 type="tel"
59 id="phoneNumber"
60 value={phoneNumber}
61 onChange={(e) => setPhoneNumber(e.target.value)}
62 placeholder="+1234567890"
63 className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
64 required
65 />
66 </div>
67
68 <button
69 type="submit"
70 disabled={loading}
71 className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50"
72 >
73 {loading ? "Sending..." : "Send Survey"}
74 </button>
75 </form>
76
77 {message && (
78 <div
79 className={`mt-4 p-3 rounded-md ${
80 message.includes("Error")
81 ? "bg-red-100 text-red-700"
82 : "bg-green-100 text-green-700"
83 }`}
84 >
85 {message}
86 </div>
87 )}
88 </div>
89 </div>
90 );
91}
The code above creates a component that powers the “Send Survey” page, which allows an admin or operator to trigger a WhatsApp survey for any phone number. The user enters a number, submits the form, and the app sends a request to the /api/send-survey
endpoint.
In the src/app,
create a folder called responses.
Inside it, create a file called page.js
and add the following code:
1"use client";
2import { useState, useEffect } from "react";
3
4export default function Responses() {
5 const [responses, setResponses] = useState([]);
6 const [loading, setLoading] = useState(true);
7 const [error, setError] = useState("");
8 const [groupedResponses, setGroupedResponses] = useState({});
9
10 useEffect(() => {
11 fetchResponses();
12 }, []);
13
14 const fetchResponses = async () => {
15 try {
16 setLoading(true);
17 setError("");
18
19 const strapiUrl =
20 process.env.NEXT_PUBLIC_STRAPI_API_URL || "http://localhost:1337";
21 console.log("Fetching from:", `${strapiUrl}/api/survey-responses`);
22
23 const response = await fetch(
24 `${strapiUrl}/api/survey-responses?sort=createdAt:desc`,
25 {
26 headers: {
27 "Content-Type": "application/json",
28 },
29 }
30 );
31
32 if (!response.ok) {
33 throw new Error(`HTTP error! status: ${response.status}`);
34 }
35
36 const data = await response.json();
37 console.log("Raw API response:", data);
38
39 const responseData = data.data || [];
40 setResponses(responseData);
41
42 const grouped = responseData.reduce((acc, response) => {
43
44 const phone =
45 response.phoneNumber ||
46 response.data?.phoneNumber;
47
48 if (phone) {
49 if (!acc[phone]) {
50 acc[phone] = [];
51 }
52 acc[phone].push(response);
53 }
54 return acc;
55 }, {});
56
57 console.log("Grouped responses:", grouped);
58 setGroupedResponses(grouped);
59 } catch (error) {
60 console.error("Error fetching responses:", error);
61 setError(`Failed to fetch responses: ${error.message}`);
62 } finally {
63 setLoading(false);
64 }
65 };
66
67 const getResponseValue = (response, field) => {
68 return (
69 response.attributes?.[field] ||
70 response[field] ||
71 response.data?.[field] ||
72 "N/A"
73 );
74 };
75
76 if (loading) {
77 return (
78 <div className="container mx-auto px-4 py-8">
79 <div className="text-center">Loading responses...</div>
80 </div>
81 );
82 }
83
84 if (error) {
85 return (
86 <div className="container mx-auto px-4 py-8">
87 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
88 {error}
89 </div>
90 <button
91 onClick={fetchResponses}
92 className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
93 >
94 Retry
95 </button>
96 </div>
97 );
98 }
99
100 return (
101 <div className="container mx-auto px-4 py-8">
102 <div className="mb-4">
103 <a href="/" className="text-blue-600 hover:text-blue-800">
104 ← Back to Home
105 </a>
106 </div>
107
108 <div className="flex justify-between items-center mb-8">
109 <h1 className="text-3xl font-bold">Survey Responses</h1>
110 <button
111 onClick={fetchResponses}
112 className="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700"
113 disabled={loading}
114 >
115 Refresh
116 </button>
117 </div>
118
119 <div className="mb-4 bg-blue-50 p-4 rounded">
120 <p className="text-sm text-blue-700">
121 Total responses: {responses.length} | Unique users:{" "}
122 {Object.keys(groupedResponses).length}
123 </p>
124 </div>
125
126 {Object.keys(groupedResponses).length === 0 ? (
127 <div className="text-center py-12">
128 <div className="text-6xl mb-4">📊</div>
129 <p className="text-gray-600 text-lg">No responses yet.</p>
130 <p className="text-gray-500 text-sm mt-2">
131 Send a survey to get started!
132 </p>
133 </div>
134 ) : (
135 <div className="space-y-6">
136 {Object.entries(groupedResponses).map(
137 ([phoneNumber, userResponses]) => (
138 <div
139 key={phoneNumber}
140 className="bg-white p-6 rounded-lg shadow-md border"
141 >
142 <h3 className="text-lg font-semibold mb-4 text-blue-600">
143 📱 {phoneNumber}
144 <span className="ml-2 text-sm text-gray-500 font-normal">
145 ({userResponses.length} response
146 {userResponses.length !== 1 ? "s" : ""})
147 </span>
148 </h3>
149
150 <div className="space-y-3">
151 {userResponses
152 .sort((a, b) => {
153 const aOrder = getResponseValue(a, "questionId");
154 const bOrder = getResponseValue(b, "questionId");
155 return aOrder - bOrder;
156 })
157 .map((response, index) => (
158 <div
159 key={response.id || index}
160 className="border-l-4 border-gray-200 pl-4 py-2"
161 >
162 <p className="font-medium text-gray-800">
163 Q{getResponseValue(response, "questionId")}:{" "}
164 {getResponseValue(response, "questionText")}
165 </p>
166 <p className="text-gray-600 mt-1 bg-gray-50 p-2 rounded">
167 <strong>Answer:</strong>{" "}
168 {getResponseValue(response, "answer")}
169 </p>
170 <p className="text-xs text-gray-400 mt-1">
171 {new Date(
172 getResponseValue(response, "timestamp") ||
173 getResponseValue(response, "createdAt")
174 ).toLocaleString()}
175 </p>
176 </div>
177 ))}
178 </div>
179 </div>
180 )
181 )}
182 </div>
183 )}
184
185 {/* Debug section - remove in production */}
186 <details className="mt-8 bg-gray-100 p-4 rounded">
187 <summary className="cursor-pointer text-sm text-gray-600">
188 Debug Information (click to expand)
189 </summary>
190 <pre className="mt-2 text-xs overflow-auto">
191 {JSON.stringify(
192 { responses: responses.slice(0, 2), groupedResponses },
193 null,
194 2
195 )}
196 </pre>
197 </details>
198 </div>
199 );
200}
The code above creates a component that displays the responses. When the page loads, it fetches all survey responses from Strapi and organizes them by phone number.
Below is an image of the response page:
In the src/app
folder, create a subfolder called questions
. Inside it, create a file called page.js
and add the following code:
1"use client";
2import { useState, useEffect } from "react";
3
4export default function Questions() {
5 const [questions, setQuestions] = useState([]);
6 const [loading, setLoading] = useState(true);
7 const [error, setError] = useState("");
8
9 useEffect(() => {
10 fetchQuestions();
11 }, []);
12
13 const fetchQuestions = async () => {
14 try {
15 setLoading(true);
16 setError("");
17
18 const strapiUrl =
19 process.env.NEXT_PUBLIC_STRAPI_API_URL || "http://localhost:1337";
20 console.log(
21 "Fetching questions from:",
22 `${strapiUrl}/api/survey-questions`
23 );
24
25 const response = await fetch(
26 `${strapiUrl}/api/survey-questions?sort=order:asc`,
27 {
28 headers: {
29 "Content-Type": "application/json",
30 },
31 }
32 );
33
34 if (!response.ok) {
35 throw new Error(`HTTP error! status: ${response.status}`);
36 }
37
38 const data = await response.json();
39 console.log("Questions API response:", data);
40
41 const questionsData = data.data || [];
42 setQuestions(questionsData);
43 } catch (error) {
44 console.error("Error fetching questions:", error);
45 setError(`Failed to fetch questions: ${error.message}`);
46 } finally {
47 setLoading(false);
48 }
49 };
50
51 const getQuestionValue = (question, field) => {
52 return (
53 question[field] ||
54 question.data?.[field] ||
55 "N/A"
56 );
57 };
58
59 if (loading) {
60 return (
61 <div className="container mx-auto px-4 py-8">
62 <div className="text-center">Loading questions...</div>
63 </div>
64 );
65 }
66
67 if (error) {
68 return (
69 <div className="container mx-auto px-4 py-8">
70 <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
71 {error}
72 </div>
73 <button
74 onClick={fetchQuestions}
75 className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
76 >
77 Retry
78 </button>
79 </div>
80 );
81 }
82
83 return (
84 <div className="container mx-auto px-4 py-8 hv/vhhhhhhhhh
85 <div className="mb-4">
86 <a href="/" className="text-blue-600 hover:text-blue-800">
87 ← Back to Home
88 </a>
89 </div>
90
91 <div className="flex justify-between items-center mb-8">
92 <h1 className="text-3xl font-bold">Survey Questions</h1>
93 <div className="space-x-2">
94 <button
95 onClick={fetchQuestions}
96 className="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700"
97 disabled={loading}
98 >
99 Refresh
100 </button>
101 <a
102 href="http://localhost:1337/admin"
103 target="_blank"
104 rel="noopener noreferrer"
105 className="bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700"
106 >
107 Manage in Strapi Admin
108 </a>
109 </div>
110 </div>
111
112 <div className="mb-4 bg-blue-50 p-4 rounded">
113 <p className="text-sm text-blue-700">
114 Total questions: {questions.length}
115 </p>
116 </div>
117
118 {questions.length === 0 ? (
119 <div className="text-center py-12">
120 <div className="text-6xl mb-4">❓</div>
121 <p className="text-gray-600 text-lg">No questions found.</p>
122 <p className="text-gray-500 text-sm mt-2">
123 Add some questions in the Strapi admin panel to get started.
124 </p>
125 <a
126 href="http://localhost:1337/admin"
127 target="_blank"
128 rel="noopener noreferrer"
129 className="inline-block mt-4 bg-purple-600 text-white px-6 py-3 rounded hover:bg-purple-700"
130 >
131 Open Strapi Admin
132 </a>
133 </div>
134 ) : (
135 <div className="space-y-4">
136 {questions
137 .sort((a, b) => {
138 const aOrder = getQuestionValue(a, "order");
139 const bOrder = getQuestionValue(b, "order");
140 return aOrder - bOrder;
141 })
142 .map((question) => (
143 <div
144 key={question.id}
145 className="bg-white p-6 rounded-lg shadow-md border"
146 >
147 <div className="flex items-start justify-between">
148 <div className="flex-1">
149 <div className="flex items-center gap-2 mb-2">
150 <span className="inline-block bg-blue-100 text-blue-800 text-sm px-3 py-1 rounded-full font-medium">
151 Order: {getQuestionValue(question, "order")}
152 </span>
153 <span
154 className={`px-3 py-1 rounded-full text-sm font-medium ${
155 getQuestionValue(question, "isActive")
156 ? "bg-green-100 text-green-800"
157 : "bg-red-100 text-red-800"
158 }`}
159 >
160 {getQuestionValue(question, "isActive")
161 ? "Active"
162 : "Inactive"}
163 </span>
164 </div>
165 <p className="text-gray-800 font-medium text-lg">
166 {getQuestionValue(question, "questionText")}
167 </p>
168 <p className="text-xs text-gray-400 mt-2">
169 Created:{" "}
170 {new Date(
171 getQuestionValue(question, "createdAt")
172 ).toLocaleString()}
173 </p>
174 </div>
175 </div>
176 </div>
177 ))}
178 </div>
179 )}
180 </div>
181 );
182}
In the code above, we create a component for the questions. When the page loads, it fetches all the survey questions from Strapi and displays them in order. Each question card shows its order, active status, text, and creation date. If no questions are found, it prompts the user to add them via the Strapi admin panel.
Below is an image of what the page will look like:
Navigate to the src/app/page.js
file and update the code to this:
1export default function Home() {
2 return (
3 <div className="container mx-auto px-4 py-8">
4 <h1 className="text-4xl font-bold text-center mb-8">
5 WhatsApp Survey App
6 </h1>
7
8 <div className="max-w-4xl mx-auto">
9 <div className="grid md:grid-cols-3 gap-6">
10 <div className="bg-white p-6 rounded-lg shadow-md text-center">
11 <div className="text-4xl mb-4">📋</div>
12 <h3 className="text-xl font-semibold mb-2">Send Survey</h3>
13 <p className="text-gray-600 mb-4">
14 Start a new WhatsApp survey for a user
15 </p>
16 <a
17 href="/send-survey"
18 className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700"
19 >
20 Send Survey
21 </a>
22 </div>
23
24 <div className="bg-white p-6 rounded-lg shadow-md text-center">
25 <div className="text-4xl mb-4">📊</div>
26 <h3 className="text-xl font-semibold mb-2">View Responses</h3>
27 <p className="text-gray-600 mb-4">
28 See all survey responses and analytics
29 </p>
30 <a
31 href="/responses"
32 className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
33 >
34 View Responses
35 </a>
36 </div>
37
38 <div className="bg-white p-6 rounded-lg shadow-md text-center">
39 <div className="text-4xl mb-4">❓</div>
40 <h3 className="text-xl font-semibold mb-2">Manage Questions</h3>
41 <p className="text-gray-600 mb-4">
42 View and manage survey questions
43 </p>
44 <a
45 href="/questions"
46 className="bg-purple-600 text-white px-4 py-2 rounded-md hover:bg-purple-700"
47 >
48 Manage Questions
49 </a>
50 </div>
51 </div>
52 </div>
53 </div>
54 );
55}
Below is an image of what the home page looks like:
Next, navigate to src/app/globals.css
and update the code to this:
1@import "tailwindcss";
2
3:root {
4 --background: #ffffff;
5 --foreground: #171717;
6}F
7
8@theme inline {
9 --color-background: var(--background);
10 --color-foreground: var(--foreground);
11 --font-sans: var(--font-geist-sans);
12 --font-mono: var(--font-geist-mono);
13}
14
15@media (prefers-color-scheme: dark) {
16 :root {
17 --background: #0a0a0a;
18 --foreground: #ededed;
19 }
20}
21
22body {
23 background: var(--background);
24 color: var(--foreground);
25 font-family: Arial, Helvetica, sans-serif;
26}
27
28@tailwind base;
29@tailwind components;
30@tailwind utilities;
31
32body {
33 background-color: #f9fafb;
34}
35
36.container {
37 max-width: 1200px;
38}
Also, update your src/app/layout.js
with this:
1import { Inter } from "next/font/google";
2import "./globals.css";
3
4const inter = Inter({ subsets: ["latin"] });
5
6export const metadata = {
7 title: "WhatsApp Survey App",
8 description: "Send surveys via WhatsApp and collect responses",
9};
10
11export default function RootLayout({ children }) {
12 return (
13 <html lang="en">
14 <body className={inter.className} suppressHydrationWarning={true}>
15 {children}
16 </body>
17 </html>
18 );
19}
All set now let's test the application!
Here are the steps to take when testing your app:
In your terminal run this command to start the terminal:
npm run dev
Next, navigate to http://localhost:3000 in your browser:
To send a survey, click Send survey and enter a number. Make sure you add the country code.
Once completed, it is added to Strapi's backend, and you can view this on the View Responses page.
Here's the link to the full project on GitHub.
You now have a fully functional WhatsApp-based survey system built with Next.js, Strapi, and Twilio. The system covers everything from triggering the first survey message to processing replies and walking users through the flow automatically.
You can extend this by adding survey analytics and reporting, conditional logic for branching questions, reusable survey templates, scheduled campaign triggers, or even integrating it directly with your CRM.
I'm a web developer and writer. I love to share my experiences and things I've learned through writing.