DEV Community

Cover image for Build comments section with Next.js and Supabase
Hoon Wee
Hoon Wee

Posted on

Build comments section with Next.js and Supabase

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.

Comment section

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

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

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

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

And then, replace contents in styles/globals.css with the code down below.

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

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

Run the following command to serve web page in development environment,

$ yarn dev
Enter fullscreen mode Exit fullscreen mode

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

Create Organization

Now for the organization we've just created, we'll create a new project which will contain SQL tables.

  1. Name: I'll just name it "Master Supabase", but the naming doesn't matter.
  2. 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.
  3. Region: Choose the nearest place where you live(as for me, Seoul).
  4. Pricing Plan: Choose 'Free tier', but you can upgrade later if you want.

Once finished the form, hit Create new project.

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.

  1. Name: comments, but feel free to choose other name.
  2. Description: This is optional, and I am gonna skip it this time.
  3. Enable Row Level Security: This is a fancy feature in Supabase, but we'll discover it my later post. Now we just skip it.
  4. Columns: We'll edit & add several columns like the image below.

Make sure you change the type of 'id' column into uuid!

Create Table

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

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:

  1. User type the comment in the form, then hit Submit.
  2. We somehow send the comment data to our Supabase comments table.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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

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.

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

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

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,

  1. Add a comment react state for the placeholder of user's comment payload.
  2. Add onChange function to update the comment payload in comment whenever the payload is changed.
  3. 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 use event.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"
            />
...
Enter fullscreen mode Exit fullscreen mode

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.

Check form state

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

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 using insert, we create a new row in our comments table with give table_row data.

    • As you can see, we have only put username and payload in our table_row, because the rest of the data will have it's default value.
  • const { data, error }: Supabase will give us a response containing data which contains information of our action, and error 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.

New data row

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:

  1. We get all the comments from the comments table.
  2. We display it - in list-like format.
  3. 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>
...
Enter fullscreen mode Exit fullscreen mode

Let's disect this part real quick:

  1. Below our form element we added a comment list section, iterated by commentList react state that we newly created.
  2. The commentList state has a array-type of CommentParams interface, which contains all the column names for each object key.
  3. The question mark ? at reply_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();
  }, []);
...
Enter fullscreen mode Exit fullscreen mode

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!

Comment list

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>

...

Enter fullscreen mode Exit fullscreen mode

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 of aDate and bDate is for casting Date type to number type, since Typescript's sort()'s return value has to be in number 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>

...

Enter fullscreen mode Exit fullscreen mode

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 with setEditComment.
    • 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.

Edit comment 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);
    }
  };

...

Enter fullscreen mode Exit fullscreen mode

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

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.

Run SQL query

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 changes updated_at data, so even though we've mistakenly confirm the edit without changing, the comment will always be marked as edited 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"

...

Enter fullscreen mode Exit fullscreen mode

Now our Confirm button will be disabled unless the content of the comment has been changed.

Disable when not edited

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 ? (

...

Enter fullscreen mode Exit fullscreen mode

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>

...

Enter fullscreen mode Exit fullscreen mode

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>

...

Enter fullscreen mode Exit fullscreen mode

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.
  • We added a Reply button, which will use setReplyOf to save the id of comment that we select.

Ok this explanation enough, but basically it will look like this simple.

Replying

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

...

Enter fullscreen mode Exit fullscreen mode

Now try adding a replying comment and then refresh. If done correctly, you'll see a reply comment like the image below.

Replied comments

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

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>

...

Enter fullscreen mode Exit fullscreen mode

What has been changed?

  • We replaced three texts in our comment item - Edit, Delete, and Reply.
  • 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>
          </>
        ) : (
          <>

...

Enter fullscreen mode Exit fullscreen mode

We've changed Reply of to our Reply icon, and changed Confirm and Cancel too.
We didn't change much, but looks way better!

Replace with Icons

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

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.
  • 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 useStates and useEffects 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.

Stale-While-Revalidate

Setting SWR, and refactor APIs

Now let's install SWR with the command below.

$ yarn add swr
Enter fullscreen mode Exit fullscreen mode

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

Now that's a lot of code all of a sudden! Don't worry, I'll explain one-by-one.

  • CommentsApi function takes req which is a request from the caller of this API, and res which is a response that we'll modify according to the request.
  • Inside the function, we encounter a switch condition filter with 5 cases:
    • 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>

...

Enter fullscreen mode Exit fullscreen mode

Here's what happened:

  • Removed commentList React State, getCommentList function and useEffect 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") and fetcher.
    • Since we are using GET method with fetch, our GET case in comments.ts is executed, which fetches the full comment list.
  • Added little ? and ?? [] to commentList when it's used for finding or sorting something.
    • A reason for this is because our data fetched from useSWR is fallible, so it counts for the chance to being a undefined for fetch failure.
    • So we should inform the find function with ? typing that it might contain the undefined data.
    • For sort function, which doesn't tolerate undefined, we have to hand over at least the empty array.

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 = () => {

...

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 two mutate functions. This will send a POST request to comments.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 second mutate function to compare with modified cache, and initalize the comment form with setComment by setting an empty string.

Now with our powerful cache-modifying code, we can see updated comment list without reloading the page!

Without reloading

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: "" });
      }
    }
  };

...

Enter fullscreen mode Exit fullscreen mode

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 in confirmEdit.
  • Before executing 2nd mutate, we check if our request has succeeded with response[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 :)");
      }
    }
  };

...

Enter fullscreen mode Exit fullscreen mode

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.

Toast UI

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 (2)

Collapse
 
ahmadwahyudi profile image
Ahmad

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

Collapse
 
janghanpark profile image
janghan-park

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