Build a Todo Application with Next.js and Supabase

Build a Todo Application with Next.js and Supabase

Introduction

Next.js has become a popular choice for building full-stack web applications. But when it comes to developing web apps that require setting up and managing a backend database, all that can become tedious. Enter Supabase, an open-source Firebase alternative that provides database, authentication, cloud storage, and more.

In this article, you'll learn how to build a fully functional todo application using Next and Supabase. You'll also learn how to set up a Supabase database and implement fundamental functionalities like CRUD (Create, Read, Update, Delete) operations with Next.js. By the end of this read, you will have hands-on experience in combining these technologies and an impressive portfolio project to showcase your skills.

Prerequisites

To comfortably follow this article and develop your todo application with Next and Supabase, you'll need the following:

  • Node.js version 12.20 or later: You can check your installed node version by running node -v in your terminal. If you don't have it installed or need to upgrade, visit the official Node.js website for instructions.

  • A code editor will be essential for writing and editing your application's code. Popular options include Visual Studio Code (VS Code) or Sublime Text.

  • A solid understanding of JavaScript.

  • Familiarity with React or Next.js (preferred)

Setting up Supabase

Your first task is to create an account on Supabase if you haven't done so already. Head to the official Supabase website and sign up, preferably using your GitHub account or an email address.

Next, create a new project by clicking the "New Project" button. Choose a name that suits your preferences. I'll refer to it as "Todo" for consistency throughout this article.

Once you have completed this step, you'll be redirected to a page where you can access all your project details, including its URL and API key. These credentials are sensitive and required to connect your Next application to Supabase.

Creating a Next app

Now that your Supabase account is up and running let us get on with creating a starter Next application. To begin, open your terminal and run one of the following commands:

npx create-next-app@latest

or

yarn create next-app

On installation, you'll see the following prompts:

What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like to use `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to customize the default import alias (@/*)? No / Yes

For simplicity, we'll focus on using the page router for now. You wouldn't need to install the app router or customize any default import aliases during your initial setup. However, feel free to explore these options later if you'd like.

Once your installation is complete, open your newly created project in any code editor to install Supabase. To do that, open your terminal, and in your project root directory, run one of the following commands:

npm install @supabase/supabase-js

or

yarn add @supabase/supabase-js

Connecting a Next App to Supabase

To connect a Next application to Supabase, follow the instructions below:

Step 1: Create a .env file in your project root directory.

Step 2: Populate your .env file with your Supabase credentials using the NEXT_PUBLIC keyword. Here's an example of how it should be populated:

NEXT_PUBLIC_SUPABASE_PROJECT_URL = https://oknmgvxpewtvxypdrzyl.supabase.co
NEXT_PUBLIC_SUPABASE_API_KEY = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3y9b6hjHgDdvtyu

Remember that the above project URL and API key are unique credentials that are available on your Supabase dashboard. To safeguard these credentials, use a .env file to prevent them from being exposed when pushed to the public.

Step 3: Create a components folder in your src directory. Within that folder, create a supabase.js file. In this file, import createClient from Supabase and invoke the hook with your project URL and API key. It should look something like this:

import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_PROJECT_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_API_KEY;

if (!supabaseUrl || !supabaseKey) {  throw new Error(
    "NEXT_PUBLIC_SUPABASE_PROJECT_URL or NEXT_PUBLIC_SUPABASE_API_KEY is not defined in the environment."
  );
}

export const Supabase = createClient(supabaseUrl, supabaseKey);

The code above begins by importing the createClient object from Supabase and retrieving the Supabase project URL and API key from the process environment object.

If one of these variables is missing, an error is thrown indicating that they have not been declared in the project environment, and if not, the createClient method is invoked, passing the project URL and API key to create an exported Supabase client method.

Step 4: Open the table editor in your Supabase sidebar and create a new table.

Step 5: Specify the table name todos and its corresponding columns. Your table columns should include all the necessary fields for your todo app. These fields include todo of type text and two default fields handled by Supabase - createdAt and id. For now, please turn off row-level security and real-time. Once you are done making all the required changes, click "save."

Implementation of CRUD operations

Good job! You made it through connecting your Next app to Supabase. Now, It’s time to look at how to build a todo application using Supabase.

You can get this project's user interface resources by jumping on this front-end mentor challenge. Now, let’s get you started with implementing CRUD operations.

Create operation

To create a new table row, you’d need to work with two query methods: the from and the insert method.

Step 1: Navigate to your pages folder. In your index.js file, import the useState hook and your exported Supabase Client method.

import { useState } from "react";
import { Supabase } from "@/components/Supabase";

Step 2: Before the return keyword, define a todo and an isSubmitting state, setting their initial values to an empty string and false, respectively.

export default function Home() {
  const [todo, setTodo] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);
  return ();
}

Step 3: Create an asynchronous handleSubmit function that queries Supabase using the from and insert method it provides. Let’s take a look:

const handleSubmit = async (e) => {
  e.preventDefault();
  setIsSubmitting(true);
  const { error } = await Supabase.from("todos").insert([{ todo: todo }]);
  if (error) {
    console.log(error.message);
  } else {
    console.log("New todo added to Supabase DB");
    setTodo("");
  }
  setIsSubmitting(false);
};

In the code above, we chain the form and insert method to send data to Supabase. The from method specifies which table is to be queried, while the insert method allows you to insert data to your database. If in the process, an error occurs, the error message is logged to the console, and if not, a success message is logged while setting the input value to an empty string.

Step 4: Return a form with an input field and a submit button.

return (
  <main>
    <form>
      <label htmlFor="todo">
        <input
          placeholder="Create a new todo..."
          name="todo"
          type="text"
          required
          value={todo}
          onChange={(e) => setTodo(e.target.value)}
        />
        <button
          onClick={handleSubmit}
          disabled={!todo || isSubmitting}
        >Submit</button>
      </label>
    </form>
  </main>
);

The code above begins by creating a form containing an input field and a submit button. The input value of type text is assigned to the state value todo, which tracks the current input value, and the button handles the form submission via an onClick event invoking the handleSubmit function. If the current state of the input is an empty string or if the form is being submitted, the button is disabled.

Read Operation

To read data from your Supabase database, you can initiate a query to fetch your list of todos. To do this, you'd be required to use the useState and useEffect hooks from React.

Step 1: Import the useEffect hook from React.

import { useEffect} from "react";

Step 2: Define a todos state with an initial value of null.

const [todos, setTodos] = useState<null | Todos[]>(null);

Step 3: Create an asynchronous function that makes a query to Supabase to fetch a list of all your todos and invoke this function in your useEffect hook.

  const fetchTodos = async () => {
    const { data, error } = await Supabase.from("todos").select();
    if (error) {
      console.log(error?.message);
    } else {
      setTodos(data);
    }
  };

useEffect(() => {
  fetchTodos();
}, []);

The code above asynchronously fetches todos, chaining the from and select methods. The from method specifies the table to query, while select gets all data from that table. If an error occurs during the query, the error gets logged to the console. Otherwise, the todos state is set to the data.

Step 4: After your return statement, Map out the list of todos fetched.

return (
  <>
    {todos?.map((todo) => (
      <div key={todo.id}>
        <p>{todo.todo}</p>
      </div>
    ))}
  </>
);

Update Operation

To update a todo, modify the handleSubmit logic with a conditional operator so that if a todo id is provided, an update is done; otherwise, it creates a new todo.

Step 1: Using the useState hook, create an id state that keeps track of the selected todo to be updated, with an initial value of null.

const [todoId, setTodoId] = useState<null | number>(null);

Step 2: Update the handleSubmit function using a conditional statement to make update requests if an id is passed.

const handleSubmit = async (e: { preventDefault: () => void }, id?: number) => {
  e.preventDefault();
  setIsSubmitting(true);
  if (todoId) {
    const { error } = await Supabase.from("todos")
      .update([{ todo: todo }])
      .eq("id", todoId);
    if (error) {
      console.log(error.message);
    } else {
      console.log("Todo updated successfully");
      setTodo("");
      setTodoId(null);
    }
  } else {
    const { error } = await Supabase.from("todos").insert([{ todo: todo }]);
    if (error) {
      console.log(error.message);
    } else {
      console.log("New todo added to Supabase DB");
      setTodo("");
      fetchTodos();
    }
  }
  setIsSubmitting(false);
};

In the code above, the handleSubmit function accepts an optional parameter id of type number. If it is passed, it sends an update request by chaining from, the update method which indicates that you are making an update request, and the eq method which identifies the column name and row. Otherwise, it makes the same create request you did before.

Step 3: Create an "Edit" button for each item in your mapped todos. This button should have an onClick handler that sets the todo and todoId state to the clicked item value.

{
  todos?.map((todo) => (
    <div key={todo.id}>
      <div>
        <p>{todo.todo}</p>
        <div>
          <button
            onClick={(e) => {
              setTodo(todo.todo);
              setTodoId(todo.id);
            }}
          >
            Edit
          </button>
        </div>
      </div>
    </div>
  ));
}

This allows seamless editing of todos directly within the input field. Upon form submission, the handleSubmit logic takes the id into account, ensuring proper handling of the read and update operation.

Delete Operation

In Supabase, you can delete specific table rows from your tables by chaining the from, delete, and eq query methods. Here's how to do this:

Step 1: Create a handleDelete function that accepts an id parameter. Within this function, chain the from method to specify the table to be queried, followed by the delete method and the eq method to identify the column name and row.

  const handleDelete = async (id: number) => {
    const { error } = await Supabase.from("todos").delete().eq("id", id);
    if (error) {
      console.log(error.message);
    } else {
      console.log("Deleted todo from Supabase DB");
      fetchTodos();
    }
  };

The item is removed from the table if the specified column name ("id") matches the provided id value. Otherwise, an error message is logged indicating that no such item exists in the table.

Step 2: Create a delete button for each mapped todo. The button should have an onClick handler that, when clicked, invokes the handleDelete function and passes the todo's id as an argument.

{
  todos?.map((todo) => (
    <div key={todo.id}>
      <p>{todo.todo}</p>
      <button onClick={() => handleDelete(todo.id)}>X</button>
    </div>
  ));
}

Conclusion

In this article, we looked at how to set up and connect Supabase in a Next application. We also covered performing CRUD operations on a Supabase database using its queries. With everything covered, you're now fully equipped to confidently set up and manage your own Supabase database within any Next application.

Nonetheless, to master all you've learned, consider taking up some more challenges. A great place to start is with the invoice challenge on Frontend Mentor.