Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi@latest
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.
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:
blog
GET: This will get all the blog entries from the endpoint.blog
POST: This will create a new blog post from the endpoint.blog/:documentId
GET: This will return the blog post with the document ID :documentId
.blog/:documentId
DELETE: This will delete the blog post with the document ID :documentId
from 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.
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:
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.
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 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.
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.
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.
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.
Relationship links can be set in the Admin panel and manually from the generated Strapi project.
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.
has one
relation.It establishes a one-way
relation between content types in Strapi.
has one and belongs to one
.It links two content types in a one-to-one way
relationship.
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.
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.
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.
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.
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.
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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"kind": "collectionType",
"collectionName": "students",
"info": {
"singularName": "student",
"pluralName": "students",
"displayName": "Student",
"description": ""
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
// The fields are configured here
"attributes": {
"name": {
"type": "string"
},
"student_info": { //field name
"type": "relation", // field type
"relation": "oneToOne", // relation type
"target": "api::student-info.student-info", // the target of the relation
"inversedBy": "student" // more info here - https://docs.strapi.io/developer-docs/latest/development/backend-customization/models.html#relations
}
}
}
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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"kind": "collectionType",
"collectionName": "student_infos",
"info": {
"singularName": "student-info",
"pluralName": "student-infos",
"displayName": "studentInfo"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"bio": {
"type": "text"
},
"student": {
"type": "relation",
"relation": "oneToOne",
"target": "api::student.student",
"inversedBy": "student_info"
}
}
}
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.
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
4
5
6
7
8
9
10
11
12
13
14
{
...
"attributes": {
"name": {
"type": "string"
},
"employees": {
"type": "relation",
"relation": "oneToMany",
"target": "api::employee.employee",
"mappedBy": "company"
}
}
}
Also, in ./src/api/employee/content-types/employee/schema.json
file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
...
"attributes": {
"name": {
"type": "string"
},
"company": {
"type": "relation",
"relation": "manyToOne",
"target": "api::company.company",
"inversedBy": "employees"
}
}
}
This sets a one-to-many relationship in the Company
model.
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
4
5
6
7
8
9
10
11
12
13
14
{
...
"attributes": {
"name": {
"type": "string"
},
"hospitals": {
"type": "relation",
"relation": "manyToMany",
"target": "api::hospital.hospital",
"inversedBy": "doctors"
}
}
}
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
4
5
6
7
8
9
10
11
12
13
14
{
...
"attributes": {
"name": {
"type": "string"
},
"doctors": {
"type": "relation",
"relation": "manyToMany",
"target": "api::doctor.doctor",
"inversedBy": "hospitals"
}
}
}
This effectively sets a many-to-many relation between the Doctor and Hospital models.
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
4
5
6
7
8
9
10
11
12
13
{
...
"attributes": {
"name": {
"type": "string"
},
"details": {
"type": "relation",
"relation": "oneToOne",
"target": "api::detail.detail"
}
}
}
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.
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 Car
s. 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
4
5
6
7
8
9
10
11
12
13
{
...
"attributes": {
"name": {
"type": "string"
},
"cars": {
"type": "relation",
"relation": "oneToMany",
"target": "api::car.car"
}
}
}
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.
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-strapi
We move into the folder:
cd relations
cd understanding-and-using-relations-in-strapi
Create the Strapi project:
npx create-strapi@latest
The 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 npm
The 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 develop
Strapi 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.
We will have three models for our Q&A app. We will have Question
, Answer
and Comment
.
Our Question
model will be this:
1
2
3
4
Question {
qText
user
}
qText
: This will hold the question.user
: This holds the name of the user.The Answer
model will be this:
1
2
3
4
5
Answer {
aText
question
user
}
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:
1
2
3
4
5
Comment {
cText
answer
user
}
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.
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.
question
in the Display name
field.qText
in the Name
field.Long Text
in the below radio button.+ Add another field
.text
.user
.Finish
.Save
button on the top-right of the page.Next, we will create the Answer
collection
+ Create new collection type
link, a modal will show up, type in answer
. Click on the + Add another field
button.text
and type in aText
.Long Text
+ Add another field
text
and type in user
.relation
field.Question
.Question
collection and the Answer
collection.Finish
button.Save
button on the top-right of the page.The Comment
model and the Answer
model have a one-to-one relationship. A comment has one answer.
We will create the Comment collection.
+ Create new collection type
link, a modal will show up, type in comment
.+ Add another field
button.text
field.cText
and click on the + Add another field
button.relation
field.Answer
.Comment
and the Answer
but not from Answer
to comment. So, the comments
field will not appear on the Answer
response.Finish
button.Save
button 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
,delete
andupdate
endpoints
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@latest
Then 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-front
We 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-quill
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 init
You 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 / yes
We can now start adding components to your project.
npx shadcn@latest add drawer button input label avatar textarea sonner
Complete 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.tsx
Let's create the utility functions and type definnitions that we need to interact with the Strapi API.
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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"data": [
{
"id": 2,
"documentId": "m2sdmnhihlkxolcvicrz4d4t",
"qText": "How does Vibranium compare to Stark tech in terms of strength and versatility?",
"user": "Happy Hogan",
"createdAt": "2024-09-09T16:46:40.624Z",
"updatedAt": "2024-09-09T16:46:40.624Z",
"publishedAt": "2024-09-09T16:46:41.040Z",
"locale": null
},
{
"id": 4,
"documentId": "a971959k5jbf7yqmxwgnrn02",
"qText": "Could Tony Stark’s AI, JARVIS, have evolved into something beyond just an assistant?",
"user": "Happy Hogan",
"createdAt": "2024-09-09T18:39:34.602Z",
"updatedAt": "2024-09-09T18:39:34.602Z",
"publishedAt": "2024-09-09T18:39:34.980Z",
"locale": null
}
],
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 2
}
}
}
Now, we'll create the types for our data response.
Create a new file ./types/index.ts
:
mkdir types
touch types/index.ts
Enter the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
type Comment = {
id: number;
documentId: string;
cText: string;
createdAt: string;
updatedAt: string;
publishedAt: string;
locale: null;
user: string;
answer: Answer;
};
type CommentInput = {
cText: string;
user: string;
answer: string;
};
type Answer = {
id: number;
documentId: string;
aText: string;
user: string;
createdAt: string;
updatedAt: string;
publishedAt: string;
locale: null;
question?: Question | null;
};
type Question = {
id: number;
documentId: string;
qText: string;
user: string;
createdAt: string;
updatedAt: string;
publishedAt: string;
locale: null;
answers?: Answer[] | null;
};
type Meta = {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
type AnswersResponse = {
data?: Answer[];
meta?: Meta;
};
type QuestionsResponse = {
data?: Question[];
meta?: Meta;
};
type CommentsResponse = {
data?: Comment[];
meta?: Meta;
};
type QuestionResponse = {
data?: Question;
};
type AnswerResponse = {
data?: Answer;
};
type CommentResponse = {
data?: Comment;
};
type ErrorResponse = {
error?: {
message: string;
};
};
export type {
Answer,
Question,
Comment,
CommentInput,
Meta,
QuestionsResponse,
QuestionResponse,
AnswersResponse,
AnswerResponse,
CommentsResponse,
CommentResponse,
ErrorResponse,
};
With these types we can confidently fetch data from our API and use it within our application.
Next, we'll create data fetching functions to GET and POST data.
Create a new file ./utils/index.ts
:
mkdir utils
touch utils/index.ts
Enter the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
// ./utils/index.ts
import {
AnswerResponse,
AnswersResponse,
CommentInput,
CommentResponse,
CommentsResponse,
ErrorResponse,
QuestionResponse,
QuestionsResponse,
} from "@/types";
const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN;
const API_URL = process.env.NEXT_PUBLIC_API_URL;
/**
* Fetches a list of questions from the API, sorted by their last update time in ascending order.
*
* @returns {Promise<QuestionsResponse & ErrorResponse>} - A promise that resolves with the list of questions and any errors encountered.
*/
const getQuestions: () => Promise<
QuestionsResponse & ErrorResponse
> = async (): Promise<QuestionsResponse & ErrorResponse> => {
try {
const res = await fetch(`${API_URL}/questions?sort[0]=updatedAt:asc`, {
headers: {
Authorization: `Bearer ${API_TOKEN}`,
},
cache: "no-store",
});
const data = await res.json();
console.log("🚀 ~ getQuestions ~ data", data);
return data;
} catch (error) {
console.log("🚨 ~ getQuestions", error);
return {
error: { message: "Unable to fetch questions" },
};
}
};
/**
* Creates a new question in the API.
*
* @param {Object} question - The question data.
* @param {string} question.qText - The text of the question.
* @param {string} question.user - The user ID of the person asking the question.
* @returns {Promise<QuestionResponse & ErrorResponse>} - A promise that resolves with the created question and any errors encountered.
*/
const createQuestion = async (question: {
qText: string;
user: string;
}): Promise<QuestionResponse & ErrorResponse> => {
console.log("🚀 ~ createQuestion ~ question", question);
try {
const res = await fetch(`${API_URL}/questions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_TOKEN}`,
},
body: JSON.stringify({
data: {
qText: question.qText,
user: question.user,
},
}),
});
const data = await res.json();
return data;
} catch (error) {
console.log("🚨 ~ createQuestion", error);
return {
error: { message: "Unable to create question" },
};
}
};
/**
* Fetches comments related to a specific answer from the API, sorted by their last update time in ascending order.
*
* @param {string} answer - The ID of the answer to fetch comments for.
* @returns {Promise<CommentsResponse & ErrorResponse>} - A promise that resolves with the list of comments and any errors encountered.
*/
const getComments = async (
answer: string,
): Promise<CommentsResponse & ErrorResponse> => {
try {
const res = await fetch(
`${API_URL}/comments?populate=*&filters[answer][documentId]=${answer}&sort[0]=updatedAt:asc`,
{
headers: {
Authorization: `Bearer ${API_TOKEN}`,
},
cache: "no-store",
},
);
const data = await res.json();
if (data.error) {
throw new Error(data.error.message);
}
return data;
} catch (error) {
console.log("🚨 ~ getComments", error);
return {
error: { message: "Unable to fetch comments" },
};
}
};
/**
* Creates a new comment in the API.
*
* @param {CommentInput} comment - The comment data.
* @returns {Promise<CommentResponse & ErrorResponse>} - A promise that resolves with the created comment and any errors encountered.
*/
const createComment: (
comment: CommentInput,
) => Promise<CommentResponse & ErrorResponse> = async ({
cText,
user,
answer,
}: CommentInput): Promise<CommentResponse & ErrorResponse> => {
try {
const res = await fetch(`${API_URL}/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_TOKEN}`,
},
body: JSON.stringify({ data: { cText, user, answer } }),
});
const data = await res.json();
if (data.error) {
throw new Error(data.error.message);
}
return data;
} catch (error) {
console.log("🚨 ~ createComment", error);
return {
error: { message: "Unable to create comment" },
};
}
};
/**
* Creates a new answer in the API.
*
* @param {Object} answer - The answer data.
* @param {string} answer.aText - The text of the answer.
* @param {string} answer.user - The user ID of the person providing the answer.
* @param {string} answer.questionId - The ID of the question that the answer is related to.
* @returns {Promise<AnswerResponse & ErrorResponse>} - A promise that resolves with the created answer and any errors encountered.
*/
const createAnswer = async (answer: {
aText: string;
user: string;
questionId: string;
}): Promise<AnswerResponse & ErrorResponse> => {
console.log("🚀 ~ createAnswer ~ answer", answer);
try {
const res = await fetch(`${API_URL}/answers`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_TOKEN}`,
},
body: JSON.stringify({
data: {
aText: answer.aText,
user: answer.user,
question: answer.questionId,
},
}),
});
const data = await res.json();
return data;
} catch (error) {
console.log("🚨 ~ createAnswer", error);
return {
error: { message: "Unable to create answer" },
};
}
};
/**
* Fetches a specific question from the API by its ID.
*
* @param {string} id - The ID of the question to fetch.
* @returns {Promise<QuestionResponse & ErrorResponse>} - A promise that resolves with the question data and any errors encountered.
*/
const getQuestion: (
id: string,
) => Promise<QuestionResponse & ErrorResponse> = async (id: string) => {
try {
const res = await fetch(`${API_URL}/questions/${id}?populate=*`, {
headers: {
Authorization: `Bearer ${API_TOKEN}`,
},
cache: "no-store",
});
const data = await res.json();
if (data.error) {
throw new Error(data.error.message);
}
return data;
} catch (error) {
console.log("🚨 ~ getQuestion", error);
return {
error: { message: "Unable to fetch question" },
};
}
};
/**
* Fetches a list of answers related to a specific question from the API, sorted by their creation time in ascending order.
*
* @param {string} question - The ID of the question to fetch answers for.
* @returns {Promise<AnswersResponse & ErrorResponse>} - A promise that resolves with the list of answers and any errors encountered.
*/
const getAnswers: (
question: string,
) => Promise<AnswersResponse & ErrorResponse> = async (
question: string,
): Promise<AnswersResponse & ErrorResponse> => {
try {
const res = await fetch(
`${API_URL}/answers?populate=*&filters[question][documentId]=${question}&sort[0]=createdAt:asc`,
{
headers: {
Authorization: `Bearer ${API_TOKEN}`,
},
cache: "no-store",
},
);
const data = await res.json();
if (data.error) {
throw new Error(data.error.message);
}
return data;
} catch (error) {
console.log("🚨 ~ getAnswers", error);
return {
error: { message: "Unable to fetch answers" },
};
}
};
export {
getQuestion,
getQuestions,
createQuestion,
getAnswers,
createAnswer,
getComments,
createComment,
};
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 the updatedAt
field in ascending order. The sort parameter is passed as a query string:
1
const 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.
1
body: JSON.stringify({ data: { qText: question.qText, user: question.user } }),
getComments
: Fetches comments related to a specific answer, filtering by the answer's documentId
and sorting by the updatedAt
field in ascending order. The filter and sort parameters are included in the query string:
1
const 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 to createQuestion
.
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 the populate=*
query parameter to include related data:
1
const res = await fetch(`${API_URL}/questions/${id}?populate=*`, {...});
getAnswers
: Fetches a list of answers related to a specific question, filtering by the question
document ID and sorting by the createdAt
field in ascending order:
1
const 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
Now, we can create the components for displaying and posting data.
Let's create a component for displaying questions - ./components/Question/Card.tsx
and enter the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ./components/Question/Card.tsx
import { Button } from "@/components/ui/button";
import { Question } from "@/types";
import Link from "next/link";
const QuestionCard: React.FC<{
question: Question;
}> = ({ question }) => {
return (
<article
key={question.id}
className="rounded-none border border-stone-100 bg-stone-50 p-4 dark:border-stone-700 dark:bg-stone-800"
>
<h3 className="text-3xl font-semibold">{question.qText}</h3>
<p className="text-stone-600 dark:text-stone-400">
Asked by {question.user} on{" "}
{new Date(question.createdAt).toDateString()}
</p>
<Button variant="outline" className="mt-3" asChild>
<Link href={`/questions/${question.documentId}`}>View Question</Link>
</Button>
</article>
);
};
export 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.
Now, let's create the form component for creating new questions - ./components/Question/Form.tsx
and enter the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// ./components/Question/Form.tsx
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createQuestion } from "@/utils";
const QuestionForm: React.FC<{
className?: string;
}> = ({ className }) => {
const [question, setQuestion] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!question.trim() || !name.trim()) {
toast.error("Please fill in all fields");
return;
}
toast.promise(createQuestion({ qText: question, user: name }), {
loading: (() => {
setLoading(true);
return "Submitting question...";
})(),
success: (data) => {
console.log("🚀 ~ handleSubmit ~ data", data);
if (data.error) {
throw new Error(data.error.message);
}
setLoading(false);
setQuestion("");
setName("");
router.push(`/questions/${data.data?.documentId}`);
return "Question submitted successfully!";
},
error: (error) => {
setLoading(false);
console.log("🚨 ~ handleSubmit ~ error", error);
return "Failed to submit question";
},
});
};
return (
<form
onSubmit={handleSubmit}
className={cn("grid items-start gap-4", className)}
>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
type="name"
id="name"
defaultValue="Happy Hogan"
onChange={(e) => setName(e.target.value)}
value={name}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="question">Your Question</Label>
<Textarea
id="question"
value={question}
onChange={(e) => setQuestion(e.target.value)}
/>
</div>
<Button type="submit">
{loading ? "Submitting question..." : "Submit Question"}
</Button>
</form>
);
};
export default QuestionForm;
The QuestionForm
component allows users to submit a question. Here's a breakdown of its functionality:
useState
hooks to manage the state of the question
, name
, and loading
fields.handleSubmit
function:toast.promise
to display loading, success, and error messages during the createQuestion
API call.router.push
if the submission is successful.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// ./components/Question/Drawer.tsx
"use client";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import QuestionForm from "@/components/Question/Form";
const QuestionDrawer: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerTrigger asChild>
<Button variant="outline">Ask Question</Button>
</DrawerTrigger>
<DrawerContent>
<div className="wrapper mx-auto w-full max-w-3xl">
<DrawerHeader className="text-left">
<DrawerTitle>Ask Question</DrawerTitle>
<DrawerDescription>What would you like to ask?</DrawerDescription>
</DrawerHeader>
{/* Question Form */}
<QuestionForm className="px-4" />
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
};
export default QuestionDrawer;
Next, we'll create the components for Answers.
Create a new file - ./components/Answer/Card.tsx
and enter the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
// ./components/Answer/Card.tsx
"use client";
import { Answer, Comment } from "@/types";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import CommentForm from "@/components/Comment/Form";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import CommentCard from "@/components/Comment/Card";
import { getComments } from "@/utils";
const AnswerCard: React.FC<{
answer: Answer;
}> = ({ answer }) => {
const [showPostComment, setShowPostComment] = useState(false);
const [showComments, setShowComments] = useState(false);
const [comments, setComments] = useState<Comment[] | null>(null);
const [loading, setLoading] = useState(false);
const handleGetComments = async () => {
if (showComments) {
setShowComments(false);
return;
}
setLoading(true);
toast.promise(getComments(answer?.documentId), {
loading: (() => {
setLoading(true);
return "Fetching comments...";
})(),
success: (data) => {
if (data?.error) {
return data.error.message;
}
if (!data?.data?.length) {
return "No comments yet. Be the first!";
}
setComments(data?.data);
setLoading(false);
return "Comments fetched successfully";
},
error: (error) => {
setLoading(false);
console.log("🚨 ~ handleGetComments ~ error", error);
return "Unable to fetch comments";
},
finally: () => {
setLoading(false);
setShowComments(true);
},
});
};
return (
<article className="flex flex-col rounded-none border border-stone-100 bg-stone-50 dark:border-stone-900 dark:bg-stone-900">
<div className="user flex w-full items-center gap-2 border-b border-stone-200 p-4 dark:border-stone-800">
<Avatar>
<AvatarImage
src={`https://avatar.iran.liara.run/public/${Math.floor(
Math.random() * 10 + 1,
)}`}
alt={answer?.user}
/>
<AvatarFallback>
{answer?.user
?.split(" ")
.map((name) => name[0])
.join("")}
</AvatarFallback>
</Avatar>
<p className=" ">{answer?.user}</p> on{" "}
<p>{new Date(answer?.updatedAt).toDateString()}</p>
</div>
<div
className="p-4"
{...{
dangerouslySetInnerHTML: {
__html: answer?.aText,
},
}}
></div>
<div className="flex flex-wrap gap-2 border-t border-stone-200 p-4 dark:border-stone-800">
{!showPostComment ? (
<Button
onClick={() => setShowPostComment(!showPostComment)}
variant={"outline"}
>
Post a comment
</Button>
) : (
<div className="w-full">
<Button
onClick={() => setShowPostComment(!showPostComment)}
variant={"outline"}
className="mb-4"
>
Hide comment form
</Button>
<CommentForm answer={answer?.documentId} />
</div>
)}
<Button onClick={handleGetComments} variant={"outline"}>
{loading
? "Fetching comments..."
: showComments
? "Hide comments"
: "Show comments"}
</Button>
</div>
{showComments && (
<div className="border-t border-stone-200 dark:border-stone-800">
{comments?.length ? (
<ul className="flex flex-col gap-4">
{comments.map((comment, i) => (
<li
key={comment?.documentId}
className="border-t border-stone-200 first-of-type:border-t-0 dark:border-stone-800"
>
<CommentCard comment={comment} i={i} />
</li>
))}
</ul>
) : (
<p className="p-4">No comments yet. Be the first!</p>
)}
</div>
)}
</article>
);
};
export default AnswerCard;
Here's how it works:
useState
hooks to manage the visibility of the comment form (showPostComment
), the visibility of comments (showComments
), the list of comments (comments
), and the loading state (loading
).Avatar
component to show the user's avatar or initials.CommentForm
by updating showPostComment
.handleGetComments
, which fetches comments using the getComments
function and updates the comments
state. It also manages the loading state and displays a toast notification based on the success or failure of the operation.showComments
is true, the component renders a list of CommentCard
components. 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:
Create a new file - ./components/Answer/Form.tsx
and enter the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// ./components/Answer/Form.tsx
"use client";
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { createAnswer } from "@/utils";
const AnswerForm: React.FC<{ id?: string }> = ({ id }) => {
const router = useRouter();
const [value, setValue] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
console.log("🚀 ~ file: Form.tsx ~ line 6 ~ AnswerForm ~ value", value, id);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!id) return toast.error("Invalid question ID");
if (!value.trim() || !name.trim())
return toast.error("Please fill in all fields before submitting");
toast.promise(createAnswer({ aText: value, user: name, questionId: id }), {
loading: (() => {
setLoading(true);
return "Submitting answer...";
})(),
success: (data) => {
console.log("🚀 ~ handleSubmit ~ data", data);
if (data.error) {
throw new Error(data.error.message);
}
setLoading(false);
setName("");
setValue("");
router.refresh();
return "Answer submitted successfully!";
},
error: (error) => {
console.log("🚨 ~ handleSubmit ~ error", error);
setLoading(false);
return error.message;
},
});
};
return (
<>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<ReactQuill
theme="snow"
value={value}
onChange={(content, delta, source, editor) =>
setValue(editor.getHTML())
}
placeholder="What's your answer?"
/>
<Input
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit" variant="default" className="w-fit">
{loading ? "Submitting..." : "Submit Answer"}
</Button>
</form>
</>
);
};
export default AnswerForm;
The AnswerForm
component provides a user interface for submitting answers to questions:
value
for the answer content, name
for the user’s name, and loading
to indicate the submission status.toast.promise
to handle the createAnswer
function, showing appropriate feedback.ReactQuill
editor for rich-text answers, an Input
field for the user's name, and a Button
that 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.
Create a new file - ./components/Comment/Card.tsx
and enter the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ./components/Comment/Card.tsx
"use client";
import { Comment } from "@/types";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
const CommentCard: React.FC<{
comment: Comment;
i: number;
}> = ({ comment, i }) => {
return (
<article className="flex flex-col gap-4 p-4">
<div className="flex items-center gap-2">
<Avatar>
<AvatarImage
src={`https://avatar.iran.liara.run/public/${i + 1}`}
alt={comment?.user}
/>
<AvatarFallback>
{comment?.user
?.split(" ")
.map((name) => name[0])
.join("")}
</AvatarFallback>
</Avatar>
<p className=" ">{comment?.user}</p>
</div>
<p className="text-sm">{comment?.cText}</p>
</article>
);
};
export default CommentCard;
The CommentCard
component displays individual comments with the following features:
Avatar
, AvatarImage
, and AvatarFallback
components 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.cText
).The component is styled with a flex
layout for the avatar and text, ensuring a clean, organized appearance.
Create a new file - ./components/Comment/Form.tsx
and enter the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// ./components/Comment/Form.tsx
"use client";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createComment } from "@/utils";
const CommentForm: React.FC<{
answer: string;
}> = ({ answer }) => {
const router = useRouter();
const [comment, setComment] = useState("");
const [user, setUser] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!comment.trim() || !user.trim()) {
toast.error("Please fill in all fields");
return;
}
toast.promise(createComment({ cText: comment, user, answer }), {
loading: (() => {
setLoading(true);
return "Posting comment...";
})(),
success: (data) => {
console.log("🚀 ~ data", data);
if (data.error) {
throw new Error(data.error.message);
}
setLoading(false);
setComment("");
setUser("");
router.refresh();
return "Comment posted!";
},
error: (error) => {
setLoading(false);
return error.message;
},
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<Textarea
className="bg-white dark:bg-stone-800"
name="comment"
placeholder="Type your comment here..."
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
<Input
className="bg-white dark:bg-stone-800"
type="text"
name="name"
placeholder="Your name"
value={user}
onChange={(e) => setUser(e.target.value)}
/>
<Button type="submit" variant={"default"}>
{loading ? "Posting comment..." : "Post comment"}
</Button>
</form>
);
};
export default CommentForm;
The CommentForm
component allows users to submit comments. Here's a brief breakdown:
Textarea
for the comment text.Input
field for the user's name.Button
to submit the form.useState
manages the comment text, user name, and loading state.handleSubmit
validates the inputs.toast.promise
function to handle the comment creation process and calls the createComment
function, providing feedback on the submission status (loading, success, or error).router.refresh()
.This component provides a user-friendly interface for submitting comments.
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.tsx
Now, we open the components/Site/Header.tsx
and paste the below code to it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ./components/Site/Header.tsx
import Link from "next/link";
const SiteHeader = () => {
return (
<header className="sticky top-0 z-10 w-full bg-red-700 p-4 text-red-50">
<div className="wrapper mx-auto max-w-3xl">
<Link href="/">
<figure className="site-logo font-heading text-2xl font-black uppercase">
The Q&A Times
</figure>
</Link>
</div>
</header>
);
};
export default SiteHeader;
This component just renders the text The Q&A Times
in the header section of our app.
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// ./app/layout.tsx
import type { Metadata, Viewport } from "next";
import "./globals.css";
import SiteHeader from "@/components/Site/Header";
import { Toaster } from "@/components/ui/sonner";
const APP_NAME = "Q&A Times";
const APP_DEFAULT_TITLE = "The Q&A Times";
const APP_TITLE_TEMPLATE = "%s - Q&A Times";
const APP_DESCRIPTION =
"Feel free to ask any question and get answers from the community";
const APP_URL = process.env.APP_URL || "https://qa-times.netlify.app";
export const metadata: Metadata = {
applicationName: APP_NAME,
title: {
default: APP_DEFAULT_TITLE,
template: APP_TITLE_TEMPLATE,
},
description: APP_DESCRIPTION,
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: APP_DEFAULT_TITLE,
// startUpImage: [],
},
formatDetection: {
telephone: false,
},
openGraph: {
type: "website",
siteName: APP_NAME,
title: {
default: APP_DEFAULT_TITLE,
template: APP_TITLE_TEMPLATE,
},
description: APP_DESCRIPTION,
images: [
{
url: `${APP_URL}/images/qa-cover.png`,
width: 1200,
height: 630,
alt: APP_DEFAULT_TITLE,
},
],
},
twitter: {
card: "summary",
title: {
default: APP_DEFAULT_TITLE,
template: APP_TITLE_TEMPLATE,
},
description: APP_DESCRIPTION,
images: [
{
url: `${APP_URL}/images/qa-cover.png`,
width: 1200,
height: 630,
alt: APP_DEFAULT_TITLE,
},
],
},
};
export const viewport: Viewport = {
themeColor: "#ffffff",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<SiteHeader />
{children}
<Toaster richColors position="top-center" theme="system" />
</body>
</html>
);
}
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.
Let's create our page components.
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ./app/page.tsx
import QuestionCard from "@/components/Question/Card";
import QuestionDrawer from "@/components/Question/Drawer";
import { getQuestions } from "@/utils";
export default async function Home() {
const questions = await getQuestions();
return (
<main>
<header className="bg-stone-50 px-4 py-12 dark:bg-stone-900 lg:px-6">
<div className="wrapper mx-auto max-w-3xl">
<h1 className="mb-2 text-6xl font-black leading-tight">
Start asking questions
</h1>
<QuestionDrawer />
</div>
</header>
<section className="site-section p-4 lg:px-6">
<div className="wrapper mx-auto max-w-3xl">
<header className="section-header mb-4">
<h2 className="section-title text-xl font-semibold">Questions</h2>
</header>
{questions?.data?.length ? (
<ul className="grid gap-4">
{questions.data.map((question) => (
<li key={question?.documentId}>
<QuestionCard question={question} />
</li>
))}
</ul>
) : (
<p>No questions found yet. Be the first to ask!</p>
)}
</div>
</section>
</main>
);
}
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:
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.tsx
and enter the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// ./questions/[id]/page.tsx
import AnswerCard from "@/components/Answer/Card";
import AnswerForm from "@/components/Answer/Form";
import { getAnswers, getQuestion } from "@/utils";
import Link from "next/link";
const QuestionPage = async ({
params,
}: {
params: {
id: string;
};
}) => {
// get the question id from the path
const id = params.id;
// fetch the question and answers
const question = await getQuestion(id as string);
const answers = await getAnswers(id as string);
return (
<main>
{id && question.data ? (
<>
<header className="bg-stone-50 px-4 py-12 dark:bg-stone-900 lg:px-6">
<div className="wrapper mx-auto max-w-3xl">
<h1 className="mb-2 text-4xl font-black leading-tight">
{question.data.qText}
</h1>
<p>
Asked by {question.data.user} on{" "}
{new Date(question.data.createdAt).toDateString()}
</p>
</div>
</header>
<section className="site-section bg-sla px-4 py-12 lg:px-6">
<div className="wrapper mx-auto max-w-3xl">
<AnswerForm id={id} />
</div>
</section>
<section className="site-section px-4 py-12 lg:px-6">
<div className="wrapper mx-auto max-w-3xl">
<header className="section-header mb-8">
<h2 className="text-2xl">Answers</h2>
</header>
<ul className="flex flex-col gap-4">
{answers?.data?.length ? (
answers?.data?.map((answer) => (
<li className="" key={answer?.documentId}>
<AnswerCard answer={answer} />
</li>
))
) : (
<p>No answers yet. Be the first!</p>
)}
</ul>
</div>
</section>
</>
) : (
<header className="bg-stone-50 px-4 py-12 dark:bg-stone-900 lg:px-6">
<div className="wrapper mx-auto max-w-3xl">
<h1 className="mb-2 text-4xl font-black leading-tight">
Oops! Question not found
</h1>
<Link className="underline" href="/">
Maybe you'd like to ask a question?
</Link>
</div>
</header>
)}
</main>
);
};
export 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.
Find the source code of the project below:
We covered a lot in this tutorial. We started by introducing relationships in the database and going through the types of relationships one after the other, explaining what they are. Next, we saw how we can set up a Strapi project and how we can establish relations in Strapi collections.
Finally, we built a Q&A app just like Quora to fully demonstrate how relations in Strapi can be used in a real-life app. This article is a goldmine of Strapi info.