Forem

Cover image for Build an AI-powered Quora clone with Strapi and Next.js - Part 1
Strapi for Strapi

Posted on • Originally published at strapi.io

Build an AI-powered Quora clone with Strapi and Next.js - Part 1

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add this to the file:

nodeLinker: node-modules
npmRegistryServer: https://registry.npmjs.org/
Enter fullscreen mode Exit fullscreen mode

Next, initialize a workspace with Yarn:

yarn init -w
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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"
  }
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Choose sqlite as its database.

Run Strapi development server with:

yarn workspace quora-backend develop 
Enter fullscreen mode Exit fullscreen mode

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):

Question content type.png

Its user relation:

Question-User relation.png

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.png

Answer content type and User relation:

Answer-User relation.png

Answer content type and Question relation:

Answer-Question relation.png

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.png

Bot Answer content type and Question relation:

Bot Answer-Question relation.png

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.png

Comment content type and User relation:

Comment-User relation.png

Comment content type and Question relation:

Comment-Question relation.png

Comment content type and Answer relation:

Comment-Answer relation.png

Comment content type and Bot Answer relation:

Comment-Bot Answer relation.png

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:

Vote content type.png

type field of Vote content type:

Comment type field.png

Vote content type and User relation:

Vote-User relation.png

Vote content type and Question relation:

Vote-Question relation.png

Vote content type and Answer relation:

Vote-Answer relation.png

Vote content type and Bot Answer relation:

Vote-Bot Answer relation.png

Vote content type and Comment relation:

Vote-Comment relation.png

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
Enter fullscreen mode Exit fullscreen mode

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.
# 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}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Head to Settings on the Strapi admin panel.
  2. Then under API Tokens in the sidebar,
  3. Click the Add new API token button.
  4. Add a name for the token and an optional description.
  5. Set the Token duration to Unlimited.
  6. Set the Token access to Full access.
  7. 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}
Enter fullscreen mode Exit fullscreen mode

The API Token setting on the admin panel:
1 - strapi api token in admin.png

The settings for the token:
2 - strapi api token settings.png

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
Enter fullscreen mode Exit fullscreen mode

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 };
  },
};

Enter fullscreen mode Exit fullscreen mode

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',
        },
    ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
    }
}
Enter fullscreen mode Exit fullscreen mode

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',
        },
    ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 = []
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
        },
    ]
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
            )
    },
};
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    })
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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

  1. On the sidebar, under the Users & permissions plugin click Roles.
  2. Click the Authenticated role.
  3. 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.

Answer Authenticated Permissions.png

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.

"scripts": {
  ...
  "generate-types": "strapi ts:generate-types"
}
Enter fullscreen mode Exit fullscreen mode

Next, create the packages/shared/strapi-types folder:

mkdir -p packages/shared/strapi-types/src
Enter fullscreen mode Exit fullscreen mode

Initialize a package within it:

yarn --cwd packages/shared/strapi-types init
Enter fullscreen mode Exit fullscreen mode

Create an index.ts and tsconfig.json file:

touch packages/shared/strapi-types/index.ts packages/shared/strapi-types/tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Copy the contents of the tsconfig.json file from this link.

Add its dependencies:

yarn workspace strapi-types add @strapi/strapi 
Enter fullscreen mode Exit fullscreen mode

Add this script to packages/shared/strapi-types/package.json:

"scripts": {
    "generate-types": "cp ../../../apps/quora-backend/types/generated/* src/"
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Then run:

turbo generate-types
Enter fullscreen mode Exit fullscreen mode

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)