In this article, we will look into the relational fields in Strapi to see how we can utilize them to establish relationships in our models.
What is Strapi?
Strapi is an open-source Node.js headless CMS(Content Management System) based on Node.js used to develop APIs(RESTful and GraphQL APIs) and build the APIs content. The APIs in Strapi are built in the form of collections or single types.
A collection in Strapi will create and expose the endpoints on all the HTTP verbs. For example, if we have a blog collection. Strapi will create the following endpoints based on the collection:
blogGET: This will get all the blog entries from the endpoint.blogPOST: This will create a new blog post from the endpoint.blog/:documentIdGET: This will return the blog post with the document ID:documentId.blog/:documentIdDELETE: This will delete the blog post with the document ID:documentIdfrom the endpoint.
Strapi creates all those APIs for us. We can then add content to the collection via the admin panel or the Strapi API.
Internally, Strapi is powered by Koajs, and its default database is SQLite, where it persists the content we add to the collections and single-types.
Now, you will learn about relations in database models and establish the relations in Strapi collections.
Relations in Database Fields and Strapi
The database contains tables, columns, and records. Now, relationships can be defined in the database tables. In Strapi, we can use relations to create links between our Content Types. This relationship is like a pointer or reference. They point to data in a table that depicts what they contain.
There are types of relationships we can establish in Strapi:
- One-to-one (1:1)
- One-to-Many
- Many-to-Many
- One-Way
- Many-way
- Polymorphic
Strapi One-to-one relationship (1:1)
In this Strapi one-to-one relationship, a column in a table points to only one column in another table.
For example, in a Student table, a studentId column can point to a StudentInfo table. A column in the StudentInfo table, studentId points back to the Student table. So here, the Student table is associated with one and only one record in the StudentInfo table. We can fetch a student's info from the Student table, and we can fetch a student from the StudentInfo table. That's a one-to-one relationship.
Strapi One-to-Many Relationship
This relation involves a table pointing to several or many tables. A column in table A can point to several tables(B, C, D), these tables, in turn, point to table A. Also, each table (A, B, C, D) can hold one or more records of the column in table A.
For example, let's say we have a Company table. This table holds the list of all the companies in a system. We can create an Employee table to hold the name of an employee. Now, we can add a companyId column to the Employee table, and this companyId will point to the Company table.
Now a Company table can point to many employee records in the Employee table. Also, each record in the Employee table points back to a record in the Company table. The relation here is Strapi one-to-many relationship.
Strapi Many-to-Many Relationship
Strapi many-to-many relationship involves a column in a table pointing to many records in another table and a column in another table pointing to many records in the first table. For example, many doctors can be associated with many hospitals.
Strapi One-Way Relationship
This relationship involves a column pointing or linking to another column in a table. The thing here is that the other column does not point back to the "pointing" column. One-way relation is similar to One-to-One relation but differs because the column being "pointed" does not link back to the pointing column.
For example, in a User table, A detailsId column in the User table can point to a Details table. This means that the details of a user are in the detailsId column in the User table and the details are stored in the Details table.
So we see that the User table points to only one table, which is the Details table. The relationship is one-way. There is no column in the Details table that points back to the User table.
Strapi Many-way Relationship
This relation involves a column in a table pointing to many records in another table. The records being pointed to does not point back or link back to the record.
For example, a User table has a column carId that points to a Car table. The carId can point to many records in the Car table but the Car record does not point back to the User table, this relationship is a Strapi many-way relationship.
Polymorphic
This relationship involves a column in a table that can link to different columns in other tables. In a polymorphic relationship, a model/table can be associated with different models/tables. In other relationships we have seen, it is mainly between a table and another table, not more than three tables are involved in the relationship. But in a polymorphic relationship, multiple tables are involved.
For example, a Tire table holds can be linked and have links to a Toyota table, Mercedes table, etc. So a Toyota can relate to the same Tire as a Mercedes.
We have seen all the relations we have. The below sections will explain and show how we can set the relations from both the Strapi admin UI and a Strapi project.
Where are relations set in Strapi?
Relationship links can be set in the Admin panel and manually from the generated Strapi project.
Via Strapi Admin Panel
Relations can be set in Strapi's Collection types, Single types, and Components. The relation is set when adding fields to our Collection, Single collection, or Component type. The relation field is selected:
Another UI is displayed in the modal:
This is where we set the relations between the current model we are creating and an existing model.
We have two big boxes in the above picture, the left box is the current model we are creating, and the right box is the model the current model will be having relations with. We can click on the dropdown icon to select the model we want to link relations within the right box.
The smaller boxes with icons are the relations we can establish between the two models in the bigger boxes.
Let's look at the smaller boxes starting from the left.
- The first box represents the
has onerelation.
It establishes a one-way relation between content types in Strapi.
- The second box is
has one and belongs to one.
It links two content types in a one-to-one way relationship.
- The third box is
belongs to many.
It links two content types in a one-to-many relation. The content type in the left-bigger box will have a field that links to many records in the content type that is in the right-bigger box. The field in the content type in the right-bigger box will have a field that links to a single record in the left-content type.
- The fourth box is
has many.
This one links two content types in a many-to-one relation. Here, the content type at the left-bigger box has a field that links to many records to the content type at the right-bigger box. It is the reverse of the belongs to many boxes.
- The fifth box is
has and belongs to many.
This box links two content types in a many-to-many relationship. Both content types in the bigger boxes will have a field that links many records to each other.
- The sixth box is
has many.
It links two content types in a many-way relationship. The field on the left content type links to many records in the right content type. The right content type does not link back to the left content type.
Via Strapi Project
Let's see how we set relations in our content types from our Strapi project. The content types in a Strapi project are stored in the ./src/api/ folder in our Strapi project. The relations are set in the ./src/api/[NAME]/content-types/[NAME]/schema.json file.
Fields are set inside the attributes section. To set a relation field we use some properties like model, collection, etc. Let's see how we set the relations for all the types of relations in Strapi.
One-to-One (1:1)
To set a one-to-one relation between two content types, we’ll create a new property in the attributes property. Let's say we want to set a one-to-one between a Student model and a Student-info model, we will open the ./src/api/student/content-types/student/schema.json file and add the code:
1 {
2 "kind": "collectionType",
3 "collectionName": "students",
4 "info": {
5 "singularName": "student",
6 "pluralName": "students",
7 "displayName": "Student",
8 "description": ""
9 },
10 "options": {
11 "draftAndPublish": true
12 },
13 "pluginOptions": {},
14
15 // The fields are configured here
16 "attributes": {
17 "name": {
18 "type": "string"
19 },
20
21 "student_info": { //field name
22 "type": "relation", // field type
23 "relation": "oneToOne", // relation type
24 "target": "api::student-info.student-info", // the target of the relation
25 "inversedBy": "student" // more info here - https://docs.strapi.io/developer-docs/latest/development/backend-customization/models.html#relations
26 }
27 }
28 }The relation field is student_info. The model refers to the content type in Strapi the field is pointing to. It is set to student_info and so this property in the Student content type points to the student_info content type.
We set the type as relation and the relation as oneToOne. All these state that the Student model has and belongs to one StudentInfo.
Let's see inside ./src/api/student-info/content-types/student-info/schema.json file
1 {
2 "kind": "collectionType",
3 "collectionName": "student_infos",
4 "info": {
5 "singularName": "student-info",
6 "pluralName": "student-infos",
7 "displayName": "studentInfo"
8 },
9 "options": {
10 "draftAndPublish": true
11 },
12 "pluginOptions": {},
13 "attributes": {
14 "bio": {
15 "type": "text"
16 },
17 "student": {
18 "type": "relation",
19 "relation": "oneToOne",
20 "target": "api::student.student",
21 "inversedBy": "student_info"
22 }
23 }
24 }Here, we have a student property which points to the student collection type. The relation set here is also oneToOne
These two JSON configs of both Student and StudentInfo models establish a one-to-one relationship between them as you can see in the interface below. This is similar for all other relations.
One-to-Many
Let's say we have two content types, Employee and Company. The Company has many Employee records, and the Employee record points back to a Company record.
To establish this in the content types, we will go to their /schema.json files in our project and set relational fields in Strapi.
For the Company model, we want an employees relation to point to many Employees. So we will do the below in the ./src/api/company/content-types/company/schema.json file.
1 {
2 ...
3 "attributes": {
4 "name": {
5 "type": "string"
6 },
7 "employees": {
8 "type": "relation",
9 "relation": "oneToMany",
10 "target": "api::employee.employee",
11 "mappedBy": "company"
12 }
13 }
14 }Also, in ./src/api/employee/content-types/employee/schema.json file:
1 {
2 ...
3 "attributes": {
4 "name": {
5 "type": "string"
6 },
7 "company": {
8 "type": "relation",
9 "relation": "manyToOne",
10 "target": "api::company.company",
11 "inversedBy": "employees"
12 }
13 }
14 }This sets a one-to-many relationship in the Company model.
Many-to-Many
In setting a many-to-many relation from our Strapi project, we will set the relation field of both content types.
For example, doctors can work in many hospitals and many hospitals can have many doctors. In this case, our Doctor model in ./src/api/doctor/content-types/doctor/schema.json will be this:
1 {
2 ...
3 "attributes": {
4 "name": {
5 "type": "string"
6 },
7 "hospitals": {
8 "type": "relation",
9 "relation": "manyToMany",
10 "target": "api::hospital.hospital",
11 "inversedBy": "doctors"
12 }
13 }
14 }The hospital relation field points to many hospitals.
The Hospital model will be this:
./src/api/hospital/content-types/hospital/schema.json:
1 {
2 ...
3 "attributes": {
4 "name": {
5 "type": "string"
6 },
7 "doctors": {
8 "type": "relation",
9 "relation": "manyToMany",
10 "target": "api::doctor.doctor",
11 "inversedBy": "hospitals"
12 }
13 }
14 }This effectively sets a many-to-many relation between the Doctor and Hospital models.
One-Way
To set this relation from our Strapi project between two models, we will define a relation field in one model's /schema.json file only. The other model will have no relation connecting to other model define in its /schema.json file.
For example, we have two models User and Detail and they have one-way relation. To set this up. We set the below in the User's model file user/models/user.settings.json file:
1 {
2 ...
3 "attributes": {
4 "name": {
5 "type": "string"
6 },
7 "details": {
8 "type": "relation",
9 "relation": "oneToOne",
10 "target": "api::detail.detail"
11 }
12 }
13 }There will be no relation setting in the Detail schema file that will point to the User model. So in this way, we have set a one-way relation between the User and Detail models in Strapi.
Many-Way
This is the same as the one-way relation, but this one involves one model pointing to many records in another model, but this other model does not point back.
To set this manually in Strapi, we will set a relation field with the collection property in one model but no relation definition in the other model.
For example, a User has many Cars. The relation is many-way. A user can own many cars. The setting will be this for the User:
user/models/user.settings.json:
1 {
2 ...
3 "attributes": {
4 "name": {
5 "type": "string"
6 },
7 "cars": {
8 "type": "relation",
9 "relation": "oneToMany",
10 "target": "api::car.car"
11 }
12 }
13 }The car relation has a collection property that is set to car. This setting tells Strapi that the cars field in the User model points to many Car records.
We will not make a relation in the Car model that will point back to the User model because this is a many-way relation.
We have learned all the relations in Strapi and also learned how to set them up both via the Strapi admin UI panel and from a Strapi project. Now, we show how to use some of the relations in Strapi to build a real-life app.
Setting up Strapi Project
We will create a Q&A app just like Quora, and users can ask questions, answer questions, and comment on answers. We will build this app to demonstrate how to use Strapi relations to link our models.
This project will be in two parts: the backend and the front-end. Of course, the backend will be built using Strapi, and the front-end will be built using Next.js.
We will create a central folder that will hold both backend and frontend projects:
mkdir relations
mkdir understanding-and-using-relations-in-strapiWe move into the folder:
cd relations
cd understanding-and-using-relations-in-strapiCreate the Strapi project:
npx create-strapi@latestThe CLI will ask a few more questions:
npx create-strapi@latest
Strapi v5.0.1 🚀 Let's create your new project
? What is the name of your project? qa-app
We can't find any auth credentials in your Strapi config.
Create a free account on Strapi Cloud and benefit from:
- ✦ Blazing-fast ✦ deployment for your projects
- ✦ Exclusive ✦ access to resources to make your project successful
- An ✦ Awesome ✦ community and full enjoyment of Strapi's ecosystem
Start your 14-day free trial now!
? Please log in or sign up. Skip
? Do you want to use the default database (sqlite) ? Yes
? Start with an example structure & data? No
? Start with Typescript? Yes
? Install dependencies with npm? Yes
? Initialize a git repository? Yes
Strapi Creating a new application at /Users/theodore/dev/qa-app
deps Installing dependencies with npmThe above command will create a Strapi project in qa-app folder inside the understanding-and-using-relations-in-strapi folder.
To start the project, run:
npm run developStrapi will serve the project on localhost:1337. It will launch the Strapi admin UI panel on localhost:1337/admin.
Fill in your details and click on the LET'S START button. We will begin to build our collections but first, let's draw our models.
Models
We will have three models for our Q&A app. We will have Question, Answer and Comment.
Our Question model will be this:
1Question {
2 qText
3 user
4}qText: This will hold the question.user: This holds the name of the user.
The Answer model will be this:
1Answer {
2 aText
3 question
4 user
5}aText: This holds the answer text.question: This holds the reference to the question.user: The user that answered.
The Comment model will look like this:
1Comment {
2 cText
3 answer
4 user
5}cText: This will hold the comment text on the answer.answer: This is the reference to the answer.user: The user that commented.
We have seen how our collection will look like, now let's build our collections. These models have relationships that connect them. Let's see them below.
One-to-Many
The Question model and the Answer model have a one-to-many relationship. A Question will have many Answers. Now, we will build a Question collection in Strapi, and also we will create the Answer collection and there we will establish the relation between them. Now, on the http://localhost:1337/admin/ page click on the Create First Content Type button, a modal will appear.
We will create the Question collection.
- Type
questionin theDisplay namefield. - Click on the text field.
- Type
qTextin theNamefield. - Select
Long Textin the below radio button.
- Click on
+ Add another field. - Select
text. - Type in
user.
- Click on
Finish. - Next, click on the
Savebutton on the top-right of the page.
Next, we will create the Answer collection
- Click on the
+ Create new collection typelink, a modal will show up, type inanswer. Click on the+ Add another fieldbutton. - Select
textand type inaText. - Select
Long Text
- Click on
+ Add another field - Select
textand type inuser.
- Select
relationfield. - On the right box, press on the dropdown element and select
Question. - Click on the fourth small box, counting from left. The box establishes a one-to-many relationship between the
Questioncollection and theAnswercollection.
- Click on the
Finishbutton. - Next, click on the
Savebutton on the top-right of the page.
One-to-One
The Comment model and the Answer model have a one-to-one relationship. A comment has one answer.
We will create the Comment collection.
- Click on the
+ Create new collection typelink, a modal will show up, type incomment.
- Click on the
+ Add another fieldbutton. - Select
textfield. - Type in
cTextand click on the+ Add another fieldbutton.
- Select
relationfield. - On the big box on the right, click on the dropdown element and select
Answer. - Select the first small box, counting from the left. This box establishes the one-to-one relationship between the
Commentand theAnswerbut not fromAnswerto comment. So, thecommentsfield will not appear on theAnswerresponse.
- Click on the
Finishbutton. - Next, click on the
Savebutton on the top-right of the page.
We are done building our collections and establishing their relationships. Now, let's build the front end.
Before we start building the frontend, we have set the permissions for a Public unauthenticated user so that our Strapi API can return data from routes without authentication.
NOTE: You’d typically need authentication in your application, especially when dealing with
create,deleteandupdateendpoints
Enable all permissions for answer collection type
Enable all permissions for question collection type
Enable all permissions for comment collection type
Building the QnA App
Our app will have two pages: the index and the question view page.
/index: This page will display all questions in the app./questions/:id: This page is a dynamic page. It will display the details of a specific question. The details displayed are the answers to the question and the comments are replies to the answers.
npx create-next-app@latestThen we complete some prompts:
npx create-next-app@latest qa-front
Need to install the following packages:
create-next-app@14.2.8
Ok to proceed? (y) y
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
Creating a new Next.js app in /Users/miracleio/Documents/writing/strapi/understanding-and-using-relations-in-strapi/qa-front.
Using npm.
Initializing project with template: app-tw
Installing dependencies:
- react
- react-dom
- next
Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next
npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm warn deprecated @humanwhocodes/config-array@0.11.14: Use @eslint/config-array instead
npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
npm warn deprecated @humanwhocodes/object-schema@2.0.3: Use @eslint/object-schema instead
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
added 368 packages, and audited 369 packages in 56s
139 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Initialized a git repository.
Success!Now, we move into the directory:
cd qa-frontWe will need the following dependencies:
axios: We will need this for making HTTP calls to our Strapi collection endpoints.quill: An editor we will use for answering questions in our app.
We will install axios:
yarn add axios
npm install quill axios react-quillSetting up Shadcn/UI
To speed up developmemnt we'll be leveraging shadcn/ui which is a collection of re-usable components that you can copy and paste into your apps.
npx shadcn@latest initYou will be asked a few questions to configure components.json:
Which style would you like to use? › New York
Which color would you like to use as base color? › Zinc
Do you want to use CSS variables for colors? › no / yesWe can now start adding components to your project.
npx shadcn@latest add drawer button input label avatar textarea sonnerComplete the prompts:
npx shadcn@latest add drawer button input label avatar textarea
✔ You need to create a component.json file to add components. Proceed? … yes
✔ Which style would you like to use? › New York
? Which color would you like to use as the base color? › - Use arrow-keys. Return to submit.
? Which color would you like to use as the base color? › - Use arrow-keys. Return to submit.
? Which color would you like to use as the base color? › - Use arrow-keys. Return to submit.
? Which color would you like to use as the base color? › - Use arrow-keys. Return to submit.
? Which color would you like to use as the base color? › - Use arrow-keys. Return to submit.
✔ Which color would you like to use as the base color? › Slate
✔ Would you like to use CSS variables for theming? … no / yes
✔ Writing components.json.
✔ Checking registry.
✔ Installing dependencies.
✔ Created 7 files:
- components/ui/drawer.tsx
- components/ui/button.tsx
- components/ui/input.tsx
- components/ui/label.tsx
- components/ui/avatar.tsx
- components/ui/textarea.tsx
- components/ui/sonner.tsxFetching Data
Let's create the utility functions and type definnitions that we need to interact with the Strapi API.
Strapi v5 changes
The Strapi v5 API response format has been refined a bit from v4. Noteably, there's no longer an attributes to nest the incoming data.
Strapi 5 now uses documents and documents are accessed by their documentId.
Here's a sample of the data:
1{
2 "data": [
3 {
4 "id": 2,
5 "documentId": "m2sdmnhihlkxolcvicrz4d4t",
6 "qText": "How does Vibranium compare to Stark tech in terms of strength and versatility?",
7 "user": "Happy Hogan",
8 "createdAt": "2024-09-09T16:46:40.624Z",
9 "updatedAt": "2024-09-09T16:46:40.624Z",
10 "publishedAt": "2024-09-09T16:46:41.040Z",
11 "locale": null
12 },
13 {
14 "id": 4,
15 "documentId": "a971959k5jbf7yqmxwgnrn02",
16 "qText": "Could Tony Stark’s AI, JARVIS, have evolved into something beyond just an assistant?",
17 "user": "Happy Hogan",
18 "createdAt": "2024-09-09T18:39:34.602Z",
19 "updatedAt": "2024-09-09T18:39:34.602Z",
20 "publishedAt": "2024-09-09T18:39:34.980Z",
21 "locale": null
22 }
23 ],
24 "meta": {
25 "pagination": {
26 "page": 1,
27 "pageSize": 25,
28 "pageCount": 1,
29 "total": 2
30 }
31 }
32}Now, we'll create the types for our data response.
Creating Types
Create a new file ./types/index.ts:
mkdir types
touch types/index.tsEnter the following:
1type Comment = {
2 id: number;
3 documentId: string;
4 cText: string;
5 createdAt: string;
6 updatedAt: string;
7 publishedAt: string;
8 locale: null;
9 user: string;
10 answer: Answer;
11};
12
13type CommentInput = {
14 cText: string;
15 user: string;
16 answer: string;
17};
18
19type Answer = {
20 id: number;
21 documentId: string;
22 aText: string;
23 user: string;
24 createdAt: string;
25 updatedAt: string;
26 publishedAt: string;
27 locale: null;
28 question?: Question | null;
29};
30
31type Question = {
32 id: number;
33 documentId: string;
34 qText: string;
35 user: string;
36 createdAt: string;
37 updatedAt: string;
38 publishedAt: string;
39 locale: null;
40 answers?: Answer[] | null;
41};
42
43type Meta = {
44 pagination: {
45 page: number;
46 pageSize: number;
47 pageCount: number;
48 total: number;
49 };
50};
51
52type AnswersResponse = {
53 data?: Answer[];
54 meta?: Meta;
55};
56
57type QuestionsResponse = {
58 data?: Question[];
59 meta?: Meta;
60};
61
62type CommentsResponse = {
63 data?: Comment[];
64 meta?: Meta;
65};
66
67type QuestionResponse = {
68 data?: Question;
69};
70
71type AnswerResponse = {
72 data?: Answer;
73};
74
75type CommentResponse = {
76 data?: Comment;
77};
78
79type ErrorResponse = {
80 error?: {
81 message: string;
82 };
83};
84
85export type {
86 Answer,
87 Question,
88 Comment,
89 CommentInput,
90 Meta,
91 QuestionsResponse,
92 QuestionResponse,
93 AnswersResponse,
94 AnswerResponse,
95 CommentsResponse,
96 CommentResponse,
97 ErrorResponse,
98};With these types we can confidently fetch data from our API and use it within our application.
Create Data fetching functions
Next, we'll create data fetching functions to GET and POST data.
Create a new file ./utils/index.ts:
mkdir utils
touch utils/index.tsEnter the following:
1// ./utils/index.ts
2
3import {
4 AnswerResponse,
5 AnswersResponse,
6 CommentInput,
7 CommentResponse,
8 CommentsResponse,
9 ErrorResponse,
10 QuestionResponse,
11 QuestionsResponse,
12} from "@/types";
13
14const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN;
15const API_URL = process.env.NEXT_PUBLIC_API_URL;
16
17/**
18 * Fetches a list of questions from the API, sorted by their last update time in ascending order.
19 *
20 * @returns {Promise<QuestionsResponse & ErrorResponse>} - A promise that resolves with the list of questions and any errors encountered.
21 */
22const getQuestions: () => Promise<
23 QuestionsResponse & ErrorResponse
24> = async (): Promise<QuestionsResponse & ErrorResponse> => {
25 try {
26 const res = await fetch(`${API_URL}/questions?sort[0]=updatedAt:asc`, {
27 headers: {
28 Authorization: `Bearer ${API_TOKEN}`,
29 },
30 cache: "no-store",
31 });
32 const data = await res.json();
33 console.log("🚀 ~ getQuestions ~ data", data);
34
35 return data;
36 } catch (error) {
37 console.log("🚨 ~ getQuestions", error);
38 return {
39 error: { message: "Unable to fetch questions" },
40 };
41 }
42};
43
44/**
45 * Creates a new question in the API.
46 *
47 * @param {Object} question - The question data.
48 * @param {string} question.qText - The text of the question.
49 * @param {string} question.user - The user ID of the person asking the question.
50 * @returns {Promise<QuestionResponse & ErrorResponse>} - A promise that resolves with the created question and any errors encountered.
51 */
52const createQuestion = async (question: {
53 qText: string;
54 user: string;
55}): Promise<QuestionResponse & ErrorResponse> => {
56 console.log("🚀 ~ createQuestion ~ question", question);
57
58 try {
59 const res = await fetch(`${API_URL}/questions`, {
60 method: "POST",
61 headers: {
62 "Content-Type": "application/json",
63 Authorization: `Bearer ${API_TOKEN}`,
64 },
65 body: JSON.stringify({
66 data: {
67 qText: question.qText,
68 user: question.user,
69 },
70 }),
71 });
72 const data = await res.json();
73 return data;
74 } catch (error) {
75 console.log("🚨 ~ createQuestion", error);
76 return {
77 error: { message: "Unable to create question" },
78 };
79 }
80};
81
82/**
83 * Fetches comments related to a specific answer from the API, sorted by their last update time in ascending order.
84 *
85 * @param {string} answer - The ID of the answer to fetch comments for.
86 * @returns {Promise<CommentsResponse & ErrorResponse>} - A promise that resolves with the list of comments and any errors encountered.
87 */
88const getComments = async (
89 answer: string,
90): Promise<CommentsResponse & ErrorResponse> => {
91 try {
92 const res = await fetch(
93 `${API_URL}/comments?populate=*&filters[answer][documentId]=${answer}&sort[0]=updatedAt:asc`,
94 {
95 headers: {
96 Authorization: `Bearer ${API_TOKEN}`,
97 },
98 cache: "no-store",
99 },
100 );
101 const data = await res.json();
102 if (data.error) {
103 throw new Error(data.error.message);
104 }
105 return data;
106 } catch (error) {
107 console.log("🚨 ~ getComments", error);
108 return {
109 error: { message: "Unable to fetch comments" },
110 };
111 }
112};
113
114/**
115 * Creates a new comment in the API.
116 *
117 * @param {CommentInput} comment - The comment data.
118 * @returns {Promise<CommentResponse & ErrorResponse>} - A promise that resolves with the created comment and any errors encountered.
119 */
120const createComment: (
121 comment: CommentInput,
122) => Promise<CommentResponse & ErrorResponse> = async ({
123 cText,
124 user,
125 answer,
126}: CommentInput): Promise<CommentResponse & ErrorResponse> => {
127 try {
128 const res = await fetch(`${API_URL}/comments`, {
129 method: "POST",
130 headers: {
131 "Content-Type": "application/json",
132 Authorization: `Bearer ${API_TOKEN}`,
133 },
134 body: JSON.stringify({ data: { cText, user, answer } }),
135 });
136 const data = await res.json();
137 if (data.error) {
138 throw new Error(data.error.message);
139 }
140 return data;
141 } catch (error) {
142 console.log("🚨 ~ createComment", error);
143 return {
144 error: { message: "Unable to create comment" },
145 };
146 }
147};
148
149/**
150 * Creates a new answer in the API.
151 *
152 * @param {Object} answer - The answer data.
153 * @param {string} answer.aText - The text of the answer.
154 * @param {string} answer.user - The user ID of the person providing the answer.
155 * @param {string} answer.questionId - The ID of the question that the answer is related to.
156 * @returns {Promise<AnswerResponse & ErrorResponse>} - A promise that resolves with the created answer and any errors encountered.
157 */
158const createAnswer = async (answer: {
159 aText: string;
160 user: string;
161 questionId: string;
162}): Promise<AnswerResponse & ErrorResponse> => {
163 console.log("🚀 ~ createAnswer ~ answer", answer);
164
165 try {
166 const res = await fetch(`${API_URL}/answers`, {
167 method: "POST",
168 headers: {
169 "Content-Type": "application/json",
170 Authorization: `Bearer ${API_TOKEN}`,
171 },
172 body: JSON.stringify({
173 data: {
174 aText: answer.aText,
175 user: answer.user,
176 question: answer.questionId,
177 },
178 }),
179 });
180 const data = await res.json();
181 return data;
182 } catch (error) {
183 console.log("🚨 ~ createAnswer", error);
184 return {
185 error: { message: "Unable to create answer" },
186 };
187 }
188};
189
190/**
191 * Fetches a specific question from the API by its ID.
192 *
193 * @param {string} id - The ID of the question to fetch.
194 * @returns {Promise<QuestionResponse & ErrorResponse>} - A promise that resolves with the question data and any errors encountered.
195 */
196const getQuestion: (
197 id: string,
198) => Promise<QuestionResponse & ErrorResponse> = async (id: string) => {
199 try {
200 const res = await fetch(`${API_URL}/questions/${id}?populate=*`, {
201 headers: {
202 Authorization: `Bearer ${API_TOKEN}`,
203 },
204 cache: "no-store",
205 });
206 const data = await res.json();
207 if (data.error) {
208 throw new Error(data.error.message);
209 }
210 return data;
211 } catch (error) {
212 console.log("🚨 ~ getQuestion", error);
213 return {
214 error: { message: "Unable to fetch question" },
215 };
216 }
217};
218
219/**
220 * Fetches a list of answers related to a specific question from the API, sorted by their creation time in ascending order.
221 *
222 * @param {string} question - The ID of the question to fetch answers for.
223 * @returns {Promise<AnswersResponse & ErrorResponse>} - A promise that resolves with the list of answers and any errors encountered.
224 */
225const getAnswers: (
226 question: string,
227) => Promise<AnswersResponse & ErrorResponse> = async (
228 question: string,
229): Promise<AnswersResponse & ErrorResponse> => {
230 try {
231 const res = await fetch(
232 `${API_URL}/answers?populate=*&filters[question][documentId]=${question}&sort[0]=createdAt:asc`,
233 {
234 headers: {
235 Authorization: `Bearer ${API_TOKEN}`,
236 },
237 cache: "no-store",
238 },
239 );
240 const data = await res.json();
241 if (data.error) {
242 throw new Error(data.error.message);
243 }
244 return data;
245 } catch (error) {
246 console.log("🚨 ~ getAnswers", error);
247 return {
248 error: { message: "Unable to fetch answers" },
249 };
250 }
251};
252
253export {
254 getQuestion,
255 getQuestions,
256 createQuestion,
257 getAnswers,
258 createAnswer,
259 getComments,
260 createComment,
261};This file defines several utility functions for interacting with an API that manages questions, answers, and comments. Here's a brief overview of each function:
getQuestions: Fetches a list of questions from the API, sorted by theupdatedAtfield in ascending order. The sort parameter is passed as a query string:1const res = await fetch(`${API_URL}/questions?sort[0]=updatedAt:asc`, {...});This ensures that the most recently updated questions are retrieved last.
createQuestion: Sends a POST request to create a new question. The question's text and user ID are included in the request body, which is sent as JSON.1body: JSON.stringify({ data: { qText: question.qText, user: question.user } }),getComments: Fetches comments related to a specific answer, filtering by the answer'sdocumentIdand sorting by theupdatedAtfield in ascending order. The filter and sort parameters are included in the query string:1const res = await fetch(`${API_URL}/comments?populate=*&filters[answer][documentId]=${answer}&sort[0]=updatedAt:asc`, {...});createComment: Sends a POST request to create a new comment. The comment's text, user ID, and related answer ID are included in the request body, similar tocreateQuestion.createAnswer: Sends a POST request to create a new answer for a specific question. The answer's text, user ID, and the ID of the question it belongs to are included in the request body.getQuestion: Fetches details for a specific question by its ID, using thepopulate=*query parameter to include related data:1const res = await fetch(`${API_URL}/questions/${id}?populate=*`, {...});getAnswers: Fetches a list of answers related to a specific question, filtering by thequestiondocument ID and sorting by thecreatedAtfield in ascending order:1const res = await fetch(`${API_URL}/answers?populate=*&filters[question][documentId]=${question}&sort[0]=createdAt:asc`, {...});
Each of these functions uses query parameters to filter and sort data returned by the API, ensuring that the correct data is retrieved in the desired order.
For example, the sort[0]=updatedAt:asc query parameter in getQuestions ensures that the list of questions is sorted by their update time, in ascending order. Similarly, the filters[answer][documentId]=${answer} parameter in getComments filters comments to only those related to a specific answer.
Note: The
populate=*query parameter allows us to fetch all fields relation, media and components fields
Creating components
Now, we can create the components for displaying and posting data.
Question Card
Let's create a component for displaying questions - ./components/Question/Card.tsx and enter the following:
1// ./components/Question/Card.tsx
2
3import { Button } from "@/components/ui/button";
4import { Question } from "@/types";
5import Link from "next/link";
6
7const QuestionCard: React.FC<{
8 question: Question;
9}> = ({ question }) => {
10 return (
11 <article
12 key={question.id}
13 className="rounded-none border border-stone-100 bg-stone-50 p-4 dark:border-stone-700 dark:bg-stone-800"
14 >
15 <h3 className="text-3xl font-semibold">{question.qText}</h3>
16 <p className="text-stone-600 dark:text-stone-400">
17 Asked by {question.user} on{" "}
18 {new Date(question.createdAt).toDateString()}
19 </p>
20 <Button variant="outline" className="mt-3" asChild>
21 <Link href={`/questions/${question.documentId}`}>View Question</Link>
22 </Button>
23 </article>
24 );
25};
26
27export default QuestionCard;This code defines a QuestionCard React component that displays a question's text, the user who asked it, and the date it was created. It includes a button that links to the detailed view of the question. The component uses Tailwind CSS classes for styling and receives a question object as a prop.
Question Form
Now, let's create the form component for creating new questions - ./components/Question/Form.tsxand enter the following:
1// ./components/Question/Form.tsx
2
3import { cn } from "@/lib/utils";
4import { Button } from "@/components/ui/button";
5import { Input } from "@/components/ui/input";
6import { Label } from "@/components/ui/label";
7import { Textarea } from "@/components/ui/textarea";
8import { toast } from "sonner";
9import { useState } from "react";
10import { useRouter } from "next/navigation";
11import { createQuestion } from "@/utils";
12
13const QuestionForm: React.FC<{
14 className?: string;
15}> = ({ className }) => {
16 const [question, setQuestion] = useState("");
17 const [name, setName] = useState("");
18 const [loading, setLoading] = useState(false);
19 const router = useRouter();
20
21 const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
22 e.preventDefault();
23 if (!question.trim() || !name.trim()) {
24 toast.error("Please fill in all fields");
25 return;
26 }
27 toast.promise(createQuestion({ qText: question, user: name }), {
28 loading: (() => {
29 setLoading(true);
30 return "Submitting question...";
31 })(),
32 success: (data) => {
33 console.log("🚀 ~ handleSubmit ~ data", data);
34
35 if (data.error) {
36 throw new Error(data.error.message);
37 }
38 setLoading(false);
39 setQuestion("");
40 setName("");
41 router.push(`/questions/${data.data?.documentId}`);
42
43 return "Question submitted successfully!";
44 },
45 error: (error) => {
46 setLoading(false);
47 console.log("🚨 ~ handleSubmit ~ error", error);
48
49 return "Failed to submit question";
50 },
51 });
52 };
53 return (
54 <form
55 onSubmit={handleSubmit}
56 className={cn("grid items-start gap-4", className)}
57 >
58 <div className="grid gap-2">
59 <Label htmlFor="name">Name</Label>
60 <Input
61 type="name"
62 id="name"
63 defaultValue="Happy Hogan"
64 onChange={(e) => setName(e.target.value)}
65 value={name}
66 />
67 </div>
68 <div className="grid gap-2">
69 <Label htmlFor="question">Your Question</Label>
70 <Textarea
71 id="question"
72 value={question}
73 onChange={(e) => setQuestion(e.target.value)}
74 />
75 </div>
76 <Button type="submit">
77 {loading ? "Submitting question..." : "Submit Question"}
78 </Button>
79 </form>
80 );
81};
82
83export default QuestionForm;The QuestionForm component allows users to submit a question. Here's a breakdown of its functionality:
- It uses
useStatehooks to manage the state of thequestion,name, andloadingfields. - The
handleSubmitfunction:- Prevents the default form action.
- Validates input fields.
- Uses
toast.promiseto display loading, success, and error messages during thecreateQuestionAPI call. - Resets the form and redirects the user to the newly created question's page using
router.pushif the submission is successful.
Question Drawer
The Question form will be displayed in a drawer when the user wants to ask a new question. Create a new file - ./components/Question/Drawer.tsx:
1// ./components/Question/Drawer.tsx
2
3"use client";
4
5import {
6 Drawer,
7 DrawerClose,
8 DrawerContent,
9 DrawerDescription,
10 DrawerFooter,
11 DrawerHeader,
12 DrawerTitle,
13 DrawerTrigger,
14} from "@/components/ui/drawer";
15import { Button } from "@/components/ui/button";
16import { useState } from "react";
17import QuestionForm from "@/components/Question/Form";
18
19const QuestionDrawer: React.FC = () => {
20 const [isOpen, setIsOpen] = useState(false);
21 return (
22 <Drawer open={isOpen} onOpenChange={setIsOpen}>
23 <DrawerTrigger asChild>
24 <Button variant="outline">Ask Question</Button>
25 </DrawerTrigger>
26 <DrawerContent>
27 <div className="wrapper mx-auto w-full max-w-3xl">
28 <DrawerHeader className="text-left">
29 <DrawerTitle>Ask Question</DrawerTitle>
30 <DrawerDescription>What would you like to ask?</DrawerDescription>
31 </DrawerHeader>
32 {/* Question Form */}
33 <QuestionForm className="px-4" />
34 <DrawerFooter className="pt-2">
35 <DrawerClose asChild>
36 <Button variant="outline">Cancel</Button>
37 </DrawerClose>
38 </DrawerFooter>
39 </div>
40 </DrawerContent>
41 </Drawer>
42 );
43};
44
45export default QuestionDrawer;Next, we'll create the components for Answers.
Answer Card
Create a new file - ./components/Answer/Card.tsx and enter the following:
1// ./components/Answer/Card.tsx
2
3"use client";
4
5import { Answer, Comment } from "@/types";
6import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
7import CommentForm from "@/components/Comment/Form";
8import { useState } from "react";
9import { Button } from "@/components/ui/button";
10import { toast } from "sonner";
11import CommentCard from "@/components/Comment/Card";
12import { getComments } from "@/utils";
13
14const AnswerCard: React.FC<{
15 answer: Answer;
16}> = ({ answer }) => {
17 const [showPostComment, setShowPostComment] = useState(false);
18 const [showComments, setShowComments] = useState(false);
19 const [comments, setComments] = useState<Comment[] | null>(null);
20 const [loading, setLoading] = useState(false);
21
22 const handleGetComments = async () => {
23 if (showComments) {
24 setShowComments(false);
25 return;
26 }
27 setLoading(true);
28 toast.promise(getComments(answer?.documentId), {
29 loading: (() => {
30 setLoading(true);
31 return "Fetching comments...";
32 })(),
33 success: (data) => {
34 if (data?.error) {
35 return data.error.message;
36 }
37 if (!data?.data?.length) {
38 return "No comments yet. Be the first!";
39 }
40 setComments(data?.data);
41 setLoading(false);
42 return "Comments fetched successfully";
43 },
44 error: (error) => {
45 setLoading(false);
46 console.log("🚨 ~ handleGetComments ~ error", error);
47 return "Unable to fetch comments";
48 },
49 finally: () => {
50 setLoading(false);
51 setShowComments(true);
52 },
53 });
54 };
55
56 return (
57 <article className="flex flex-col rounded-none border border-stone-100 bg-stone-50 dark:border-stone-900 dark:bg-stone-900">
58 <div className="user flex w-full items-center gap-2 border-b border-stone-200 p-4 dark:border-stone-800">
59 <Avatar>
60 <AvatarImage
61 src={`https://avatar.iran.liara.run/public/${Math.floor(
62 Math.random() * 10 + 1,
63 )}`}
64 alt={answer?.user}
65 />
66 <AvatarFallback>
67 {answer?.user
68 ?.split(" ")
69 .map((name) => name[0])
70 .join("")}
71 </AvatarFallback>
72 </Avatar>
73 <p className=" ">{answer?.user}</p> on{" "}
74 <p>{new Date(answer?.updatedAt).toDateString()}</p>
75 </div>
76 <div
77 className="p-4"
78 {...{
79 dangerouslySetInnerHTML: {
80 __html: answer?.aText,
81 },
82 }}
83 ></div>
84 <div className="flex flex-wrap gap-2 border-t border-stone-200 p-4 dark:border-stone-800">
85 {!showPostComment ? (
86 <Button
87 onClick={() => setShowPostComment(!showPostComment)}
88 variant={"outline"}
89 >
90 Post a comment
91 </Button>
92 ) : (
93 <div className="w-full">
94 <Button
95 onClick={() => setShowPostComment(!showPostComment)}
96 variant={"outline"}
97 className="mb-4"
98 >
99 Hide comment form
100 </Button>
101 <CommentForm answer={answer?.documentId} />
102 </div>
103 )}
104 <Button onClick={handleGetComments} variant={"outline"}>
105 {loading
106 ? "Fetching comments..."
107 : showComments
108 ? "Hide comments"
109 : "Show comments"}
110 </Button>
111 </div>
112 {showComments && (
113 <div className="border-t border-stone-200 dark:border-stone-800">
114 {comments?.length ? (
115 <ul className="flex flex-col gap-4">
116 {comments.map((comment, i) => (
117 <li
118 key={comment?.documentId}
119 className="border-t border-stone-200 first-of-type:border-t-0 dark:border-stone-800"
120 >
121 <CommentCard comment={comment} i={i} />
122 </li>
123 ))}
124 </ul>
125 ) : (
126 <p className="p-4">No comments yet. Be the first!</p>
127 )}
128 </div>
129 )}
130 </article>
131 );
132};
133
134export default AnswerCard;Here's how it works:
- State Management: It uses
useStatehooks to manage the visibility of the comment form (showPostComment), the visibility of comments (showComments), the list of comments (comments), and the loading state (loading). - User Interface: The component renders an article element that displays the answer, along with the user who posted it and the date. It uses an
Avatarcomponent to show the user's avatar or initials. - Comment Handling:
- When the "Post a comment" button is clicked, it toggles the visibility of the
CommentFormby updatingshowPostComment. - When the "Show comments" button is clicked, it triggers
handleGetComments, which fetches comments using thegetCommentsfunction and updates thecommentsstate. It also manages the loading state and displays a toast notification based on the success or failure of the operation.
- When the "Post a comment" button is clicked, it toggles the visibility of the
- Comment Display: If comments are fetched and
showCommentsis true, the component renders a list ofCommentCardcomponents. If there are no comments, it displays a message encouraging the user to be the first to comment.
This structure allows users to interact with an answer by viewing or posting comments in a dynamic and responsive way.
Next, we'll create the form for posting answers:
Answer Form
Create a new file - ./components/Answer/Form.tsx and enter the following:
1// ./components/Answer/Form.tsx
2
3"use client";
4import ReactQuill from "react-quill";
5import "react-quill/dist/quill.snow.css";
6import { useState } from "react";
7import { Input } from "@/components/ui/input";
8import { Button } from "@/components/ui/button";
9import { toast } from "sonner";
10import { useRouter } from "next/navigation";
11import { createAnswer } from "@/utils";
12
13const AnswerForm: React.FC<{ id?: string }> = ({ id }) => {
14 const router = useRouter();
15 const [value, setValue] = useState("");
16 const [name, setName] = useState("");
17 const [loading, setLoading] = useState(false);
18 console.log("🚀 ~ file: Form.tsx ~ line 6 ~ AnswerForm ~ value", value, id);
19
20 const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
21 e.preventDefault();
22
23 if (!id) return toast.error("Invalid question ID");
24 if (!value.trim() || !name.trim())
25 return toast.error("Please fill in all fields before submitting");
26 toast.promise(createAnswer({ aText: value, user: name, questionId: id }), {
27 loading: (() => {
28 setLoading(true);
29 return "Submitting answer...";
30 })(),
31 success: (data) => {
32 console.log("🚀 ~ handleSubmit ~ data", data);
33
34 if (data.error) {
35 throw new Error(data.error.message);
36 }
37 setLoading(false);
38 setName("");
39 setValue("");
40 router.refresh();
41 return "Answer submitted successfully!";
42 },
43 error: (error) => {
44 console.log("🚨 ~ handleSubmit ~ error", error);
45 setLoading(false);
46 return error.message;
47 },
48 });
49 };
50
51 return (
52 <>
53 <form onSubmit={handleSubmit} className="flex flex-col gap-2">
54 <ReactQuill
55 theme="snow"
56 value={value}
57 onChange={(content, delta, source, editor) =>
58 setValue(editor.getHTML())
59 }
60 placeholder="What's your answer?"
61 />
62 <Input
63 type="text"
64 placeholder="Your name"
65 value={name}
66 onChange={(e) => setName(e.target.value)}
67 />
68 <Button type="submit" variant="default" className="w-fit">
69 {loading ? "Submitting..." : "Submit Answer"}
70 </Button>
71 </form>
72 </>
73 );
74};
75
76export default AnswerForm;The AnswerForm component provides a user interface for submitting answers to questions:
- State Management: It manages
valuefor the answer content,namefor the user’s name, andloadingto indicate the submission status. - Submission Handling: On form submission, it validates the presence of required fields and uses
toast.promiseto handle thecreateAnswerfunction, showing appropriate feedback. - UI Elements: It includes a
ReactQuilleditor for rich-text answers, anInputfield for the user's name, and aButtonthat changes text based on the loading state.
The form clears fields and refreshes the page upon successful submission.
Next, we'll create the components for comments.
Comment Card
Create a new file - ./components/Comment/Card.tsx and enter the following:
1// ./components/Comment/Card.tsx
2
3"use client";
4
5import { Comment } from "@/types";
6import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
7
8const CommentCard: React.FC<{
9 comment: Comment;
10 i: number;
11}> = ({ comment, i }) => {
12 return (
13 <article className="flex flex-col gap-4 p-4">
14 <div className="flex items-center gap-2">
15 <Avatar>
16 <AvatarImage
17 src={`https://avatar.iran.liara.run/public/${i + 1}`}
18 alt={comment?.user}
19 />
20 <AvatarFallback>
21 {comment?.user
22 ?.split(" ")
23 .map((name) => name[0])
24 .join("")}
25 </AvatarFallback>
26 </Avatar>
27 <p className=" ">{comment?.user}</p>
28 </div>
29 <p className="text-sm">{comment?.cText}</p>
30 </article>
31 );
32};
33
34export default CommentCard;The CommentCard component displays individual comments with the following features:
- Avatar Display: Uses
Avatar,AvatarImage, andAvatarFallbackcomponents to show a user's avatar, which is fetched from a URL based on the comment index (i + 1). If the avatar is not available, it displays the user's initials. - Comment Content: Shows the commenter's name and the text of the comment (
comment.cText).
The component is styled with a flex layout for the avatar and text, ensuring a clean, organized appearance.
Comment Form
Create a new file - ./components/Comment/Form.tsx and enter the following:
1// ./components/Comment/Form.tsx
2
3"use client";
4
5import { Textarea } from "@/components/ui/textarea";
6import { Input } from "@/components/ui/input";
7import { Button } from "@/components/ui/button";
8import { toast } from "sonner";
9import { useState } from "react";
10import { useRouter } from "next/navigation";
11import { createComment } from "@/utils";
12
13const CommentForm: React.FC<{
14 answer: string;
15}> = ({ answer }) => {
16 const router = useRouter();
17 const [comment, setComment] = useState("");
18 const [user, setUser] = useState("");
19 const [loading, setLoading] = useState(false);
20
21 const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
22 e.preventDefault();
23 if (!comment.trim() || !user.trim()) {
24 toast.error("Please fill in all fields");
25 return;
26 }
27 toast.promise(createComment({ cText: comment, user, answer }), {
28 loading: (() => {
29 setLoading(true);
30 return "Posting comment...";
31 })(),
32 success: (data) => {
33 console.log("🚀 ~ data", data);
34 if (data.error) {
35 throw new Error(data.error.message);
36 }
37
38 setLoading(false);
39 setComment("");
40 setUser("");
41 router.refresh();
42 return "Comment posted!";
43 },
44 error: (error) => {
45 setLoading(false);
46 return error.message;
47 },
48 });
49 };
50
51 return (
52 <form onSubmit={handleSubmit} className="flex flex-col gap-2">
53 <Textarea
54 className="bg-white dark:bg-stone-800"
55 name="comment"
56 placeholder="Type your comment here..."
57 value={comment}
58 onChange={(e) => setComment(e.target.value)}
59 />
60 <Input
61 className="bg-white dark:bg-stone-800"
62 type="text"
63 name="name"
64 placeholder="Your name"
65 value={user}
66 onChange={(e) => setUser(e.target.value)}
67 />
68 <Button type="submit" variant={"default"}>
69 {loading ? "Posting comment..." : "Post comment"}
70 </Button>
71 </form>
72 );
73};
74
75export default CommentForm;The CommentForm component allows users to submit comments. Here's a brief breakdown:
- Form Elements:
- A
Textareafor the comment text. - An
Inputfield for the user's name. - A
Buttonto submit the form.
- A
- State Management:
useStatemanages the comment text, user name, and loading state.
- Form Submission:
- On form submission,
handleSubmitvalidates the inputs. - Uses the
toast.promisefunction to handle the comment creation process and calls thecreateCommentfunction, providing feedback on the submission status (loading, success, or error). - After a successful submission, it resets the form and refreshes the page using
router.refresh().
- On form submission,
This component provides a user-friendly interface for submitting comments.
Site Header Component
Next, we will create a SiteHeader component, this component will render our header so it appears in our app.
Run the below command to generate the Header files:
mkdir components/Site
touch components/Site/Header.tsxNow, we open the components/Site/Header.tsx and paste the below code to it:
1// ./components/Site/Header.tsx
2
3import Link from "next/link";
4
5const SiteHeader = () => {
6 return (
7 <header className="sticky top-0 z-10 w-full bg-red-700 p-4 text-red-50">
8 <div className="wrapper mx-auto max-w-3xl">
9 <Link href="/">
10 <figure className="site-logo font-heading text-2xl font-black uppercase">
11 The Q&A Times
12 </figure>
13 </Link>
14 </div>
15 </header>
16 );
17};
18
19export default SiteHeader;This component just renders the text The Q&A Times in the header section of our app.
Modifying the Layout
To make the component appear application-wide in our app we will go the the layout.tsx component in ./app/layout.tsx file and render the component.
1// ./app/layout.tsx
2
3import type { Metadata, Viewport } from "next";
4import "./globals.css";
5import SiteHeader from "@/components/Site/Header";
6import { Toaster } from "@/components/ui/sonner";
7
8const APP_NAME = "Q&A Times";
9const APP_DEFAULT_TITLE = "The Q&A Times";
10const APP_TITLE_TEMPLATE = "%s - Q&A Times";
11const APP_DESCRIPTION =
12 "Feel free to ask any question and get answers from the community";
13const APP_URL = process.env.APP_URL || "https://qa-times.netlify.app";
14
15export const metadata: Metadata = {
16 applicationName: APP_NAME,
17 title: {
18 default: APP_DEFAULT_TITLE,
19 template: APP_TITLE_TEMPLATE,
20 },
21 description: APP_DESCRIPTION,
22 manifest: "/manifest.json",
23 appleWebApp: {
24 capable: true,
25 statusBarStyle: "default",
26 title: APP_DEFAULT_TITLE,
27 // startUpImage: [],
28 },
29 formatDetection: {
30 telephone: false,
31 },
32 openGraph: {
33 type: "website",
34 siteName: APP_NAME,
35 title: {
36 default: APP_DEFAULT_TITLE,
37 template: APP_TITLE_TEMPLATE,
38 },
39 description: APP_DESCRIPTION,
40 images: [
41 {
42 url: `${APP_URL}/images/qa-cover.png`,
43 width: 1200,
44 height: 630,
45 alt: APP_DEFAULT_TITLE,
46 },
47 ],
48 },
49 twitter: {
50 card: "summary",
51 title: {
52 default: APP_DEFAULT_TITLE,
53 template: APP_TITLE_TEMPLATE,
54 },
55 description: APP_DESCRIPTION,
56 images: [
57 {
58 url: `${APP_URL}/images/qa-cover.png`,
59 width: 1200,
60 height: 630,
61 alt: APP_DEFAULT_TITLE,
62 },
63 ],
64 },
65};
66
67export const viewport: Viewport = {
68 themeColor: "#ffffff",
69};
70export default function RootLayout({
71 children,
72}: Readonly<{
73 children: React.ReactNode;
74}>) {
75 return (
76 <html lang="en">
77 <body>
78 <SiteHeader />
79 {children}
80 <Toaster richColors position="top-center" theme="system" />
81 </body>
82 </html>
83 );
84}Here, we also defined the Metadata for our application and imported the <SiteHeader/>component.
With this, our SiteHeader component will be rendered on all pages in our application.
Creating Pages
Let's create our page components.
Home Page
The ./app/page.tsx page will be loaded when the index route / is navigated to.
This is our home page and will show questions and allow users to create new questions.
So, open the ./app/page.tsx file and paste the below code to it:
1 // ./app/page.tsx
2
3import QuestionCard from "@/components/Question/Card";
4import QuestionDrawer from "@/components/Question/Drawer";
5import { getQuestions } from "@/utils";
6
7export default async function Home() {
8 const questions = await getQuestions();
9 return (
10 <main>
11 <header className="bg-stone-50 px-4 py-12 dark:bg-stone-900 lg:px-6">
12 <div className="wrapper mx-auto max-w-3xl">
13 <h1 className="mb-2 text-6xl font-black leading-tight">
14 Start asking questions
15 </h1>
16 <QuestionDrawer />
17 </div>
18 </header>
19 <section className="site-section p-4 lg:px-6">
20 <div className="wrapper mx-auto max-w-3xl">
21 <header className="section-header mb-4">
22 <h2 className="section-title text-xl font-semibold">Questions</h2>
23 </header>
24 {questions?.data?.length ? (
25 <ul className="grid gap-4">
26 {questions.data.map((question) => (
27 <li key={question?.documentId}>
28 <QuestionCard question={question} />
29 </li>
30 ))}
31 </ul>
32 ) : (
33 <p>No questions found yet. Be the first to ask!</p>
34 )}
35 </div>
36 </section>
37 </main>
38 );
39}Here, the getQuestions function is called to asynchronously fetch a list of questions. The page is divided into two main sections:
1. Header:
- Contains a title ("Start asking questions") and a QuestionDrawer component, likely a form or UI element to add new questions.
2. Questions Section:
- Displays the fetched questions using the QuestionCard component.
- If there are no questions, it shows a message encouraging users to ask the first question.
Now, if the questions array is populated, it renders a list of QuestionCard components. If not, it displays a fallback message.
So, with that, we should have something like this:
Create Dynamic Question Page
In order to display the question and its answers we'll need to create a dynamic page using dynamic routes which will fetch questions by the ID and display it along with answers.
Create a new file - ./app/questions/[id]/page.tsxand enter the following:
1// ./questions/[id]/page.tsx
2
3import AnswerCard from "@/components/Answer/Card";
4import AnswerForm from "@/components/Answer/Form";
5import { getAnswers, getQuestion } from "@/utils";
6import Link from "next/link";
7
8const QuestionPage = async ({
9 params,
10}: {
11 params: {
12 id: string;
13 };
14}) => {
15 // get the question id from the path
16 const id = params.id;
17 // fetch the question and answers
18 const question = await getQuestion(id as string);
19 const answers = await getAnswers(id as string);
20 return (
21 <main>
22 {id && question.data ? (
23 <>
24 <header className="bg-stone-50 px-4 py-12 dark:bg-stone-900 lg:px-6">
25 <div className="wrapper mx-auto max-w-3xl">
26 <h1 className="mb-2 text-4xl font-black leading-tight">
27 {question.data.qText}
28 </h1>
29 <p>
30 Asked by {question.data.user} on{" "}
31 {new Date(question.data.createdAt).toDateString()}
32 </p>
33 </div>
34 </header>
35 <section className="site-section bg-sla px-4 py-12 lg:px-6">
36 <div className="wrapper mx-auto max-w-3xl">
37 <AnswerForm id={id} />
38 </div>
39 </section>
40 <section className="site-section px-4 py-12 lg:px-6">
41 <div className="wrapper mx-auto max-w-3xl">
42 <header className="section-header mb-8">
43 <h2 className="text-2xl">Answers</h2>
44 </header>
45 <ul className="flex flex-col gap-4">
46 {answers?.data?.length ? (
47 answers?.data?.map((answer) => (
48 <li className="" key={answer?.documentId}>
49 <AnswerCard answer={answer} />
50 </li>
51 ))
52 ) : (
53 <p>No answers yet. Be the first!</p>
54 )}
55 </ul>
56 </div>
57 </section>
58 </>
59 ) : (
60 <header className="bg-stone-50 px-4 py-12 dark:bg-stone-900 lg:px-6">
61 <div className="wrapper mx-auto max-w-3xl">
62 <h1 className="mb-2 text-4xl font-black leading-tight">
63 Oops! Question not found
64 </h1>
65 <Link className="underline" href="/">
66 Maybe you'd like to ask a question?
67 </Link>
68 </div>
69 </header>
70 )}
71 </main>
72 );
73};
74
75export default QuestionPage;This code sets up a dynamic page that shows a specific question and its answers based on the id in the URL. The id is pulled from the URL using params.id, and then it’s used to fetch the question and its related answers with getQuestion and getAnswers. If the question is found, the page displays the question details, an answer form, and a list of answers using the AnswerForm and AnswerCard components. If the question isn’t found, it shows a simple message saying the question wasn’t found and includes a link back to the homepage.
Test the App
Add new question:
New Question Added
View questions
Answer a question:
Submitted Answer
Comment on an answer:
Submitted Comment on an answer
Source Code and live preview
Find the source code of the project below:
- Live Preview - Frontend - https://qa-times.netlify.app
- Live Preview - Backend
- Front-end Code on GitHub
- Back-end Code on GitHub
Troubleshooting Relations
Encountering issues with relations is common. Here are some tips to identify and resolve them.
Common Issues and Solutions
Relations Not Linking Properly:
- Verify the relation configurations in the Content-Type Builder.
- Ensure that the
populateparameter is used correctly when fetching data.
Null Values in Related Data:
- Check that related records exist.
- Confirm that the correct IDs are used when connecting relations.
Performance Issues:
- Optimize queries by fetching only necessary fields.
- Use pagination to limit the amount of data retrieved.
Debugging Approaches
- Inspect API Responses: Use tools like Postman or your browser's developer tools to examine responses.
- Enable Detailed Logging: Adjust logging levels in Strapi to get more information.
- Test Queries in Isolation: Use the GraphQL Playground or REST client to test queries separately.
Error Handling Best Practices
Server-Side:
- Implement error handling middleware.
- Validate inputs and handle exceptions gracefully.
Client-Side:
- Provide clear error messages to users.
- Implement retry logic or fallback options when appropriate.
For more detailed information, consult the official Strapi documentation on relations and error handling.
Conclusion
Mastering relations in Strapi is essential for building dynamic and scalable applications. By understanding the different types of relations and how to implement them, you can create a robust content architecture that mirrors real-world connections. Remember to follow best practices, optimize performance, and handle errors gracefully to ensure your application is efficient and maintainable.
Looking to explore more about Strapi? Check out our articles on building custom controllers in Strapi and implementing authentication in Strapi.