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:
mkdir -p quora-clone/apps
cd 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:
nodeLinker: node-modules
npmRegistryServer: https://registry.npmjs.org/
Next, initialize a workspace with Yarn:
yarn 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:
{
"$schema": "https://turborepo.org/schema.json",
"tasks": {
"develop": {
"cache": false
},
"dev": {
"cache": false,
"dependsOn": ["develop"]
},
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"quora-backend#generate-types": {},
"strapi-types#generate-types": {
"dependsOn": ["quora-backend#generate-types"]
}
}
}
We'll need two scripts:
-
turbo run dev --parallel
: runs all the projects in parallel -
turbo 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:
{
"name": "quora-clone",
"packageManager": "yarn@4.2.2",
"private": true,
"version": "1.0.0",
"workspaces": ["apps/*", "packages/shared/*"],
"engines": {
"node": ">=20.15.1"
},
"scripts": {
"dev": "turbo run dev --parallel",
"generate-types": "turbo run generate-types"
}
}
Generating the Strapi Backend
The backend of this app is called quora-backend
. To create it, run:
npx 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:
yarn 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:
touch apps/quora-backend/.env
Create Cloudflare Workers AI API Token
On the Cloudflare dashboard:
- Heading to AI > Workers AI.
- Pick Use REST API.
- Under the Get API token section, click the Create a Workers AI API Token button.
- Check the token name, permissions, and account resources.
- When satisfied with the details, click the Create API Token button.
- Select the Copy API Token button.
- Place this value in the
.env
file as shown below. - Your Cloudflare account ID is available on the same page. Copy the ID and add it to the
.env
file as shown below.
# Cloudflare
CLOUDFLARE_API_URL=https://api.cloudflare.com/client/v4/accounts
CLOUDFLARE_ACCOUNT_ID={YOUR CLOUDFLARE ACCOUNT ID}
CLOUDFLARE_AI_MODEL=ai/run/@cf/meta/llama-3.1-8b-instruct
CLOUDFLARE_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:
STRAPI_BOT_API_TOKEN={YOUR STRAPI API TOKEN}
The API Token setting on the admin panel:
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:
touch 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:
interface homeQuestion {
id?: string | number;
topAnswer?: {
id?: string | number;
documentId?: string;
upvoteCount?: number;
commentCount?: number;
};
}
export default {
async home(ctx, _next) {
const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {};
const knex = strapi.db.connection;
const offset = (page - 1) * pageSize || 0;
// Answers
const answeredQs = await strapi
.documents("api::question.question")
.findMany({
filters: {
$or: [
{
answers: {
$and: [
{
id: {
$notNull: true,
},
},
],
},
},
{
bot_answer: {
id: {
$notNull: true,
},
},
answers: {
$and: [
{
id: {
$null: true,
},
},
],
},
},
],
},
populate: {
bot_answer: {},
answers: {
fields: ["id"],
},
},
fields: ["id", "title"],
limit: pageSize,
start: offset,
});
const qIds = answeredQs.filter((q) => !!q["answers"]).map((q) => q.id);
const upvoteCounts = await knex("questions as q")
.whereIn("q.id", qIds)
.leftJoin("answers_question_lnk as aql", "q.id", "aql.question_id")
.whereNotNull("aql.answer_id")
.leftJoin("answers as a", "aql.answer_id", "a.id")
.leftJoin("votes_answer_lnk as val", "a.id", "val.answer_id")
.leftJoin("votes as v", "val.vote_id", "v.id")
.select(
"q.id as question_id",
"a.id as answer_id",
"a.document_id as answer_document_id",
knex.raw(
"SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count",
["upvote"],
),
)
.groupBy("q.id", "a.id");
const questionsWithAnswers = {};
upvoteCounts.forEach((row) => {
const questionId = row.question_id;
if (!questionsWithAnswers[questionId]) {
questionsWithAnswers[questionId] = {
id: questionId,
topAnswer: {
id: row.answer_id,
upvoteCount: row.upvote_count,
documentId: row.answer_document_id,
},
};
} else if (
row.upvote_count >
questionsWithAnswers[questionId].topAnswer.upvoteCount
) {
questionsWithAnswers[questionId].topAnswer = {
id: row.answer_id,
upvoteCount: row.upvote_count,
documentId: row.answer_document_id,
};
}
});
const topAnswers = Object.values(questionsWithAnswers).map(
(q: homeQuestion) => {
return q.topAnswer;
},
);
let answers = await strapi.documents("api::answer.answer").findMany({
filters: {
id: {
$in: topAnswers.map((ta) => ta.id),
},
},
populate: ["answerer", "question"],
});
const commentCounts = await knex("answers as a")
.whereIn(
"a.id",
answers.map((a) => a.id),
)
.leftJoin("comments_answer_lnk as cal", "a.id", "cal.answer_id")
.leftJoin("comments as c", "cal.comment_id", "c.id")
.select(
"a.id as answer_id",
"a.document_id as answer_document_id",
knex.raw("COUNT(c.id) as comment_count"),
)
.groupBy("a.id");
answers = answers.map((ans) => {
const tempAnsw = topAnswers.find((a) => a.documentId == ans.documentId);
ans["upvoteCount"] = tempAnsw?.upvoteCount || 0;
const tempCC = commentCounts.find(
(cc) => cc.answer_document_id == ans.documentId,
);
ans["commentCount"] = tempCC?.comment_count || 0;
return ans;
});
// Bot Answers
const botOnlyAnsweredQuestions = answeredQs.filter(
(q) => !!q["bot_answer"] && !!!q["answers"].length,
);
const baqIds = botOnlyAnsweredQuestions.map((q) => q.id);
let botAnswers = botOnlyAnsweredQuestions.map((baq) => {
const tempBA = baq["bot_answer"];
delete baq["bot_answer"];
tempBA["question"] = baq;
return tempBA;
});
const baIds = botAnswers.map((ba) => ba.id);
const baUpvotes = await knex("questions as q")
.whereIn("q.id", baqIds)
.leftJoin("questions_bot_answer_lnk as qbal", "q.id", "qbal.question_id")
.whereNotNull("qbal.bot_answer_id")
.leftJoin("bot_answers as ba", "qbal.bot_answer_id", "ba.id")
.leftJoin("votes_bot_answer_lnk as vbal", "ba.id", "vbal.bot_answer_id")
.leftJoin("votes as v", "vbal.vote_id", "v.id")
.select(
"ba.id as bot_answer_id",
knex.raw(
"SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count",
["upvote"],
),
)
.groupBy("q.id", "ba.id");
const baCommentCounts = await knex("bot_answers as ba")
.whereIn("ba.id", baIds)
.leftJoin(
"comments_bot_answer_lnk as cbal",
"ba.id",
"cbal.bot_answer_id",
)
.leftJoin("comments as c", "cbal.comment_id", "c.id")
.select(
"ba.id as bot_answer_id",
knex.raw("COUNT(c.id) as comment_count"),
)
.groupBy("ba.id");
botAnswers = botAnswers.map((ba) => {
const tempUC = baUpvotes.find((bauc) => bauc.bot_answer_id === ba.id);
ba["upvoteCount"] = tempUC?.upvote_count || 0;
const tempCC = baCommentCounts.find((cc) => cc.bot_answer_id === ba.id);
ba["commentCount"] = tempCC?.comment_count || 0;
return ba;
});
ctx.body = [...answers, ...botAnswers];
},
async homeCount(ctx, _next) {
const answeredQuestionCount = await strapi
.documents("api::question.question")
.count({
filters: {
$or: [
{
answers: {
$and: [
{
id: {
$notNull: true,
},
},
],
},
},
{
bot_answer: {
id: {
$notNull: true,
},
},
answers: {
$and: [
{
id: {
$null: true,
},
},
],
},
},
],
},
});
ctx.body = { count: answeredQuestionCount };
},
async comments(ctx, _next) {
const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {};
const id = ctx.params?.id || "";
let comments = await strapi.documents("api::comment.comment").findMany({
filters: {
answer: {
id: { $eq: id },
},
},
sort: "createdAt:desc",
populate: ["commenter"],
start: (page - 1) * pageSize || 0,
limit: pageSize || 25,
});
const knex = strapi.db.connection;
const upvoteCounts = await knex("comments as c")
.leftJoin("votes_comment_lnk as vcl", "c.id", "vcl.comment_id")
.leftJoin("votes as v", "vcl.vote_id", "v.id")
.whereIn(
"c.id",
comments.map((c) => c.id),
)
.select(
"c.id as comment_id",
"c.document_id as comment_document_id",
knex.raw(
"SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count",
["upvote"],
),
)
.groupBy("c.id");
comments = comments.map((comment) => {
const tempUVC = upvoteCounts.find((uvc) => uvc.comment_id == comment.id);
comment["upvoteCount"] = tempUVC?.upvote_count || 0;
return comment;
});
ctx.body = comments;
},
async commentCount(ctx, _next) {
const id = ctx.params?.id || "";
let commentCount = await strapi.documents("api::comment.comment").count({
filters: {
answer: {
id: { $eq: id },
},
},
});
ctx.body = { count: commentCount };
},
};
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:
export default {
routes: [
{
method: 'GET',
path: '/answers/home',
handler: 'augment.home',
},
{
method: 'GET',
path: '/answers/home/count',
handler: 'augment.homeCount',
},
{
method: 'GET',
path: '/answers/:id/comments',
handler: 'augment.comments',
},
{
method: 'GET',
path: '/answers/:id/comments/count',
handler: 'augment.commentCount',
},
]
}
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:
touch 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:
export default {
async comments(ctx, _next) {
const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
const id = ctx.params?.id || ''
let comments = await strapi.documents('api::comment.comment').findMany({
filters: {
bot_answer: {
id: { $eq: id }
}
},
populate: ['commenter'],
sort: 'createdAt:desc',
start: ((page - 1) * pageSize) || 0,
limit: pageSize || 25
})
const knex = strapi.db.connection
const upvoteCounts = await knex('comments as c')
.leftJoin('votes_comment_lnk as vcl', 'c.id', 'vcl.comment_id')
.leftJoin('votes as v', 'vcl.vote_id', 'v.id')
.whereIn('c.id', comments.map(c => c.id))
.select(
'c.id as comment_id',
'c.document_id as comment_document_id',
knex.raw('SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count', ['upvote'])
)
.groupBy('c.id')
comments = comments.map((comment) => {
const tempUVC = upvoteCounts.find(uvc => uvc.comment_id == comment.id)
comment['upvoteCount'] = tempUVC?.upvote_count || 0
return comment
})
ctx.body = comments
},
async commentCount(ctx, _next) {
const id = ctx.params?.id || ''
let commentCount = await strapi.documents('api::comment.comment').count({
filters: {
bot_answer: {
id: { $eq: id }
}
}
})
ctx.body = { count: commentCount }
}
}
The routes file apps/quora-backend/src/api/bot-answer/routes/01-augment.ts
has:
export default {
routes: [
{
method: 'GET',
path: '/bot-answers/:id/comments',
handler: 'augment.comments',
},
{
method: 'GET',
path: '/bot-answers/:id/comments/count',
handler: 'augment.commentCount',
},
]
}
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:
export default {
async count(ctx, _next) {
ctx.body = {
count: await strapi.documents('api::question.question').count({
filters: {
$or: [
{
answers: {
$not: null
}
},
{
bot_answer: {
$not: null
}
}
]
}
})
}
},
async comments(ctx, _next) {
const knex = strapi.db.connection
const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
const id = ctx.params?.id || ''
let comments = await strapi.documents('api::comment.comment').findMany({
filters: {
question: {
id: { $eq: id }
}
},
populate: ['commenter'],
start: ((page - 1) * pageSize) || 0,
limit: pageSize || 25
})
const commentIds = comments.map(c => c.id)
const upvoteCounts = await knex('comments as c')
.leftJoin('votes_comment_lnk as vcl', 'c.id', 'vcl.comment_id')
.leftJoin('votes as v', 'vcl.vote_id', 'v.id')
.whereIn('c.id', commentIds)
.select(
'c.id as comment_id',
'c.document_id as comment_document_id',
knex.raw('SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count', ['upvote'])
)
.groupBy('c.id')
.orderBy('upvote_count', 'desc')
comments = comments.map(c => {
const uc = upvoteCounts.find(count => count.comment_id == c.id)
if (uc) c['upvoteCount'] = uc.upvote_count
return c
})
ctx.body = comments
},
async answers(ctx, _next) {
const knex = strapi.db.connection
const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
const id = ctx.params?.id || ''
const question = await strapi.documents('api::question.question').findFirst({
filters: {
id: { $eq: id }
}
})
if (question) {
let answers = await strapi.documents('api::answer.answer').findMany({
filters: {
question: {
id: { $eq: id }
}
},
limit: pageSize,
start: (page - 1) * pageSize || 0,
populate: ['answerer']
})
const answerIds = answers.map(a => a.id)
const answersWithVotes = await knex('answers as a')
.leftJoin('votes_answer_lnk as val', 'a.id', 'val.answer_id')
.leftJoin('votes as v', 'val.vote_id', 'v.id')
.whereIn('a.id', answerIds)
.select(
'a.id as answer_id',
'a.document_id as answer_document_id',
knex.raw('SUM(CASE WHEN v.type = ? THEN 1 ELSE 0 END) as upvote_count', ['upvote'])
)
.groupBy('a.id')
.orderBy('upvote_count', 'desc')
const commentCounts = await knex('answers as a')
.whereIn('a.id', answerIds)
.leftJoin('comments_answer_lnk as cal', 'a.id', 'cal.answer_id')
.leftJoin('comments as c', 'cal.comment_id', 'c.id')
.select(
'a.id as answer_id',
'c.id as comment_id',
'a.document_id as answer_document_id',
knex.raw('COUNT(c.id) as comment_count')
)
.groupBy('a.id')
.orderBy('comment_count', 'desc')
answers = answers.map((ans) => {
const tempAnsw = answersWithVotes.find(a => a.answer_id == ans.id)
ans['upvoteCount'] = tempAnsw?.upvote_count || 0
const tempCC = commentCounts.find(cc => cc.answer_id == ans.id)
ans['commentCount'] = tempCC?.comment_count || 0
return ans
})
const answerCount = await strapi.documents('api::answer.answer').count({
filters: {
question: {
id: { $eq: id }
}
}
})
const commentCount = await strapi.documents('api::comment.comment').count({
filters: {
question: {
id: { $eq: id }
}
}
})
question['answers'] = answers
question['answerCount'] = answerCount
question['commentCount'] = commentCount
}
ctx.body = question
},
async commentCount(ctx, _next) {
const id = ctx.params?.id || ''
let commentCount = await strapi.documents('api::comment.comment').count({
filters: {
question: {
id: { $eq: id }
}
}
})
ctx.body = { count: commentCount }
},
async homeQuestions(ctx, _next) {
const knex = strapi.db.connection
const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
let questions = await strapi.documents('api::question.question').findMany({
start: (page - 1) * pageSize || 0,
limit: pageSize
})
const answerCount = await knex('questions as q')
.whereIn('q.id', questions.map(q => q.id))
.leftJoin('answers_question_lnk as aql', 'q.id', 'aql.question_id')
.leftJoin('answers as a', 'aql.answer_id', 'a.id')
.select(
'q.id as question_id',
knex.raw('COUNT(a.id) as answer_count')
)
.groupBy('q.id')
.orderBy('answer_count', 'desc')
const botAnswerCount = await knex('questions as q')
.whereIn('q.id', questions.map(q => q.id))
.leftJoin('questions_bot_answer_lnk as qbal', 'q.id', 'qbal.question_id')
.leftJoin('bot_answers as ba', 'qbal.bot_answer_id', 'ba.id')
.select(
'q.id as question_id',
knex.raw('COUNT(ba.id) as answer_count')
)
.groupBy('q.id')
.orderBy('answer_count', 'desc')
questions = questions.map(qst => {
const ac = answerCount.find(el => el.question_id == qst.id)
const bac = botAnswerCount.find(el => el.question_id == qst.id)
qst['answerCount'] = (ac.answer_count || 0) + (bac.answer_count || 0)
return qst
})
ctx.body = questions
},
async botAnswers(ctx, _next) {
const id = ctx.params?.id || ''
const botAnswer = await strapi.documents('api::bot-answer.bot-answer').findFirst({
filters: {
question: {
id: {
$eq: id
}
}
}
})
if (botAnswer) {
const commentCount = await strapi.documents('api::comment.comment').count({
filters: {
bot_answer: {
id: {
$eq: botAnswer.id
}
}
}
})
const upvoteCount = await strapi.documents('api::vote.vote').count({
filters: {
bot_answer: {
id: {
$eq: botAnswer.id
}
},
type: {
$eq: 'upvote'
}
}
})
botAnswer["commentCount"] = commentCount
botAnswer["upvoteCount"] = upvoteCount
ctx.body = [botAnswer]
return
}
ctx.body = []
}
}
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:
export default {
routes: [
{
method: 'GET',
path: '/questions/count',
handler: 'augment.count',
},
{
method: 'GET',
path: '/questions/home',
handler: 'augment.homeQuestions',
},
{
method: 'GET',
path: '/questions/:id/comments',
handler: 'augment.comments',
},
{
method: 'GET',
path: '/questions/:id/answers',
handler: 'augment.answers',
},
{
method: 'GET',
path: '/questions/:id/comments/count',
handler: 'augment.commentCount',
},
{
method: 'GET',
path: '/questions/:id/bot-answers',
handler: 'augment.botAnswers',
},
]
}
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
:
module.exports = {
afterCreate(event) {
const { result } = event
function handleError(err) {
return Promise.reject(err)
}
fetch(
`${process.env.CLOUDFLARE_API_URL}/${process.env.CLOUDFLARE_ACCOUNT_ID}/${process.env.CLOUDFLARE_AI_MODEL}`,
{
method: 'POST',
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
},
body: JSON.stringify({
prompt: result.title
}),
}
)
.then(res => {
if (res.ok) {
return res.json()
} else {
handleError(res)
}
},
handleError
)
.then(pr => {
return fetch(
`http${process.env.HOST == '0.0.0.0' || process.env.HOST == 'localhost' ? '' : 's'}://${process.env.HOST}:${process.env.PORT}/api/bot-answers`,
{
method: 'POST',
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.STRAPI_BOT_API_TOKEN}`,
},
body: JSON.stringify({
data: {
body: (pr as { result: { response: string } }).result.response,
question: result.id
}
}),
})
},
handleError
)
.then(res => {
if (res.ok) {
return res.json()
} else {
handleError(res)
}
},
handleError
)
.then(data => {
return data
},
handleError
)
},
};
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:
import { factories } from '@strapi/strapi'
export default factories.createCoreController(
'api::vote.vote',
({ strapi }) => ({
async create(ctx, _next) {
const { data: { answer = 0, comment = 0, question = 0, bot_answer = 0, voter } } = (ctx.request as any).body
const existingVotes = await strapi.documents('api::vote.vote').findMany({
filters: {
voter: voter,
...(answer && { answer: { id: { $eq: answer } } }),
...(bot_answer && { bot_answer: { id: { $eq: bot_answer } } }),
...(comment && { comment: { id: { $eq: comment } } }),
...(question && { question: { id: { $eq: question } } }),
}
})
if (!existingVotes.length) {
const res = await strapi.documents('api::vote.vote').create({
...(ctx.request as any).body,
})
ctx.body = res
} else {
const res = await strapi.documents('api::vote.vote').update({
documentId: existingVotes[0].documentId,
...(ctx.request as any).body,
})
ctx.body = res
}
}
})
)
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:
touch apps/quora-backend/src/extensions/users-permissions/strapi-server.ts
Here's the contents of this file:
const processRequest = async (ctx, uid, userRole, populate = []) => {
const { page = 1, pageSize = 25 } = ctx.request.query.pagination || {}
const id = ctx.params?.id || ''
const results = await strapi.documents(uid).findMany({
filters: {
[userRole]: {
id: { $eq: id }
}
},
populate,
start: ((page - 1) * pageSize) || 0,
limit: pageSize || 25
})
ctx.body = results
}
const countEntries = async (ctx, uid, userRole) => {
const id = ctx.params?.id || ''
const count = await strapi.documents(uid).count({
filters: {
[userRole]: {
id: { $eq: id }
}
}
})
ctx.body = { count }
}
async function questions(ctx) {
await processRequest(
ctx,
'api::question.question',
null
)
}
async function answers(ctx) {
await processRequest(
ctx,
'api::answer.answer',
'answerer',
['question']
)
}
async function comments(ctx) {
await processRequest(
ctx,
'api::comment.comment',
'commenter',
['question', 'answer']
)
}
async function votes(ctx) {
await processRequest(
ctx,
'api::vote.vote',
'voter',
['question', 'answer', 'comment']
)
}
async function questionCount(ctx) {
await countEntries(
ctx,
'api::question.question',
'asker'
)
}
async function answerCount(ctx) {
await countEntries(
ctx,
'api::answer.answer',
'answerer',
)
}
async function commentCount(ctx) {
await countEntries(
ctx,
'api::comment.comment',
'commenter',
)
}
async function voteCount(ctx) {
await countEntries(
ctx,
'api::vote.vote',
'voter',
)
}
module.exports = (plugin) => {
plugin.controllers.user['answers'] = answers
plugin.controllers.user['answerCount'] = answerCount
plugin.controllers.user['comments'] = comments
plugin.controllers.user['commentCount'] = commentCount
plugin.controllers.user['questions'] = questions
plugin.controllers.user['questionCount'] = questionCount
plugin.controllers.user['votes'] = votes
plugin.controllers.user['voteCount'] = voteCount
plugin.routes['content-api'].routes.push(
{
method: 'GET',
path: '/users/:id/answers',
handler: 'user.answers',
config: { prefix: '' }
},
{
method: 'GET',
path: '/users/:id/answers/count',
handler: 'user.answerCount',
config: { prefix: '' }
},
{
method: 'GET',
path: '/users/:id/comments',
handler: 'user.comments',
config: { prefix: '' }
},
{
method: 'GET',
path: '/users/:id/comments/count',
handler: 'user.commentCount',
config: { prefix: '' }
},
{
method: 'GET',
path: '/users/:id/questions',
handler: 'user.questions',
config: { prefix: '' }
},
{
method: 'GET',
path: '/users/:id/questions/count',
handler: 'user.questionCount',
config: { prefix: '' }
},
{
method: 'GET',
path: '/users/:id/votes',
handler: 'user.votes',
config: { prefix: '' }
},
{
method: 'GET',
path: '/users/:id/votes/count',
handler: 'user.voteCount',
config: { prefix: '' }
}
)
return plugin;
};
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:
- On the sidebar, under the Users & permissions plugin click Roles.
- Click the Public role.
- 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.
"scripts": {
...
"generate-types": "strapi ts:generate-types"
}
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
:
"scripts": {
"generate-types": "cp ../../../apps/quora-backend/types/generated/* src/"
}
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
:
export * from "./src/components"
export * 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!
Strapi 5 is available now! Start building today!
Top comments (0)