Introduction
Online forums bring people together to share their thoughts, ideas, and knowledge with others. Platforms like Quora allow users to ask and answer questions, engage in discussions, and vote on content to surface the most valuable insights.
In this tutorial series, you'll learn how to build a limited-scope Quora clone using Strapi 5, Next.js, and Cloudflare. This first part focuses on setting up Strapi, structuring content types, and integrating Cloudflare’s AI to generate responses.
Tutorial Outline
This tutorial is divided into two:
- Part 1: Setting up the Strapi backend, defining content types, and integrating Cloudflare Workers AI for automated responses.
- Part 2: Building the frontend using Next.js, including pages for questions, authentication, and user accounts.
Tutorial Goals
Our Quora clone will include the following features:
- User authentication (cloned Quora sign-up, cloned Quora login, account management)
- Posting questions and answers
- AI-generated responses via Cloudflare Workers AI
- Commenting on questions and answers.
- Upvoting and downvoting functionality.
This is what the final project will look like.
Prerequisites
To follow along in this tutorial, you will need:
These are the versions used in this tutorial:
- Node.js(v20.15.1)
- Yarn(v1.22.22)
- Turborepo(v2.1.2)
Cloudflare currently offers free access to Workers AI models, but this may change in the future. Keep an eye on their pricing updates.
Setting Up the Monorepo with Turborepo
Since our project has both a frontend (Next.js) and a backend (Strapi), we’ll manage our project as a monorepo using Turborepo.
Creating the Monorepo Folder
Run the following commands to set up the project structure:
1mkdir -p quora-clone/apps
2cd quora-clone
The apps/
directory will house both the frontend (quora-frontend
) and backend (quora-backend
).
Installing Dependencies
Some dependencies that the front-end relies on are not available on the Yarn registry.
However, they are available on the NPM registry. As such, a switch to this registry has to be made.
The default Yarn node linker may cause issues with identifying installed dependencies, preventing the project from starting. Switched to node-modules
to get Yarn going.
To address both these issues, create a .yarnrc.yml
to the root of the project:
touch .yarnrc.yml
Add this to the file:
1nodeLinker: node-modules
2npmRegistryServer: https://registry.npmjs.org/
Next, initialize a workspace with Yarn:
1yarn init -w
After you run this command, in addition to other important files, a packages
folder is created.
This is the folder in which the shared types mentioned are placed. Look out for this in subsequent parts of this tutorial.
Turbo tasks are configured using the turbo.json
file. Create a Turbo file with:
touch turbo.json
Replace its contents with:
1{
2 "$schema": "https://turborepo.org/schema.json",
3 "tasks": {
4 "develop": {
5 "cache": false
6 },
7 "dev": {
8 "cache": false,
9 "dependsOn": ["develop"]
10 },
11 "build": {
12 "dependsOn": ["^build"],
13 "outputs": [".next/**", "!.next/cache/**", "dist/**"]
14 },
15 "quora-backend#generate-types": {},
16 "strapi-types#generate-types": {
17 "dependsOn": ["quora-backend#generate-types"]
18 }
19 }
20}
We'll need two scripts:
turbo run dev --parallel
: runs all the projects in parallelturbo run generate-types
: generates Strapi stypes from the back-end and adds them to the packages folder. This will be implemented later.
Replace the contents of scripts
and workspaces
in package.json with:
1{
2 "name": "quora-clone",
3 "packageManager": "yarn@4.2.2",
4 "private": true,
5 "version": "1.0.0",
6 "workspaces": ["apps/*", "packages/shared/*"],
7 "engines": {
8 "node": ">=20.15.1"
9 },
10 "scripts": {
11 "dev": "turbo run dev --parallel",
12 "generate-types": "turbo run generate-types"
13 }
14}
Generating the Strapi Backend
The backend of this app is called quora-backend
. To create it, run:
1npx create-strapi-app@latest apps/quora-backend --no-run --ts --use-yarn --install --skip-cloud --no-example --no-git-init
Choose sqlite
as its database.
Run Strapi development server with:
1yarn workspace quora-backend develop
Strapi will launch on your browser. Create an administration account.
Once you're done signing up, you will be routed to the admin panel. Here's where you will create the content types in the next step.
How to Create Content Types in Strapi 5
This project requires 5 content types:
- Question
- Answer
- Bot Answer
- Comment
- Vote
Select the Content-type Builder in the main navigation of the admin panel and create the types with these specifications.
1. Question Content Type
A question represents what users will ask. Use these settings when creating it:
Type settings
Field | Value |
---|---|
Display Name | Question |
API ID (Singular) | question |
API ID (Plural) | questions |
Type | Collection Type |
Draft & publish | false |
Fields and their settings
Field Name | Type | Required |
---|---|---|
title | Short text | true |
asker | Relation with User | User has many questions |
After setting up all the types, the question content type will look something like this (but not just yet, though):
Its user
relation:
2. Answer Content Type
These are user-provided responses to questions posted on the site.
Type settings
Field | Value |
---|---|
Display Name | Bot Answer |
API ID (Singular) | bot-answer |
API ID (Plural) | bot-answers |
Type | Collection Type |
Draft & publish | false |
Fields and their settings
Field Name | Type | Other details |
---|---|---|
body | Rich text (Markdown) | |
answerer | Relation with User | User has many questions |
question | Relation with Question | Question has many answers |
This is what the Answer content type will look like:
Answer content type and User relation:
Answer content type and Question relation:
3. Bot Answer Content Type
These are the answers generated by the Cloudflare Workers AI.
Type settings
Field | Value |
---|---|
Display Name | Bot Answer |
API ID (Singular) | bot-answer |
API ID (Plural) | bot-answers |
Type | Collection Type |
Draft & publish | false |
Fields and their settings
Field Name | Type | Other details |
---|---|---|
body | Rich text (Markdown) | |
question | Relation | Question has one bot answer |
Here is what the Bot Answer Content Type will look like:
Bot Answer content type and Question relation:
4. Comments Content Type
Comments are left by users on questions, answers, and bot answers.
Type settings
Field | Value |
---|---|
Display Name | Comment |
API ID (Singular) | comment |
API ID (Plural) | comments |
Type | Collection Type |
Draft & publish | false |
Fields and their settings
Field Name | Type | Other details |
---|---|---|
body | Rich text (Markdown) | |
commenter | Relation with User | User has many comments |
question | Relation with Question | Question has many comments |
answer | Relation with Answer | Answer has many comments |
bot_answer | Relation with Bot Answer | Bot answer has many comments |
The final Comment content type:
Comment content type and User relation:
Comment content type and Question relation:
Comment content type and Answer relation:
Comment content type and Bot Answer relation:
5. Votes Content Type
Users can either upvote or downvote questions, answers, bot answers, and comments.
Type settings
Field | Value |
---|---|
Display Name | Vote |
API ID (Singular) | vote |
API ID (Plural) | votes |
Type | Collection Type |
Draft & publish | false |
Fields and their settings
Field Name | Type | Other details |
---|---|---|
type | List of values | upvote, downvote |
voter | Relation with User | User has many votes |
question | Relation with Question | Question has many votes |
answer | Relation with Answer | Answer has many votes |
bot-answer | Relation with Bot Answer | Bot answer has many votes |
comment | Relation with Comment | Comment has many votes |
The Vote content type:
type
field of Vote content type:
Vote content type and User relation:
Vote content type and Question relation:
Vote content type and Answer relation:
Vote content type and Bot Answer relation:
Vote content type and Comment relation:
Learn more about Understanding and Using Relations in Strapi.
Let's cover the customization of the back-end. A big feature of Strapi is that it automatically generates APIs for each content type you create.
Customizing Strapi for AI-Powered Responses
Setting Up Environment Variables
Cloudflare's Workers AI requires an account ID and an API token to use its REST API. You can get these on your Cloudflare dashboard.
As of the writing of this tutorial, Cloudlfare's AI models that are still in Beta are free to use within limits, but this may change in the future. So, keep a lookout.
Start by creating the .env
file:
1touch apps/quora-backend/.env
Create Cloudflare Workers AI API Token
On the Cloudflare dashboard:
1. Heading to AI > Workers AI.
2. Pick Use REST API.
3. Under the Get API token section, click the Create a Workers AI API Token button.
4. Check the token name, permissions, and account resources.
5. When satisfied with the details, click the Create API Token button.
6. Select the Copy API Token button.
7. Place this value in the .env
file as shown below.
8. Your Cloudflare account ID is available on the same page. Copy the ID and add it to the .env
file as shown below.
1# Cloudflare
2CLOUDFLARE_API_URL=https://api.cloudflare.com/client/v4/accounts
3CLOUDFLARE_ACCOUNT_ID={YOUR CLOUDFLARE ACCOUNT ID}
4CLOUDFLARE_AI_MODEL=ai/run/@cf/meta/llama-3.1-8b-instruct
5CLOUDFLARE_API_TOKEN={YOUR CLOUDFLARE API TOKEN}
Using Strapi Lifecycle Hooks for AI Integration
Once a question is created, an API POST request is made to Cloudflare Workers AI API using the afterCreate
lifecycle hook.
The AI-generated response that Cloudflare returns is saved as a bot answer. The bot answer endpoint requires authentication.
That's why an API token is needed from the Strapi admin panel settings.
Create Strapi API Token
Here are the steps to follow to create an API token in Strapi:
- Head to Settings on the Strapi admin panel.
- Then under API Tokens in the sidebar,
- Click the Add new API token button.
- Add a name for the token and an optional description.
- Set the Token duration to Unlimited.
- Set the Token access to Full access.
- Click the Save button and copy the token to the
.env
file. The.env
file should have this key:
1STRAPI_BOT_API_TOKEN={YOUR STRAPI API TOKEN}
The API Token setting on the admin panel:
The settings for the token:
Creating API Endpoints for Answers, Questions & Votes
The Quora clone needs four new answer routes. Some of these can be optimized into one route (i.e., returning data with the total count of items), but you can make these changes as an extra task if you'd like to work on them.
In this step, we’ll enhance the Answer content type by introducing custom routes and controllers. These improvements will allow efficient pagination, upvote/downvote counts, and better retrieval of related comments.
New API Endpoints for Answers
We will add four new API routes to enhance how answers are retrieved:
Route | Controller | Purpose |
---|---|---|
/answers/home | home | Fetches paginated questions with the most upvoted answers. If no user-submitted answer is available, a bot-generated answer is returned. |
/answers/home/count | homeCount | Returns the total number of questions that have at least one user or bot answer. |
/answers/:id/comments | comments | Retrieves paginated comments for a specific answer. |
/answers/:id/comments/count | commentsCount | Returns the total count of comments under a specific answer. |
Creating the Controller for Answer Routes
First, create a new controller file for answer-related operations:
1touch apps/quora-backend/src/api/answer/controllers/augment.ts apps/quora-backend/src/api/answer/routes/01-augment.ts
Add this to the apps/quora-backend/src/api/answer/controllers/augment.ts
file:
1interface homeQuestion {
2 id?: string | number;
3 topAnswer?: {
4 id?: string | number;
5 documentId?: string;
6 upvoteCount?: number;
7 commentCount?: number;
8 };
9}
10
11export default {
12 async home(ctx, _next) {
13 const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {};
14
15 const knex = strapi.db.connection;
16
17 const offset = (page - 1) * pageSize || 0;
18
19 // Answers
20
21 const answeredQs = await strapi
22 .documents("api::question.question")
23 .findMany({
24 filters: {
25 $or: [
26 {
27 answers: {
28 $and: [
29 {
30 id: {
31 $notNull: true,
32 },
33 },
34 ],
35 },
36 },
37 {
38 bot_answer: {
39 id: {
40 $notNull: true,
41 },
42 },
43 answers: {
44 $and: [
45 {
46 id: {
47 $null: true,
48 },
49 },
50 ],
51 },
52 },
53 ],
54 },
55 populate: {
56 bot_answer: {},
57 answers: {
58 fields: ["id"],
59 },
60 },
61 fields: ["id", "title"],
62 limit: pageSize,
63 start: offset,
64 });
65
66 const qIds = answeredQs.filter((q) => !!q["answers"]).map((q) => q.id);
67
68 const upvoteCounts = await knex("questions as q")
69 .whereIn("q.id", qIds)
70 .leftJoin("answers_question_lnk as aql", "q.id", "aql.question_id")
71 .whereNotNull("aql.answer_id")
72 .leftJoin("answers as a", "aql.answer_id", "a.id")
73 .leftJoin("votes_answer_lnk as val", "a.id", "val.answer_id")
74 .leftJoin("votes as v", "val.vote_id", "v.id")
75 .select(
76 "q.id as question_id",
77 "a.id as answer_id",
78 "a.document_id as answer_document_id",
79 knex.raw(
80 "SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count",
81 ["upvote"],
82 ),
83 )
84 .groupBy("q.id", "a.id");
85
86 const questionsWithAnswers = {};
87
88 upvoteCounts.forEach((row) => {
89 const questionId = row.question_id;
90
91 if (!questionsWithAnswers[questionId]) {
92 questionsWithAnswers[questionId] = {
93 id: questionId,
94 topAnswer: {
95 id: row.answer_id,
96 upvoteCount: row.upvote_count,
97 documentId: row.answer_document_id,
98 },
99 };
100 } else if (
101 row.upvote_count >
102 questionsWithAnswers[questionId].topAnswer.upvoteCount
103 ) {
104 questionsWithAnswers[questionId].topAnswer = {
105 id: row.answer_id,
106 upvoteCount: row.upvote_count,
107 documentId: row.answer_document_id,
108 };
109 }
110 });
111
112 const topAnswers = Object.values(questionsWithAnswers).map(
113 (q: homeQuestion) => {
114 return q.topAnswer;
115 },
116 );
117
118 let answers = await strapi.documents("api::answer.answer").findMany({
119 filters: {
120 id: {
121 $in: topAnswers.map((ta) => ta.id),
122 },
123 },
124 populate: ["answerer", "question"],
125 });
126
127 const commentCounts = await knex("answers as a")
128 .whereIn(
129 "a.id",
130 answers.map((a) => a.id),
131 )
132 .leftJoin("comments_answer_lnk as cal", "a.id", "cal.answer_id")
133 .leftJoin("comments as c", "cal.comment_id", "c.id")
134 .select(
135 "a.id as answer_id",
136 "a.document_id as answer_document_id",
137 knex.raw("COUNT(c.id) as comment_count"),
138 )
139 .groupBy("a.id");
140
141 answers = answers.map((ans) => {
142 const tempAnsw = topAnswers.find((a) => a.documentId == ans.documentId);
143 ans["upvoteCount"] = tempAnsw?.upvoteCount || 0;
144
145 const tempCC = commentCounts.find(
146 (cc) => cc.answer_document_id == ans.documentId,
147 );
148 ans["commentCount"] = tempCC?.comment_count || 0;
149 return ans;
150 });
151
152 // Bot Answers
153
154 const botOnlyAnsweredQuestions = answeredQs.filter(
155 (q) => !!q["bot_answer"] && !!!q["answers"].length,
156 );
157
158 const baqIds = botOnlyAnsweredQuestions.map((q) => q.id);
159
160 let botAnswers = botOnlyAnsweredQuestions.map((baq) => {
161 const tempBA = baq["bot_answer"];
162 delete baq["bot_answer"];
163 tempBA["question"] = baq;
164
165 return tempBA;
166 });
167
168 const baIds = botAnswers.map((ba) => ba.id);
169
170 const baUpvotes = await knex("questions as q")
171 .whereIn("q.id", baqIds)
172 .leftJoin("questions_bot_answer_lnk as qbal", "q.id", "qbal.question_id")
173 .whereNotNull("qbal.bot_answer_id")
174 .leftJoin("bot_answers as ba", "qbal.bot_answer_id", "ba.id")
175 .leftJoin("votes_bot_answer_lnk as vbal", "ba.id", "vbal.bot_answer_id")
176 .leftJoin("votes as v", "vbal.vote_id", "v.id")
177 .select(
178 "ba.id as bot_answer_id",
179 knex.raw(
180 "SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count",
181 ["upvote"],
182 ),
183 )
184 .groupBy("q.id", "ba.id");
185
186 const baCommentCounts = await knex("bot_answers as ba")
187 .whereIn("ba.id", baIds)
188 .leftJoin(
189 "comments_bot_answer_lnk as cbal",
190 "ba.id",
191 "cbal.bot_answer_id",
192 )
193 .leftJoin("comments as c", "cbal.comment_id", "c.id")
194 .select(
195 "ba.id as bot_answer_id",
196 knex.raw("COUNT(c.id) as comment_count"),
197 )
198 .groupBy("ba.id");
199
200 botAnswers = botAnswers.map((ba) => {
201 const tempUC = baUpvotes.find((bauc) => bauc.bot_answer_id === ba.id);
202 ba["upvoteCount"] = tempUC?.upvote_count || 0;
203
204 const tempCC = baCommentCounts.find((cc) => cc.bot_answer_id === ba.id);
205 ba["commentCount"] = tempCC?.comment_count || 0;
206
207 return ba;
208 });
209
210 ctx.body = [...answers, ...botAnswers];
211 },
212 async homeCount(ctx, _next) {
213 const answeredQuestionCount = await strapi
214 .documents("api::question.question")
215 .count({
216 filters: {
217 $or: [
218 {
219 answers: {
220 $and: [
221 {
222 id: {
223 $notNull: true,
224 },
225 },
226 ],
227 },
228 },
229 {
230 bot_answer: {
231 id: {
232 $notNull: true,
233 },
234 },
235 answers: {
236 $and: [
237 {
238 id: {
239 $null: true,
240 },
241 },
242 ],
243 },
244 },
245 ],
246 },
247 });
248
249 ctx.body = { count: answeredQuestionCount };
250 },
251 async comments(ctx, _next) {
252 const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {};
253
254 const id = ctx.params?.id || "";
255
256 let comments = await strapi.documents("api::comment.comment").findMany({
257 filters: {
258 answer: {
259 id: { $eq: id },
260 },
261 },
262 sort: "createdAt:desc",
263 populate: ["commenter"],
264 start: (page - 1) * pageSize || 0,
265 limit: pageSize || 25,
266 });
267
268 const knex = strapi.db.connection;
269
270 const upvoteCounts = await knex("comments as c")
271 .leftJoin("votes_comment_lnk as vcl", "c.id", "vcl.comment_id")
272 .leftJoin("votes as v", "vcl.vote_id", "v.id")
273 .whereIn(
274 "c.id",
275 comments.map((c) => c.id),
276 )
277 .select(
278 "c.id as comment_id",
279 "c.document_id as comment_document_id",
280 knex.raw(
281 "SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count",
282 ["upvote"],
283 ),
284 )
285 .groupBy("c.id");
286
287 comments = comments.map((comment) => {
288 const tempUVC = upvoteCounts.find((uvc) => uvc.comment_id == comment.id);
289 comment["upvoteCount"] = tempUVC?.upvote_count || 0;
290
291 return comment;
292 });
293
294 ctx.body = comments;
295 },
296 async commentCount(ctx, _next) {
297 const id = ctx.params?.id || "";
298
299 let commentCount = await strapi.documents("api::comment.comment").count({
300 filters: {
301 answer: {
302 id: { $eq: id },
303 },
304 },
305 });
306
307 ctx.body = { count: commentCount };
308 },
309};
Creating the Routes for Answer Customizations
Next, define the custom routes by creating the following file:
To apps/quora-backend/src/api/answer/routes/01-augment.ts
add:
1export default {
2 routes: [
3 {
4 method: 'GET',
5 path: '/answers/home',
6 handler: 'augment.home',
7 },
8 {
9 method: 'GET',
10 path: '/answers/home/count',
11 handler: 'augment.homeCount',
12 },
13 {
14 method: 'GET',
15 path: '/answers/:id/comments',
16 handler: 'augment.comments',
17 },
18 {
19 method: 'GET',
20 path: '/answers/:id/comments/count',
21 handler: 'augment.commentCount',
22 },
23 ]
24}
Bot answer customizations
Two new routes are added to the bot answers API.
Route | Controller | Purpose |
---|---|---|
/bot-answers/:id/comments | comments | Returns paginated comments for the bot answer. Each answer includes its upvote and comment count. |
/bot-answers/:id/comments/count | commentCount | Returns the total comment count for a particular bot answer. This is for pagination purposes. |
Make these new controller and route files with:
1touch apps/quora-backend/src/api/bot-answer/controllers/augment.ts apps/quora-backend/src/api/bot-answer/routes/01-augment.ts
The apps/quora-backend/src/api/bot-answer/controllers/augment.ts
contains:
1export default {
2 async comments(ctx, _next) {
3 const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
4
5 const id = ctx.params?.id || ''
6
7 let comments = await strapi.documents('api::comment.comment').findMany({
8 filters: {
9 bot_answer: {
10 id: { $eq: id }
11 }
12 },
13 populate: ['commenter'],
14 sort: 'createdAt:desc',
15 start: ((page - 1) * pageSize) || 0,
16 limit: pageSize || 25
17 })
18
19 const knex = strapi.db.connection
20
21 const upvoteCounts = await knex('comments as c')
22 .leftJoin('votes_comment_lnk as vcl', 'c.id', 'vcl.comment_id')
23 .leftJoin('votes as v', 'vcl.vote_id', 'v.id')
24 .whereIn('c.id', comments.map(c => c.id))
25 .select(
26 'c.id as comment_id',
27 'c.document_id as comment_document_id',
28 knex.raw('SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count', ['upvote'])
29 )
30 .groupBy('c.id')
31
32 comments = comments.map((comment) => {
33 const tempUVC = upvoteCounts.find(uvc => uvc.comment_id == comment.id)
34 comment['upvoteCount'] = tempUVC?.upvote_count || 0
35
36 return comment
37 })
38
39 ctx.body = comments
40 },
41 async commentCount(ctx, _next) {
42 const id = ctx.params?.id || ''
43
44 let commentCount = await strapi.documents('api::comment.comment').count({
45 filters: {
46 bot_answer: {
47 id: { $eq: id }
48 }
49 }
50 })
51
52 ctx.body = { count: commentCount }
53 }
54}
The routes file apps/quora-backend/src/api/bot-answer/routes/01-augment.ts
has:
1export default {
2 routes: [
3 {
4 method: 'GET',
5 path: '/bot-answers/:id/comments',
6 handler: 'augment.comments',
7 },
8 {
9 method: 'GET',
10 path: '/bot-answers/:id/comments/count',
11 handler: 'augment.commentCount',
12 },
13 ]
14}
Question customizations
In this step, we'll enhance the Question content type by adding custom API endpoints that enable efficient pagination, retrieval of related answers (both user-generated and AI-generated), and better handling of comments.
We’ll also introduce a lifecycle hook that automatically generates an AI-based answer whenever a new question is posted.
New API Endpoints for Questions
We will create six new API routes to enhance how questions and their associated answers and comments are retrieved:
These are the question controllers and routes:
Route | Controller | Purpose |
---|---|---|
/questions/home | homeQuestions | Returns questions with answer, upvote, and comment counts to be displayed on the answer page. |
/questions/count | count | Returns the total question count for pagination. |
/questions/:id/answers | answers | Returns paginated answers for a particular question with attached comment and upvote counts. |
/questions/:id/bot-answers | botAnswers | Returns the bot generated answer for a particular question. Only one answer is generated per question. |
/questions/:id/comments | comments | Returns the paginated comments of a question. |
/questions/:id/comments/count | commentCount | Returns the total comment count left under a question for pagination purposes. |
Creating the Controller for Question Routes
First, create a new controller file for question-related operations:
touch apps/quora-backend/src/api/question/controllers/augment.ts
The controllers in apps/quora-backend/src/api/question/controllers/augment.ts
are:
1export default {
2 async count(ctx, _next) {
3 ctx.body = {
4 count: await strapi.documents('api::question.question').count({
5 filters: {
6 $or: [
7 {
8 answers: {
9 $not: null
10 }
11 },
12 {
13 bot_answer: {
14 $not: null
15 }
16 }
17 ]
18 }
19 })
20 }
21 },
22 async comments(ctx, _next) {
23 const knex = strapi.db.connection
24
25 const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
26
27 const id = ctx.params?.id || ''
28
29 let comments = await strapi.documents('api::comment.comment').findMany({
30 filters: {
31 question: {
32 id: { $eq: id }
33 }
34 },
35 populate: ['commenter'],
36 start: ((page - 1) * pageSize) || 0,
37 limit: pageSize || 25
38 })
39
40 const commentIds = comments.map(c => c.id)
41
42 const upvoteCounts = await knex('comments as c')
43 .leftJoin('votes_comment_lnk as vcl', 'c.id', 'vcl.comment_id')
44 .leftJoin('votes as v', 'vcl.vote_id', 'v.id')
45 .whereIn('c.id', commentIds)
46 .select(
47 'c.id as comment_id',
48 'c.document_id as comment_document_id',
49 knex.raw('SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count', ['upvote'])
50 )
51 .groupBy('c.id')
52 .orderBy('upvote_count', 'desc')
53
54 comments = comments.map(c => {
55 const uc = upvoteCounts.find(count => count.comment_id == c.id)
56 if (uc) c['upvoteCount'] = uc.upvote_count
57
58 return c
59 })
60
61 ctx.body = comments
62 },
63 async answers(ctx, _next) {
64 const knex = strapi.db.connection
65
66 const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
67
68 const id = ctx.params?.id || ''
69
70 const question = await strapi.documents('api::question.question').findFirst({
71 filters: {
72 id: { $eq: id }
73 }
74 })
75
76 if (question) {
77 let answers = await strapi.documents('api::answer.answer').findMany({
78 filters: {
79 question: {
80 id: { $eq: id }
81 }
82 },
83 limit: pageSize,
84 start: (page - 1) * pageSize || 0,
85 populate: ['answerer']
86 })
87
88 const answerIds = answers.map(a => a.id)
89
90 const answersWithVotes = await knex('answers as a')
91 .leftJoin('votes_answer_lnk as val', 'a.id', 'val.answer_id')
92 .leftJoin('votes as v', 'val.vote_id', 'v.id')
93 .whereIn('a.id', answerIds)
94 .select(
95 'a.id as answer_id',
96 'a.document_id as answer_document_id',
97 knex.raw('SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count', ['upvote'])
98 )
99 .groupBy('a.id')
100 .orderBy('upvote_count', 'desc')
101
102 const commentCounts = await knex('answers as a')
103 .whereIn('a.id', answerIds)
104 .leftJoin('comments_answer_lnk as cal', 'a.id', 'cal.answer_id')
105 .leftJoin('comments as c', 'cal.comment_id', 'c.id')
106 .select(
107 'a.id as answer_id',
108 'c.id as comment_id',
109 'a.document_id as answer_document_id',
110 knex.raw('COUNT(c.id) as comment_count')
111 )
112 .groupBy('a.id')
113 .orderBy('comment_count', 'desc')
114
115 answers = answers.map((ans) => {
116 const tempAnsw = answersWithVotes.find(a => a.answer_id == ans.id)
117 ans['upvoteCount'] = tempAnsw?.upvote_count || 0
118
119 const tempCC = commentCounts.find(cc => cc.answer_id == ans.id)
120 ans['commentCount'] = tempCC?.comment_count || 0
121
122 return ans
123 })
124
125 const answerCount = await strapi.documents('api::answer.answer').count({
126 filters: {
127 question: {
128 id: { $eq: id }
129 }
130 }
131 })
132
133 const commentCount = await strapi.documents('api::comment.comment').count({
134 filters: {
135 question: {
136 id: { $eq: id }
137 }
138 }
139 })
140
141 question['answers'] = answers
142 question['answerCount'] = answerCount
143 question['commentCount'] = commentCount
144 }
145
146 ctx.body = question
147 },
148 async commentCount(ctx, _next) {
149 const id = ctx.params?.id || ''
150
151 let commentCount = await strapi.documents('api::comment.comment').count({
152 filters: {
153 question: {
154 id: { $eq: id }
155 }
156 }
157 })
158
159 ctx.body = { count: commentCount }
160 },
161 async homeQuestions(ctx, _next) {
162 const knex = strapi.db.connection
163
164 const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
165
166 let questions = await strapi.documents('api::question.question').findMany({
167 start: (page - 1) * pageSize || 0,
168 limit: pageSize
169 })
170
171 const answerCount = await knex('questions as q')
172 .whereIn('q.id', questions.map(q => q.id))
173 .leftJoin('answers_question_lnk as aql', 'q.id', 'aql.question_id')
174 .leftJoin('answers as a', 'aql.answer_id', 'a.id')
175 .select(
176 'q.id as question_id',
177 knex.raw('COUNT(a.id) as answer_count')
178 )
179 .groupBy('q.id')
180 .orderBy('answer_count', 'desc')
181
182 const botAnswerCount = await knex('questions as q')
183 .whereIn('q.id', questions.map(q => q.id))
184 .leftJoin('questions_bot_answer_lnk as qbal', 'q.id', 'qbal.question_id')
185 .leftJoin('bot_answers as ba', 'qbal.bot_answer_id', 'ba.id')
186 .select(
187 'q.id as question_id',
188 knex.raw('COUNT(ba.id) as answer_count')
189 )
190 .groupBy('q.id')
191 .orderBy('answer_count', 'desc')
192
193
194 questions = questions.map(qst => {
195 const ac = answerCount.find(el => el.question_id == qst.id)
196 const bac = botAnswerCount.find(el => el.question_id == qst.id)
197 qst['answerCount'] = (ac.answer_count || 0) + (bac.answer_count || 0)
198
199 return qst
200 })
201
202 ctx.body = questions
203 },
204 async botAnswers(ctx, _next) {
205 const id = ctx.params?.id || ''
206
207 const botAnswer = await strapi.documents('api::bot-answer.bot-answer').findFirst({
208 filters: {
209 question: {
210 id: {
211 $eq: id
212 }
213 }
214 }
215 })
216
217 if (botAnswer) {
218 const commentCount = await strapi.documents('api::comment.comment').count({
219 filters: {
220 bot_answer: {
221 id: {
222 $eq: botAnswer.id
223 }
224 }
225 }
226 })
227
228 const upvoteCount = await strapi.documents('api::vote.vote').count({
229 filters: {
230 bot_answer: {
231 id: {
232 $eq: botAnswer.id
233 }
234 },
235 type: {
236 $eq: 'upvote'
237 }
238 }
239 })
240
241 botAnswer["commentCount"] = commentCount
242 botAnswer["upvoteCount"] = upvoteCount
243
244 ctx.body = [botAnswer]
245 return
246 }
247
248 ctx.body = []
249 }
250}
Creating the Routes for Question Customizations
Next, define the custom routes by creating the following file:
touch apps/quora-backend/src/api/question/routes/01-augment.ts
Here is the content:
1export default {
2 routes: [
3 {
4 method: 'GET',
5 path: '/questions/count',
6 handler: 'augment.count',
7 },
8 {
9 method: 'GET',
10 path: '/questions/home',
11 handler: 'augment.homeQuestions',
12 },
13 {
14 method: 'GET',
15 path: '/questions/:id/comments',
16 handler: 'augment.comments',
17 },
18 {
19 method: 'GET',
20 path: '/questions/:id/answers',
21 handler: 'augment.answers',
22 },
23 {
24 method: 'GET',
25 path: '/questions/:id/comments/count',
26 handler: 'augment.commentCount',
27 },
28 {
29 method: 'GET',
30 path: '/questions/:id/bot-answers',
31 handler: 'augment.botAnswers',
32 },
33 ]
34}
Automatically Generating AI Responses for Questions
To automatically generate an AI response when a question is created, we will customize the afterCreate lifecycle hook.
Create the lifecycle file:
touch apps/quora-backend/src/api/question/content-types/question/lifecycles.ts
These are the contents of apps/quora-backend/src/api/question/content-types/question/lifecycles.ts
:
1module.exports = {
2 afterCreate(event) {
3 const { result } = event
4
5 function handleError(err) {
6 return Promise.reject(err)
7 }
8
9 fetch(
10 `${process.env.CLOUDFLARE_API_URL}/${process.env.CLOUDFLARE_ACCOUNT_ID}/${process.env.CLOUDFLARE_AI_MODEL}`,
11 {
12 method: 'POST',
13 headers: {
14 "Accept": "application/json",
15 "Content-Type": "application/json",
16 "Authorization": `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
17 },
18 body: JSON.stringify({
19 prompt: result.title
20 }),
21 }
22 )
23 .then(res => {
24 if (res.ok) {
25 return res.json()
26 } else {
27 handleError(res)
28 }
29 },
30 handleError
31 )
32 .then(pr => {
33 return fetch(
34 `http${process.env.HOST == '0.0.0.0' || process.env.HOST == 'localhost' ? '' : 's'}://${process.env.HOST}:${process.env.PORT}/api/bot-answers`,
35 {
36 method: 'POST',
37 headers: {
38 "Accept": "application/json",
39 "Content-Type": "application/json",
40 "Authorization": `Bearer ${process.env.STRAPI_BOT_API_TOKEN}`,
41 },
42 body: JSON.stringify({
43 data: {
44 body: (pr as { result: { response: string } }).result.response,
45 question: result.id
46 }
47 }),
48 })
49 },
50 handleError
51 )
52 .then(res => {
53 if (res.ok) {
54 return res.json()
55 } else {
56 handleError(res)
57 }
58 },
59 handleError
60 )
61 .then(data => {
62 return data
63 },
64 handleError
65 )
66 },
67};
Vote customizations
Since a user gets only one vote per item(question, comment, answer, or not answer), the create controller of the vote content type is customized to enforce this.
If there is no vote for an item, a new one is created. If a vote exists, then only its value is updated.
It makes sense to customize the create controller as opposed to making a request to perform a check to establish if a vote exists, then updating it or creating a new one. This way, the first check request is eliminated as everything is done in one request.
So switch out the contents of apps/quora-backend/src/api/vote/controllers/vote.ts
with:
1import { factories } from '@strapi/strapi'
2
3export default factories.createCoreController(
4 'api::vote.vote',
5 ({ strapi }) => ({
6 async create(ctx, _next) {
7 const { data: { answer = 0, comment = 0, question = 0, bot_answer = 0, voter } } = (ctx.request as any).body
8
9 const existingVotes = await strapi.documents('api::vote.vote').findMany({
10 filters: {
11 voter: voter,
12 ...(answer && { answer: { id: { $eq: answer } } }),
13 ...(bot_answer && { bot_answer: { id: { $eq: bot_answer } } }),
14 ...(comment && { comment: { id: { $eq: comment } } }),
15 ...(question && { question: { id: { $eq: question } } }),
16 }
17 })
18
19 if (!existingVotes.length) {
20 const res = await strapi.documents('api::vote.vote').create({
21 ...(ctx.request as any).body,
22 })
23 ctx.body = res
24 } else {
25 const res = await strapi.documents('api::vote.vote').update({
26 documentId: existingVotes[0].documentId,
27 ...(ctx.request as any).body,
28 })
29
30 ctx.body = res
31 }
32 }
33 })
34)
Users & Permissions Plugin Customizations
The account page requires a couple of routes to fetch the user's questions, answers, comments, and votes. This is necessary to allow pagination and filtering to be performed simultaneously.
Route | Controller | Purpose |
---|---|---|
/users/:id/answers | answers | Returns the paginated answers a user has posted |
/users/:id/answers/count | answerCount | Returns the total count of answers posted by a user |
/users/:id/comments | comments | Returns a user's paginated comments |
/users/:id/comments/count | commentCount | Returns the total count of user comments |
/users/:id/questions | questions | Returns paginated questions a user has asked |
/users/:id/questions/count | questionCount | Returns the count of their questions |
/users/:id/votes | votes | Returns their paginated votes |
/users/:id/votes/count | voteCount | Returns the total count of their votes |
These routes and controllers are placed in the apps/quora-backend/src/extensions/users-permissions/strapi-server.ts
file. Create it using the command:
1touch apps/quora-backend/src/extensions/users-permissions/strapi-server.ts
Here's the contents of this file:
1const processRequest = async (ctx, uid, userRole, populate = []) => {
2 const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
3
4 const id = ctx.params?.id || ''
5
6 const results = await strapi.documents(uid).findMany({
7 filters: {
8 [userRole]: {
9 id: { $eq: id }
10 }
11 },
12 populate,
13 start: ((page - 1) * pageSize) || 0,
14 limit: pageSize || 25
15 })
16
17 ctx.body = results
18}
19
20const countEntries = async (ctx, uid, userRole) => {
21 const id = ctx.params?.id || ''
22
23 const count = await strapi.documents(uid).count({
24 filters: {
25 [userRole]: {
26 id: { $eq: id }
27 }
28 }
29 })
30
31 ctx.body = { count }
32}
33
34async function questions(ctx) {
35 await processRequest(
36 ctx,
37 'api::question.question',
38 null
39 )
40}
41async function answers(ctx) {
42 await processRequest(
43 ctx,
44 'api::answer.answer',
45 'answerer',
46 ['question']
47 )
48}
49async function comments(ctx) {
50 await processRequest(
51 ctx,
52 'api::comment.comment',
53 'commenter',
54 ['question', 'answer']
55 )
56}
57async function votes(ctx) {
58 await processRequest(
59 ctx,
60 'api::vote.vote',
61 'voter',
62 ['question', 'answer', 'comment']
63 )
64}
65
66async function questionCount(ctx) {
67 await countEntries(
68 ctx,
69 'api::question.question',
70 'asker'
71 )
72}
73async function answerCount(ctx) {
74 await countEntries(
75 ctx,
76 'api::answer.answer',
77 'answerer',
78 )
79}
80async function commentCount(ctx) {
81 await countEntries(
82 ctx,
83 'api::comment.comment',
84 'commenter',
85 )
86}
87async function voteCount(ctx) {
88 await countEntries(
89 ctx,
90 'api::vote.vote',
91 'voter',
92 )
93}
94
95
96module.exports = (plugin) => {
97 plugin.controllers.user['answers'] = answers
98 plugin.controllers.user['answerCount'] = answerCount
99 plugin.controllers.user['comments'] = comments
100 plugin.controllers.user['commentCount'] = commentCount
101 plugin.controllers.user['questions'] = questions
102 plugin.controllers.user['questionCount'] = questionCount
103 plugin.controllers.user['votes'] = votes
104 plugin.controllers.user['voteCount'] = voteCount
105
106 plugin.routes['content-api'].routes.push(
107 {
108 method: 'GET',
109 path: '/users/:id/answers',
110 handler: 'user.answers',
111 config: { prefix: '' }
112 },
113 {
114 method: 'GET',
115 path: '/users/:id/answers/count',
116 handler: 'user.answerCount',
117 config: { prefix: '' }
118 },
119 {
120 method: 'GET',
121 path: '/users/:id/comments',
122 handler: 'user.comments',
123 config: { prefix: '' }
124 },
125 {
126 method: 'GET',
127 path: '/users/:id/comments/count',
128 handler: 'user.commentCount',
129 config: { prefix: '' }
130 },
131 {
132 method: 'GET',
133 path: '/users/:id/questions',
134 handler: 'user.questions',
135 config: { prefix: '' }
136 },
137 {
138 method: 'GET',
139 path: '/users/:id/questions/count',
140 handler: 'user.questionCount',
141 config: { prefix: '' }
142 },
143 {
144 method: 'GET',
145 path: '/users/:id/votes',
146 handler: 'user.votes',
147 config: { prefix: '' }
148 },
149
150 {
151 method: 'GET',
152 path: '/users/:id/votes/count',
153 handler: 'user.voteCount',
154 config: { prefix: '' }
155 }
156 )
157
158 return plugin;
159};
Updating API Permissions in Strapi
Access to the routes added above needs to be enabled on the Strapi admin panel. You launch the panel by running yarn workspace quora-backend run develop
then head to http://localhost:1337/admin.
Authenticated Role/Users
- On the sidebar, under the Users & permissions plugin click Roles.
- Click the Authenticated role.
- Under Permissions, ensure that these are checked:
Authenticated Role Permissions
Content Type | Permissions |
---|---|
Answer | find , findOne , create , update , delete , commentCount , comments and home |
Bot-answer | find , findOne , create , update , delete , commentCount and comments |
Comment | find , findOne , create , update , delete |
Question | find , findOne , create , update , delete , answers , commentCount , count , botAnswers , comments , and homeQuestions |
Vote | find , findOne , create , update , delete |
User | answerCount , commentCount , count , destroy , findOne , questionCount , update , votes , answers , comments , create , find , me , questions , and voteCount |
Here is an example of Answer conllection type with its permissions.
Public Role/Users
For the Public role: 1. On the sidebar, under the Users & permissions plugin click Roles. 2. Click the Public role. 3. Under Permissions, ensure that these are checked:
Content Type | Permissions |
---|---|
Answer | find , findOne , commentCount , home , comments and homeCount |
Bot-answer | commentCount , comments , find and findOne |
Comment | find and findOne |
Question | answers , commentCount , count , botAnswers , comments , homeQuestions , find and findOne |
Vote | find and findOne |
Generating Shared Types for the Frontend
The app front-end needs to share types with the Strapi back-end. These types are created using the strapi ts:generate-types
command. So add this script to the apps/quora-backend/package.json
file.
1"scripts": {
2 ...
3 "generate-types": "strapi ts:generate-types"
4}
Next, create the packages/shared/strapi-types
folder:
mkdir -p packages/shared/strapi-types/src
Initialize a package within it:
yarn --cwd packages/shared/strapi-types init
Create an index.ts
and tsconfig.json
file:
touch packages/shared/strapi-types/index.ts packages/shared/strapi-types/tsconfig.json
Copy the contents of the tsconfig.json
file from this link.
Add its dependencies:
yarn workspace strapi-types add @strapi/strapi
Add this script to packages/shared/strapi-types/package.json
:
1"scripts": {
2 "generate-types": "cp ../../../apps/quora-backend/types/generated/* src/"
3}
This script copies the types Strapi generated with the strap ts:generate-types
command and places it in the apps/quora-backend/types/generated
folder to the packages/shared/strap-types/src
folder.
Add these lines to packages/shared/strapi-types/index.ts
:
1export * from "./src/components"
2export * from "./src/contentTypes"
Then run:
turbo generate-types
Now you can use the types generated from Strapi on the frontend.
Github Project Source Code
You can find the source code for this project here.
Conclusion
In this first part of the tutorial, we covered setting up a monorepo with Turborepo, configuring Strapi, and creating the necessary content types for our AI-powered Quora clone. We also explored pagination, filtering, and leveraging Strapi lifecycle hook to integrate Cloudflare's AI for automated responses.
With the backend now in place, the next part will focus on building the frontend using Next.js, including pages for questions, the Quora login authentication, and user accounts. Stay tuned!
In the meantime, check out Strapi’s documentation or join the Strapi Discord community to connect with other developers!
I am a developer and writer passionate about performance optimization, user-centric designs, and scalability.