Let's build our own comments section because... why not?
Hi yall folks! In this series, we will use sexy combination - Next.js + Supabase - to build a comment section for blogs.
Our goal
Just like every good-old Todo list tutorials, we will build simple CRUD(Create, Read, Update and Delete) features for our comments.
- Adding comments
- Reading comments
- Editing comments
- Deleting comments
- Replying to comments
The follow-along code can be found here.
What do we need?
Obviously, we'll be using those three:
- Next.js: Probably the best production-ready frontend framework in this planet
- Supabase: Trendy BaaS(Backend as a Service) product for PostgreSQL lovers
And also some extras that will make our project super-easy:
- TailwindCSS: CSS library that will make styling super-easy.
- SWR: Super-simple data fetching/caching library
Local Settting: Next.js and TailwindCSS
Create Next App
We'll first create a basic Typescript-based Next.js application using npx create-next-app
.
$ npx create-next-app --typescript supabase-comments
When the project is created, head to the files directory and you'll see those basic files.
We won't be needing all of them, so we'll remove some of them.
...node_modules
├── package.json
├── pages
│ ├── _app.tsx
│ ├── api
│ │ └── hello.ts
│ └── index.tsx
├── public # <- Remove
│ ├── favicon.ico # <- Remove
│ └── vercel.svg # <- Remove
├── styles
│ ├── Home.module.css # <- Remove
│ └── globals.css
├── tsconfig.json
└── yarn.lock
Add TailwindCSS for NextJS
Above done, then we'll add TailwindCSS & other dependencies for our styling.
$ yarn add -D tailwindcss postcss autoprefixer
$ yarn tailwindcss init -p
The last command will create tailwindcss.config.js
file, which is a config javascript file for TailwindCSS.
This simple file does a lot, but right now we'll just define which files should TailwindCSS watch for.
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
And then, replace contents in styles/globals.css
with the code down below.
@tailwind base;
@tailwind components;
@tailwind utilities;
Now we can properly use our TailwindCSS with NextJS!
To test if it works, replace the contents in pages/index.tsx
with the code down below.
import type { NextPage } from "next";
import Head from "next/head";
const Home: NextPage = () => {
return (
<div>
<Head>
<title>Comments Page</title>
</Head>
<div className="p-12">
<h1 className="text-2xl font-bold">Comments!</h1>
</div>
</div>
);
};
export default Home;
Run the following command to serve web page in development environment,
$ yarn dev
... and head to http://localhost:3000 to see the bold typed Comments! in the blank web page.
Well done! We are done with our local settings(except SWR, but we'll install it later), so let's head to remote setting.
Remote Setting: Supabase
Create an organization and a project
Head to official Supabase website and sign in with your Github account.
Then it will lead you to the app dashboard.
Click New Project
and then click + New Organization
to create your new project team.
(If you already have an organization, feel free to skip creating one)
Then it will tell you to create a new name for your organization. Just type any cool name, and hit Create organization
.
Now for the organization we've just created, we'll create a new project which will contain SQL tables.
- Name: I'll just name it "Master Supabase", but the naming doesn't matter.
- Database Password: For the password, try using passgen tool to create a strong one. I normally create one from PassGen.co, with length longer than 14.
- Region: Choose the nearest place where you live(as for me, Seoul).
- Pricing Plan: Choose 'Free tier', but you can upgrade later if you want.
Once finished the form, hit Create new project
.
And now we have our brand new Supabase project! Feel free to explore around, discover what you can do.
Create comments
table
Now we will make a SQL table called comments
. To do that click a 'Table editor' menu in left panel of the dashboard.
If your project is new, the there will be no tables in it. Let's hit Create a new table
.
Then it will show you a side panel to insert form for your table's settings.
- Name:
comments
, but feel free to choose other name. - Description: This is optional, and I am gonna skip it this time.
- Enable Row Level Security: This is a fancy feature in Supabase, but we'll discover it my later post. Now we just skip it.
- Columns: We'll edit & add several columns like the image below.
Make sure you change the type of 'id' column into uuid!
If you've finished and confirmed the form, hit Save
button.
Now we have our comments table! Let me explain each table columns' role :
-
id
: An unique identifier of the column. -
created_at
: We'll use this to sort our comments in created order. -
updated_at
: We'll use this andcreated_at
's difference to tell if the comment has been edited. -
username
: This will be used to indicate user who wrote the comment. -
payload
: Actual content of the comment. -
reply_of
: I will save the comment's id that we are replying to.
Create comment
Add input form and Supabase client library
Our index page is kinda empty now, so we should make it look better. Replace the code in pages/index.tsx
to the code down below.
import type { NextPage } from "next";
import Head from "next/head";
const Home: NextPage = () => {
return (
<div>
<Head>
<title>Comments Page</title>
</Head>
<div className="pt-36 flex justify-center">
<div className="min-w-[600px]">
<h1 className="text-4xl font-bold ">Comments</h1>
<form onSubmit={onSubmit} className="mt-8 flex gap-8">
<input type="text" placeholder="Add a comment" className="p-2 border-b focus:border-b-gray-700 w-full outline-none" />
<button className="px-4 py-2 bg-green-500 rounded-lg text-white">Submit</button>
</form>
</div>
</div>
</div>
);
};
export default Home;
We added as simple input form to create a comment. There are some styling codes added,
but since this is not a TailwindCSS tutorial I will leave the explanation to better resource.
Our form looks good, but it doesn't really do anything. To create a comment, we should do this process:
- User type the comment in the form, then hit
Submit
. - We somehow send the comment data to our Supabase
comments
table. - We check our table, see if the data has been added.
To perform the 2nd step, we need a Supabase node client library. Run the command below to add it.
$ yarn add @supabase/supabase-js
Make our messenger for Supabase
Now we need to create a supabase client object, which is a messenger that will help us interact with Supabase.
Add these 2 lines in pages/index.tsx
.
...
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(supabaseUrl, supabaseKey);
const Home: NextPage = () => {
return (
<div>
...
To create a Supabase client object, we need two data: Supabase project URL and key.
These can be found in Settings > Project settings > API
section in our Supabase project dashboard.
Understanding environment variables
These keys should be stored somewhere secure and separate.
Many of the developers save secured data as 'environment variables', commonly shorten as 'env vars'.
Env vars also works as 'variables' which can be set differently across the environment.
For our case, we need to define env vars for development environment, and to do that in NextJS, we use .env.local
file.
If you want to use the same variables in production environment, you can use .env.production
file and replace the values.
Ok, now let's create a .env.local
file in our NextJS app's root directory.
Then copy-paste the first key (anon/public key) in the image above and save it to NEXT_PUBLIC_SUPABASE_KEY
.
For the second key (URL key), save it to NEXT_PUBLIC_SUPABASE_URL
.
If done correctly, it should look like this.
NEXT_PUBLIC_SUPABASE_KEY=[first-key]
NEXT_PUBLIC_SUPABASE_URL=[second-key]
Now what's all that NEXT_PUBLIC_
prefix? NextJS handles env vars differently by their names:
- With
NEXT_PUBLIC_
prefix: Those are exposed in browser, which means it can be used in client-side jobs. - Without
NEXT_PUBLIC_
prefix: Those are server-side jobs.
So that means our supabase basically use those keys in browser side. Once we define or edit our .env.local
file, we have to restart
the development server, so go to terminal and kill the current session with CTRL-C
, and then restart with yarn dev
.
Since we can use our env vars now, add and edit the following lines in pages/index.tsx
.
...
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY ?? "";
export const supabase = createClient(supabaseUrl, supabaseKey);
const Home: NextPage = () => {
return (
<div>
...
The extra ?? ""
after each env var initalization is to prevent them to be undefined
type, which then createClient
won't accept it.
Send an insert
request
Before we use our supabase
messenger, we will first get user's comment payload from our input form.
To do that,
- Add a
comment
react state for the placeholder of user's comment payload. - Add
onChange
function to update the comment payload incomment
whenever the payload is changed. - Add
onSubmit
function to handle the submit behavior of the form. In our case we don't want to reload everytime when we submit the form, so we useevent.preventDefault()
.
The code change will be as following.
...
const Home: NextPage = () => {
const [comment, setComment] = useState<string>("");
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
const commentValue = event.target.value;
setComment(commentValue);
};
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(comment);
};
...
<form onSubmit={onSubmit} className="mt-8 flex gap-8">
<input
onChange={onChange}
type="text"
placeholder="Add a comment"
className="p-2 border-b focus:border-b-gray-700 w-full outline-none"
/>
...
To test if it works, open the Devtools for your browser and head to Console
tab, type something in the input and hit Submit
.
If succeeded, it will show like the image below.
Now we'll use our supabase client to create a comment. In SQL table's perspective, it will basically add a new row.
Replace onSubmit
function to code down below. Don't forget to add an async
keyword since the supabase client api returns a Promise
.
...
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const { data, error } = await supabase.from("comments").insert({
username: "hoonwee@email.com",
payload: comment,
});
if (!error && data) {
// If succeed
window.alert("Hooray!");
} else {
// If failed
window.alert(error?.message);
}
};
...
Let me disect the await supabase
part real quick:
-
.from("comments")
: This indicates which table should be looked for, since theoretically we have lot's of table in project. -
.insert(table_row)
: By usinginsert
, we create a new row in ourcomments
table with give table_row data.- As you can see, we have only put
username
andpayload
in ourtable_row
, because the rest of the data will have it's default value.
- As you can see, we have only put
const { data, error }
: Supabase will give us a response containingdata
which contains information of our action, anderror
if there's an error.
Now let's write something again and hit Submit
. And if you have succeeded, you'll see an alert window containing Hooray! message.
Well that's pretty nice, but we still don't know whether our data has been sent or not.
Head to comments
table in Supabase dashboard, and you'll see a new data row inserted.
Read comments
Create a comment list
Okay great, now we can create comments but we also want to display them in our web page.
To do that we'll do following steps:
- We get all the comments from the
comments
table. - We display it - in list-like format.
- We will sort then by
created_at
data to see them chronologically.
First we should add some UI for the list! Add the following code in pages/index.tsx
.
...
export const supabase = createClient(supabaseUrl, supabaseKey);
interface CommentParams {
id: string;
created_at: string;
updated_at: string;
username: string;
payload: string;
reply_of?: string;
}
const Home: NextPage = () => {
const [comment, setComment] = useState<string>("");
const [commentList, setCommentList] = useState<CommentParams[]>([]);
...
Submit
</button>
</form>
<div className="flex flex-col gap-4 pt-12">
{commentList.map((comment) => (
<div key={comment.id} className="border rounded-md p-4">
<p className="font-semibold mb-2">{comment.username}</p>
<p className="font-light">{comment.payload}</p>
</div>
))}
</div>
</div>
...
Let's disect this part real quick:
- Below our
form
element we added a comment list section, iterated bycommentList
react state that we newly created. - The
commentList
state has a array-type ofCommentParams
interface, which contains all the column names for each object key. - The question mark
?
atreply_of
field indicates that this field is optional.
Send a select
request
Before moving to next step, I want you to add more comments using our form
- because once we succeed to get the comments from supabase, it will look awesome when we have bunch of comments.
Once you've done adding more comments, now let add a new function called getCommentList
that will use supabase
client to
get all the comments. Add the code down below.
...
const [commentList, setCommentList] = useState<CommentParams[]>([]);
const getCommentList = async () => {
const { data, error } = await supabase.from("comments").select("*");
if (!error && data) {
setCommentList(data);
} else {
setCommentList([]);
}
};
useEffect(() => {
getCommentList();
}, []);
...
Right now getCommentList
function will be called once only, when we first render our page.
To do that we will call our getCommentList
in useEffect
hook. Since the useEffect
hook has no external dependency,
this will invoke the inner part only once when the component is rendered.
Now check our web page. It will look much like other comment sections!
Sort comments by created order
Now our Supabase client takes the comment list in created
order,
but soon after when we edit and reply to some of the comments it would bring them by updated
order.
So we should do a little tweak in our code to sort them.
...
<div className="flex flex-col gap-4 pt-12">
{commentList
.sort((a, b) => {
const aDate = new Date(a.created_at);
const bDate = new Date(b.created_at);
return +aDate - +bDate;
})
.map((comment) => (
<div key={comment.id} className="border rounded-md p-4">
<p className="font-semibold mb-2">{comment.username}</p>
...
Breaking this down:
- We added
.sort
before doing.map
in comment list rendering part. - The logic inside
.sort
will arrange from the oldest to the youngest. - The
+
sign at the beginning ofaDate
andbDate
is for castingDate
type tonumber
type, since Typescript'ssort()
's return value has to be innumber
type.
Update and Delete Comments
Plan a feature
We make mistakes, especially a lot when we write something in the internet.
That's why there's always a Edit
and Delete
section in posts and comment section in Facebook, Medium, Twitter and etc.
A properly working editing feature should have these features:
- Able to edit the payload(content) right at the comment item itself.
- Disable edit button unless payload hasn't changed.
- Indicate if this comment has been edited.
A great deleting feature should:
- Ask user if they're really gonna delete this to prevent if this was a click mistake.
- Then delete it.
Create edit input form
For the best user experience, the input form to edit the comment should not be the same place where you add a comment but the comment list item itself.
That means we should upgrade our comment list item, so let's add the following code to do it!
...
const Home: NextPage = () => {
const [comment, setComment] = useState<string>("");
const [commentList, setCommentList] = useState<CommentParams[]>([]);
const [editComment, setEditComment] = useState<EditCommentParams>({
id: "",
payload: "",
});
const onChangeEditComment = (event: ChangeEvent<HTMLInputElement>) => {
const payload = event.target.value;
setEditComment({ ...editComment, payload });
};
const confirmEdit = () => {
window.alert("Confirm edit comment");
};
const getCommentList = async () => {
const { data, error } = await supabase.from("comments").select("*");
...
<div key={comment.id} className="border rounded-md p-4">
<p className="font-semibold mb-2">{comment.username}</p>
<div className="flex items-center gap-2 justify-between">
{comment.id === editComment.id ? (
<input
type="text"
value={editComment.payload}
onChange={onChangeEditComment}
className="pb-1 border-b w-full"
/>
) : (
<p className="font-light">{comment.payload}</p>
)}
{editComment.id === comment.id ? (
<div className="flex gap-2">
<button type="button" onClick={confirmEdit} className="text-green-500">
Confirm
</button>
<button
type="button"
onClick={() => setEditComment({ id: "", payload: "" })}
className="text-gray-500"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setEditComment({ id: comment.id, payload: comment.payload })}
className="text-green-500"
>
Edit
</button>
)}
</div>
</div>
))}
</div>
...
A brief analysis for this one:
- We added
editComment
state, which will set which comment to edit and to what payload it should be. - We added two functions:
-
onChangeEditComment
: This will watch the value of edit form input and set the payload to edit withsetEditComment
. -
confirmEdit
: This will send a request to Supabase to update the comment, but right now we just put silly window alert.
-
- We updated our comment item section to switch between 'read-mode' and 'edit-mode' with
editComment
state.
Constructing update
request
Now only thing to do is just replacing the confirmEdit
function to communicate with Supabase.
Replace that part with this code. I'm sure you are now getting familiar with supabase
api.
...
const confirmEdit = async () => {
const { data, error } = await supabase
.from("comments")
.update({
payload: editComment.payload,
})
.match({ id: editComment.id });
if (!error && data) {
window.alert("Edited Comment!");
} else {
window.alert(error?.message);
}
};
...
So apparently from this code,
- We used
update
function to update the data.- We only need to pass the changed part, not the whole other parts.
- Then used
match
function to target which comments should be updated.
But wait, shouldn't we update the updated_at
?
That is correct! We will do it in Supabase Dashboard, not in our Next.js code.
We will use an actual SQL query to accomplish this, and to do that head to SQL Editor through navigation bar.
Then you'll see an input box to write down the SQL query. Insert the SQL query down below.
create extension if not exists moddatetime schema extensions;
create trigger handle_updated_at before update on comments
for each row execute procedure moddatetime (updated_at);
So much to explain about this query, but basically it will set the updated_at
column to the current timestamp for every update.
Hit Run
to run the query and adapt the trigger.
Now our edit request will work like a charm. Try editing any comment, and then refresh. If succeeded then you'll see your comment edited.
Disable Confirm
button when comment is not edited
Currently we just allow user to click Confirm
button whenever they press Edit
button, without checking if the payload has changed.
This can arise two problems:
- Our
confirmEdit
function always changesupdated_at
data, so even though we've mistakenly confirm the edit without changing, the comment will always be marked asedited
since there's no going back in time. - Now this is not that critical matter, but if we were to use this in much bigger project, then there will be unnecessary transaction between user browser and Supabase.
To prevent this, we need to disable the Confirm
button when user hasn't change their comment. Let's tweek a code a little bit.
...
{editComment.id === comment.id ? (
<>
<button
type="button"
onClick={confirmEdit}
disabled={editComment.payload === comment.payload}
className={`${editComment.payload === comment.payload ? `text-gray-300` : `text-green-500`}`}
>
Confirm
</button>
<button
type="button"
onClick={() => setEditComment({ id: "", payload: "" })}
className="text-gray-500"
...
Now our Confirm
button will be disabled unless the content of the comment has been changed.
Indicate edited
Comment item should indicate that it has been edited. This can be acheived quite easily - by comparing created_at
and updated_at
.
...
.map((comment) => (
<div key={comment.id} className="border rounded-md p-4">
<p className="font-semibold mb-2">
{comment.username}
{comment.updated_at !== comment.created_at && (
<span className="ml-4 text-sm italic font-extralight">edited</span>
)}
</p>
<div className="flex items-center gap-2 justify-between">
{comment.id === editComment.id ? (
...
Now if we edit any comment, it will indicate edited
in extra-light & italic font.
Delete comment
Deleting comment is not so different from updating comment - it uses same match
function to target which comment should
be deleted. Let's do this real quick.
...
const confirmDelete = async (id: string) => {
const ok = window.confirm("Delete comment?");
if (ok) {
const { data, error } = await supabase.from("comments").delete().match({ id });
if (!error && data) {
window.alert("Deleted Comment :)");
} else {
window.alert(error?.message);
}
}
};
...
onChange={onChangeEditComment}
className="pb-1 border-b w-full"
/>
) : (
<p className="font-light">{comment.payload}</p>
)}
<div className="flex gap-2">
{editComment.id === comment.id ? (
<>
<button type="button" onClick={confirmEdit} className="text-green-500">
Confirm
</button>
<button
type="button"
onClick={() => setEditComment({ id: "", payload: "" })}
className="text-gray-500"
>
Cancel
</button>
</>
) : (
<>
<button
type="button"
onClick={() => setEditComment({ id: comment.id, payload: comment.payload })}
className="text-green-500"
>
Edit
</button>
<button type="button" onClick={() => confirmDelete(comment.id)} className="text-gray-700">
Delete
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
</div>
...
After adding the code, click the Delete
button in the comment item that you want to delete, press Ok
in confirm window,
and refresh the page - then the comment is gone!
You see, making CRUD feature with Supabase is Supa-easy!
Replying to Comments
How our reply will work
Now guess the only column in our table that we haven't used yet - that's right, the reply_of
column. We'll use it right away to
add a feature to reply comments.
Let's think how our reply feature would work best:
- User clicks
Reply
button on the comment item. - Then our input form(for adding comments) will show that this comment will be a replying comment of some other comment.
- Once added and fetched to the comment list, it should still be distinguishable from normal comments.
Adding Reply of: ....
Alrighty, as always let's work with the UI part first.
...
const [editComment, setEditComment] = useState<EditCommentParams>({ id: "", payload: "" });
const [replyOf, setReplyOf] = useState<string | null>(null);
const onChangeEditComment = (event: ChangeEvent<HTMLInputElement>) => {
...
<h1 className="text-4xl font-bold ">Comments</h1>
<form onSubmit={onSubmit} className="mt-8 flex gap-8">
<div className="w-full">
{replyOf && (
<div className="flex gap-4 my-2 items-center justify-start">
<p className="text-xs font-extralight italic text-gray-600">
Reply of: {commentList.find((comment) => comment.id === replyOf)?.payload ?? ""}
</p>
<button onClick={() => setReplyOf(null)} className="text-xs font-light text-red-600">
Cancel
</button>
</div>
)}
...
.map((comment) => (
<div key={comment.id} className="border rounded-md p-4">
{comment.reply_of &&
<p className="font-extralight italic text-gray-600 text-xs">
Reply of: {commentList.find((c) => c.id === comment.reply_of)?.payload ?? ""}
</p>
}
<p className="font-semibold mb-2">
{comment.username}
...
Delete
</button>
<button type="button" onClick={() => setReplyOf(comment.id)} className="text-orange-500">
Reply
</button>
</>
)}
</div>
...
Here in this code, we can see that:
- We declared a new state called
replyOf
to save the id of replying comment that we select. - We added a single text line in (1) Input form (2) Above username in comment item, showing which comment we are replying to.
- In input form, we also added a
Cancel
button to cancel replying to it and revert our input form to normal one.
- In input form, we also added a
- We added a
Reply
button, which will usesetReplyOf
to save the id of comment that we select.
Ok this explanation enough, but basically it will look like this simple.
Then all you need to add is to pass the replying comment's id to reply_of
field in onSubmit
.
...
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const { data, error } = await supabase.from("comments").insert({
username: "hoonwee@email.com",
payload: comment,
reply_of: replyOf,
});
if (!error && data) {
window.alert("Hooray!");
} else {
window.alert(error?.message);
}
};
...
Now try adding a replying comment and then refresh. If done correctly, you'll see a reply comment like the image below.
Restyling using Icons
Problems with text-only UI
So now our comment section is already awesome. It can read, create, update, delete and reply to comments.
While it's fully functional, we have to admit that it looks very boring and visually unapealing - because we only used texts for our UI.
The problem with using only text for our UI can invoke bad user experiences like:
- It can be less intuitive, which will confuse users.
- If the text is too long, it can ruin the overall visual design.
So to solve this, we need some design element that can pack the meaning of the UI into strong and concise visual format.
The best one for this, as far as I know, is an icon.
Text → Icons
There are tons of icon packs in internet, and in here we will use one called Hero icons.
Since it's been developed from the people behind TailwindCSS, it works best with our project.
Install Hero icons with the following command.
$ yarn add @heroicons/react
Now let's start replacing several texts into Hero icons!
...
import { ReplyIcon, PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon, XIcon } from "@heroicons/react/outline";
...
Cancel
</button>
</>
) : (
<>
<button
onClick={() => setEditComment({ id: comment.id, payload: comment.payload })}
title="Edit comment"
>
<PencilIcon className="w-6" />
</button>
<button onClick={() => confirmDelete(comment.id)} title="Delete comment">
<TrashIcon className="w-6" />
</button>
<button onClick={() => setReplyOf(comment.id)} title="Reply to comment">
<ReplyIcon className="w-6 rotate-180" />
</button>
</>
)}
</div>
...
What has been changed?
- We replaced three texts in our comment item -
Edit
,Delete
, andReply
. - We removed the unnecessary color variation between buttons cause our icons already are distinguishable.
- We added a
title
property to show what this icon means when user's mouse pointer hovers on the button.- I strongly advice to do this, since the infographic that we know as common sense might not be the same in other culture.
- We rotated the
Reply
icon to 180 degrees. I did it because it felt right with this angle, but you can change it if you want.
Let's keep adding more icons.
...
{replyOf && (
<div className="flex gap-4 my-2 items-center justify-start">
<div className="flex items-center justify-start gap-2">
<ReplyIcon className="w-4 text-gray-600 rotate-180" />
<p className="font-extralight italic text-gray-600 text-sm">
{commentList.find((comment) => comment.id === replyOf)?.payload ?? ""}
</p>
</div>
<button onClick={() => setReplyOf(null)} title="Cancel">
<XIcon className="w-4 text-gray-600" />
</button>
</div>
)}
...
{comment.reply_of && (
<div className="flex items-center justify-start gap-2">
<ReplyIcon className="w-3 text-gray-600 rotate-180" />
<p className="font-extralight italic text-gray-600 text-xs">
{commentList.find((c) => c.id === comment.reply_of)?.payload ?? ""}
</p>
</div>
)}
...
<div className="flex gap-2">
{editComment.id === comment.id ? (
<>
<button
type="button"
onClick={confirmEdit}
disabled={editComment.payload === comment.payload}
title="Confirm"
>
<CheckCircleIcon
className={`${
editComment.payload === comment.payload ? `text-gray-300` : `text-green-500`
} w-6`}
/>
</button>
<button type="button" onClick={() => setEditComment({ id: "", payload: "" })} title="Cancel">
<XCircleIcon className="w-6 text-gray-600" />
</button>
</>
) : (
<>
...
We've changed Reply of
to our Reply
icon, and changed Confirm
and Cancel
too.
We didn't change much, but looks way better!
Implementing SWR
Finally, we will gonna fix the data fetching part, which right now user needs to reload everytime they modify(create, edit, delete) the comments.
By using amazing library called SWR
, we will gonna fix this problem and it will take our comment section's user experience into whole another level.
A brief overview of SWR
There are numerous data-fetching libraries for Next.js, but one of the most popular and easiest to use is SWR.
Here's a simple example from their official document page(modified a little bit for better understanding).
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.body);
function Profile() {
const { data, error } = useSWR('/api/user', fetcher);
if (error) return <div>failed to load</div>;
if (!data) return <div>loading...</div>;
return <div>hello {data.name}!</div>;
}
This simple looking code does something beautiful.
- It uses a hook called
useSWR
, which takes 2 arguments:- An
url
to fetch a data. - An
fetcher
function that'll fetch from the given url.
- An
- Then you can just use the the
data
, like you use a React state.
See? It's so simple! Say goodbye to the hard days where you had to use several useState
s and useEffect
s to manipulate and update the
remote date - which is complicated easy to make mistakes.
As the name propose, the mechanism for this comes from a HTTP cache invalidation strategy called stale-while-revalidate
.
Explaining the details about it is beyond our article, so better check out
this link if you're interested.
Setting SWR, and refactor APIs
Now let's install SWR with the command below.
$ yarn add swr
And we will replace our old method of fetching data to new one, using useSWR
.
But first I am pretty sure our code need some refactoring, since we already have too much API-related code in our client-side file index.tsx
.
Thankfully, Next.js provides us a api
directory inside pages
directory, which you can put all kinds of API codes.
Let's make new file pages/api/comments.ts
, with the code down below.
import { createClient } from "@supabase/supabase-js";
import { NextApiRequest, NextApiResponse } from "next";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + "";
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY + "";
export const supabase = createClient(supabaseUrl, supabaseKey);
const CommentsApi = async (req: NextApiRequest, res: NextApiResponse) => {
switch (req.method) {
// Get all comments
case "GET":
const { data: getData, error: getError } = await supabase.from("comments").select("*");
if (getError) {
return res.status(500).json({ message: getError.message });
}
return res.status(200).json(getData);
// Add comment
case "POST":
const comment = req.body;
const { data: postData, error: postError } = await supabase.from("comments").insert(comment);
if (postError) {
return res.status(500).json({ message: postError.message });
}
return res.status(200).json(postData);
// Edit comment
case "PATCH":
const { commentId: editcommentId, payload } = req.body;
const { data: patchData, error: patchError } = await supabase
.from("comments")
.update({ payload })
.eq("id", editcommentId);
if (patchError) {
return res.status(500).json({ message: patchError.message });
}
return res.status(200).json(patchData);
// Delete comment
case "DELETE":
const { comment_id: deleteCommentId } = req.query;
if (typeof deleteCommentId === "string") {
const { data: deleteData, error: deleteError } = await supabase
.from("comments")
.delete()
.eq("id", deleteCommentId + "");
if (deleteError) {
return res.status(500).json({ message: deleteError.message });
}
return res.status(200).json(deleteData);
}
default:
return res.status(405).json({
message: "Method Not Allowed",
});
}
};
export default CommentsApi;
Now that's a lot of code all of a sudden! Don't worry, I'll explain one-by-one.
-
CommentsApi
function takesreq
which is a request from the caller of this API, andres
which is a response that we'll modify according to the request. - Inside the function, we encounter a
switch
condition filter with 5case
s:-
case "GET"
: This will be called for getting comments. -
case "POST"
: This will be called for adding a comment. -
case "PATCH"
: This will be called for editing a comment. -
case "DELETE"
: This will be called for deleting a comment. -
default
: This will omit error for unsupported methods.
-
So what we've done is just moving the API related stuffs to this file.
Each implementation inside the case
block is identical to ones we've written in index.tsx
.
It uses await supabase.from("comments").something(...)
for every case.
Now we've made our decent looking API code, how do we access to it? It's super-easy - Just fetch "/api/comments".
Replacing 'get comments'
Now we are going to use our well organized comments.ts
API with useSWR
hook.
First let's replace the old implementation of getting all the comments.
Edit & Delete codes in index.tsx
with the code below.
...
const fetcher = (url: string) => fetch(url, { method: "GET" }).then((res) => res.json());
const Home: NextPage = () => {
const { data: commentList, error: commentListError } = useSWR<CommentParams[]>("/api/comments", fetcher);
/* Deleted
const [commentList, setCommentList] = useState<CommentParams[]>([]);
*/
const [comment, setComment] = useState<string>("");
...
/* Deleted
const getCommentList = async () => {
const { data, error } = await supabase.from("comments").select("*");
if (!error && data) {
setCommentList(data);
} else {
setCommentList([]);
}
};
useEffect(() => {
getCommentList();
}, []);
*/
...
<div className="flex items-center justify-start gap-2">
<ReplyIcon className="w-4 text-gray-600 rotate-180" />
<p className="font-extralight italic text-gray-600 text-sm">
{commentList?.find((comment) => comment.id === replyOf)?.payload ?? ""}
</p>
</div>
...
{(commentList ?? [])
.sort((a, b) => {
const aDate = new Date(a.created_at);
...
<div className="flex items-center justify-start gap-2">
<ReplyIcon className="w-3 text-gray-600 rotate-180" />
<p className="font-extralight italic text-gray-600 text-xs">
{commentList?.find((c) => c.id === comment.reply_of)?.payload ?? ""}
</p>
</div>
...
Here's what happened:
- Removed
commentList
React State,getCommentList
function anduseEffect
which was used to update comments when data is refetched. - Replaced that part with a single line of code(or maybe 2 or 3 lines of code depending on your formatter), using
useSWR
hook.- Same as an example from above, it contains
url("/api/comments")
andfetcher
. - Since we are using
GET
method withfetch
, ourGET
case incomments.ts
is executed, which fetches the full comment list.
- Same as an example from above, it contains
- Added little
?
and?? []
tocommentList
when it's used forfind
ing orsort
ing something.- A reason for this is because our data fetched from
useSWR
is fallible, so it counts for the chance to being aundefined
for fetch failure. - So we should inform the
find
function with?
typing that it might contain theundefined
data. - For
sort
function, which doesn't tolerateundefined
, we have to hand over at least the empty array.
- A reason for this is because our data fetched from
We changed our code a lot, in a good way! Our comment section should work just the same.
Replacing "add comments"
Next we'll replace 'add comment' feature. To do that we have to add another fetching function which will send a post request to our comments.ts
.
Add addCommentRequest
function right after fetcher
.
...
const fetcher = (url: string) => fetch(url, { method: "GET" }).then((res) => res.json());
const addCommentRequest = (url: string, data: any) =>
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then((res) => res.json());
const Home: NextPage = () => {
...
We stringify the comment data and post it. No difficult things to be explained.
Now we'll use an interesting feature of SWR, called mutate
.
Using mutate
we can modify the local cache of comment list before we even refetch the updated list from Supabase server.
Let's discover the behaviour by just implementing it. Update the onSubmit
function, and edit our add comment form.
...
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const newComment = {
username: "hoonwee@email.com",
payload: comment,
reply_of: replyOf,
};
if (typeof commentList !== "undefined") {
mutate("api/comments", [...commentList, newComment], false);
const response = await addCommentRequest("api/comments", newComment);
if (response[0].created_at) {
mutate("api/comments");
window.alert("Hooray!");
setComment("")
}
}
};
...
<input
onChange={onChange}
value={comment}
type="text"
placeholder="Add a comment"
className="p-2 border-b focus:border-b-gray-700 w-full outline-none"
/>
We removed our old await supabase...
and replaced it with someting else:
- We added two
mutate
functions, which will refetch comment list that has added a new comment. But why two?- The first one won't actually refetch the data. Instead it will assume that adding a comment has succeeded, and pretend that it refetched it by modifying the local cache of comment list.
- Now the second one will actually refetch the data, and compare between data modified and data refetched. When it's equal, it does nothing. While there's any difference, it will rerender for the correct comment list.
- There's a
await addCommentRequest
function call in between twomutate
functions. This will send a POST request tocomments.ts
API, and return the response for the request.- Once succeeded adding a comment, it will return an array with single comment item.
- So if the response is an array, and the first element has
created_at
field, the request is confirmed to be successful so we'll use secondmutate
function to compare with modified cache, and initalize the comment form withsetComment
by setting an empty string.
Now with our powerful cache-modifying code, we can see updated comment list without reloading the page!
Replacing "edit, delete comments"
Let's practice using mutate
one more time, replacing old code for editing comment.
Add & Replace code like down below.
...
const editCommentRequest = (url: string, data: any) =>
fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then((res) => res.json());
...
const confirmEdit = async () => {
const editData = {
payload: editComment.payload,
commentId: editComment.id,
};
if (typeof commentList !== "undefined") {
mutate(
"api/comments",
commentList.map((comment) => {
if (comment.id === editData.commentId) {
return { ...comment, payload: editData.payload };
}
}),
false
);
const response = await editCommentRequest("api/comments", editData);
console.log(response);
if (response[0].created_at) {
mutate("api/comments");
window.alert("Hooray!");
setEditComment({ id: "", payload: "" });
}
}
};
...
The flow is the same as we've done for onSubmit
function.
- We first added a
editCommentRequest
fetcher function. - We added two
mutate
, the pretending one, and the real one inconfirmEdit
. - Before executing 2nd
mutate
, we check if our request has succeeded withresponse[0].created_at
. - Finally we reset the
editComment
state.
Let's do the same work for deleting comments.
...
const deleteCommentRequest = (url: string, id: string) =>
fetch(`${url}?comment_id=${id}`, { method: "DELETE" }).then((res) => res.json());
...
const confirmDelete = async (id: string) => {
const ok = window.confirm("Delete comment?");
if (ok && typeof commentList !== "undefined") {
mutate(
"api/comments",
commentList.filter((comment) => comment.id !== id),
false
);
const response = await deleteCommentRequest("api/comments", id);
if (response[0].created_at) {
mutate("api/comments");
window.alert("Deleted Comment :)");
}
}
};
...
No explaination needed! It's the same as we did for editing comment.
Try editing & deleting comment, and check if the comment list changes properly without reloading.
And we are done!
Congratulations! We successfully built a comments section with feature of:
- CRUD(Create, Read, Update, Delete)ing the comments, with Supabase node library.
- Mutate UI without reloading with SWR
- Clean & understandable design, powered by TailwindCSS and Hero Icons.
Although our comment section is awesome, there are some improvements to be made (do it by yourself!):
- Replace browser's alert/confirm window to toast UI. It will look better.
- Implement user login, to make it usable in community service. You can make it from scratch, or...
- Transform the replying system into threads.
And that's all for this series! Thank you so much for following up this far, and I hope to see you on my next blog post/series!
Until then, happy coding!
Top comments (3)
finally found this article,
i want to make a simple comment system, because my main apps use supabase. So it fits perfectly.
Thanks for sharing this great article, it will be implemented to my apps soon
Nice post :)
I am also trying to implement a comment feature into my project using SWR for fetching data while I am using useSWRInfinite hook instead of useSWR.
In my case, similarly Bound-mutate function gets called two times when adding a comment. One for optimistic update they say(client-side's perspective) and use the other one for revalidating(refetching) to ensure that the previous update was correct.
Here what I am struggling with comes out.
Since I am using pagination with the hook, It caches an array of fetch response values of each page and revalidate every single page api call.
Like Let's say that a user opens 5 pages of comments by keeping clicking Load more button and then the hook will remember the all api calls from page 1 to page 5 and the user add a comment and mutate functions are invoked and the last mutate function will try to revalidate all the data by sending 5 api calls but I think it requests too many times just for adding a comment.
I've been looking for answers but I find it hard to find a decent one. If you have considered about this situation like me, I would really love to hear your thoughts
Thanks :)
Add Utterances Comment System in Next.js App in App Router
Sh Raj ・ Jun 27