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-cloneThe 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.ymlAdd this to the file:
1nodeLinker: node-modules
2npmRegistryServer: https://registry.npmjs.org/Next, initialize a workspace with Yarn:
1yarn init -wAfter 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.jsonReplace 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-initChoose 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/.envCreate 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 
.envfile. The.envfile 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.tsAdd 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.tsThe 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.tsThe 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.tsHere 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.tsThese 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.tsHere'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/srcInitialize a package within it:
yarn --cwd packages/shared/strapi-types initCreate an index.ts and tsconfig.json file:
touch packages/shared/strapi-types/index.ts packages/shared/strapi-types/tsconfig.jsonCopy 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-typesNow 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.