June 19, 2021

How to Create a Todo List with React and Sanity

By Joe Holmes

Ever wondered if you could build an entire app on the Sanity platform? Wonder no more! In this guide, we'll build a to-do application from scratch using Sanity as our back end.

Sanity is often described as a "headless CMS"—a content management system decoupled from its presentation, allowing a developer to create any kind of frontend they desire.

But most "headless CMS" platforms are rather limited in what they can accomplish. They're usually designed to be blogs, e-commerce stores, or marketing pages—try and do something more avant-garde and you may soon find yourself in the weeds.

Not so with Sanity! With its incredibly powerful APIs, Sanity can also compare favorably to "managed databases" such as Firebase and Supabase. Systems like these are designed for more complex, technical applications that require user authentication and dynamically presented data. Sanity can hold its own here!

Don't believe me? Let's build an app.

We'll be coding a user authenticated to-do list app in JavaScript and React with Next.js, Magic.link, and Sanity on the backend.

I've deployed a demo version if you want to check it out yourself.

Requirements

Clone starter

I created a simple starter project so we wouldn't lose time writing boilerplate. You can clone it by navigating to your preferred directory and running the following command.

git clone https://github.com/bathrobe/magic-next-sanity-todo-starter.git

The starter comes with Tailwind CSS for styling and has a basic user authentication flow already implemented, so we won't need to dive into those challenging waters in this post. The auth code is all taken from a very helpful tutorial on Vercel's website, so consult that if you'd like to know the details.

Let's first install our project's dependencies by running cd magic-next-sanity-todo-starter and then npm install .

When that's done, create a .env.local file at the root of your project. We'll add the keys one by one.

Register for Magic.link

Magic.link uses cutting-edge cryptography to take the headache out of user authentication, which is usually one of the most dreaded developer tasks. They utilize some cookie-based blockchain wizardry to remove the need for passwords or outside auth. You do not need to understand how its innards work in order to use it. Just enjoy!

Sign up for the free plan at https://dashboard.magic.link/signup. At the time of writing, you're permitted 100 users on the free plan, so it's perfect for our purposes.

Get your public and secret test keys. You'll need an encryption secret too. You can go to this Encryption Key Generator and choose any option above 256 bit.

// .env.local

NEXT_PUBLIC_MAGIC_PUB_KEY=pk_test_12345
MAGIC_SECRET_KEY=sk_test_12345
ENCRYPTION_SECRET=random_encryption_string

Deploy a todo schema from Sanity

If you have not yet done so, remember to download the Sanity CLI. Open your terminal and enter the following code.

npm install -g @sanity/cli

If you don't already have a studio you'd like to work with, run sanity init from the command line and create a new one.

The command line will ask you for the name of your studio and where you want to store the studio files. In this example, I named it "your-studio" and installed it in the same folder where I stored the starter repo.

When it asks you what schema you'd like to start with, choose the option to install a clean schema with no pre-existing data.

Take note of what you named the dataset, as you'll add it to the .env file later.

Now we'll write a todo schema in our schemas folder.

// your-studio/schemas/todo.js

export default {
    name: "todo",
    title: "Todo",
    type: "document",
    fields: [
      {
        name: "text",
        title: "Todo Text",
        type: "string",
      },
      {
        name: "createdAt",
        title: "Created at",
        type: "datetime",
      },
      {
        name: "dueDate",
        title: "Due date",
        type: "datetime",
      },
      {
        name: "isCompleted",
        title: "Is completed?",
        type: "boolean",
      },
      {
        name: "completedAt",
        title: "Completed At",
        type: "datetime",
      },
      {
        name: "userEmail",
        title: "User Email",
        type: "string",
      },
    ],
  };

Add the schema to your schema.js file.

// your-studio/schemas/schema.js

import createSchema from "part:@sanity/base/schema-creator";
import schemaTypes from "all:part:@sanity/base/schema-type";
//...
import todo from "./todo"

export default createSchema({
  name: "default",
  types: schemaTypes.concat([
		//...
    todo
  ]),
});

Run sanity deploy, choose a unique name for your deployed studio, and soon it should be live.

Protip

Technically, you don't need to deploy your studio—simply accessing the document store and using the APIs is enough for these purposes. But we'll watch the studio change in real-time sync with the frontend later, which is worth the trouble!

Get Sanity permissions

Go to sanity.io/manage and head to Settings > API > Tokens. Add a new token with "Editor" permissions and save the key somewhere safe.

Directly above the tokens field, add http://localhost:3000 to your CORS Origins. (If you deploy the app, you'll need to add your live URL to this list as well).

You can find your project ID at the top of this page, right under the title of your studio. Add all your Sanity variables to your .env.local.

// .env.local

NEXT_PUBLIC_MAGIC_PUB_KEY=pk_test_12345
MAGIC_SECRET_KEY=sk_test_12345
ENCRYPTION_SECRET=random_encryption_string_from_earlier
NEXT_PUBLIC_SANITY_ID=your_sanity_id
NEXT_PUBLIC_SANITY_DATASET=your_sanity_dataset
SANITY_WRITE_KEY=your_sanity_write_key

All right, we're configured and ready to start coding! Run npm run dev in your project's root and test it out.

Creating the input form

Let's start with the HTML submit the form that sends our todos to the studio. Later we'll fetch the data and display it on our frontend.

We can look at the finished product to see what we'll be adding.

An input form, a date picker, and a submit button walk into a bar...

First, we'll use the useState() hook to store the values of our submit the form and todo list.

Creating states to store form values

Let's import our hook as well as the react-date-picker library. Then we'll add our states.

// src/pages/todos.js

import { useState } from "react";
//we must import the datepicker's css modules manually
//so it plays nice with Next.
import DatePicker from "react-date-picker/dist/entry.nostyle";
import "react-date-picker/dist/DatePicker.css";
import "react-calendar/dist/Calendar.css";
import useAuth from "../hooks/useAuth";
import Logout from "../components/Logout";

export default function Todos() {
  const { user, loading } = useAuth();
	//create a state to store new  for todos
  const [todoList, setTodoList] = useState([]);
  //create a state for the text in the todo input form
  const [userInput, setUserInput] = useState("");
  //create a state for the due date chosen in the datepicker
  const [dueDate, setDueDate] = useState("");
  //set an error message if either input is missing
  const [errMessage, setErrMessage] = useState("");
	//...

Note the existence of our custom useAuth() hook at the top. More information about custom hooks (as well as a helpful refresher on all things React hooks!) can be found in this Fireship video.

Next we'll define some handlers for our HTML form input and submission button.

Create handleSubmit and handleChange functions

// src/pages/todos.js

//... right after the useState hooks

//FOR THE INPUT FORM:
const handleChange = (e) => {
    e.preventDefault();
    setUserInput(e.target.value);
  };
	
	//FOR THE SUBMIT BUTTON:
  const handleSubmit = async (e) => {
    e.preventDefault();
    //if either part of the form isn't filled out
    //set an error message and exit
    if (userInput.length == 0 || dueDate == "") {
      setErrMessage("Todo text and due date must be filled out.");
    } else {
      //otherwise send the todo to our api
			// (we'll make this next!)
      await fetch("/api/todo", {
        method: "POST",
        body: JSON.stringify({
          text: userInput,
          dueDate: dueDate,
          user: user.email,
        }),
      });
      // await fetchTodos(); //(we'll add this later)
      // Clear all inputs after the todo is sent to Sanity
      setUserInput("");
      setErrMessage("");
      setDueDate("");
    }
  };

handleChange stores our text input in a state. handleSubmit first makes sure both of our fields have been filled out, then posts the todo to our serverless API route and clears out the inputs.

You'll notice two things we reference that don't exist yet: the api/todo route and a fetchTodos() function. Fret not! We'll get to these in the next section. For now, we'll finish rendering our form.

Creating the input form

Replace <p>Todo app will go right here!</p> with the form element below.

// src/pages/todos.js

				{/*...*/}
				<form>
					{/*we flex the text input and datepicker
					so they display inline. */}
          <div className="flex justify-center items-center">
					<label for="todo" className="invisible">Your Todo</label>
            <input
              className="w-72 h-12 border p-4 border-blue-100"
              type="text"
							//our state
              value={userInput}
              placeholder="Make coffee."
							//our function
              onChange={handleChange}
            />
            <div className="my-8">
              <DatePicker
                className="p-4"
								//makes it so we cannot set due date in past
                minDate={new Date()}
								//our dueDate state
                onChange={setDueDate}
                value={dueDate}
              />
            </div>
          </div>{" "}
          <button
            className="focus:outline-none focus:ring focus:border-blue-800
						px-6 py-2 rounded-xl bg-blue-500 text-blue-50 hover:bg-blue-800 
						font-semibold"
						//our function
            onClick={handleSubmit}
          >
            Submit
          </button>
					{/*error set in handleSubmit*/}
          <p>{errMessage}</p>
        </form>
				{/*...*/}

You should now see a lovely todo input form, with a stylish button lifted almost entirely from Tailwind's helpful docs.

Remember the serverless API route from earlier? Let's build that now.

Make our API route

Create src/pages/api/todo.js. Since we'll want full CRUD functionality in our app, we'll need PUT and DELETE requests later. To keep our code clean, we'll use the switch syntax for the different request types.

// src/pages/api/todo.js

import client from "../../lib/sanity/client";

export default async function handler(req, res) {
  switch (req.method) {
    case "POST":
      //this JSON arrives as a string,
      //so we turn it into a JS object with JSON.parse()
      const newTodo = await JSON.parse(req.body);
      //then use the Sanity client to create a new todo doc
      try {
        await client
          .create({
            _type: "todo",
            text: newTodo.text,
            isCompleted: false,
            createdAt: new Date().toISOString(),
            dueDate: newTodo.dueDate,
            userEmail: newTodo.user,
          })
          .then((res) => {
            console.log(`Todo was created, document ID is ${res._id}`);
          });
        res
          .status(200)
          .json({ msg: `Todo was created, document ID is ${res._id}` });
      } catch (err) {
        console.error(err);
        res.status(500).json({ msg: "Error, check console" });
      }

      break;
  }
}

With that, we should be able to submit a todo from our app and have it stored in our Sanity studio. Let's try it out.

Run npm run dev once more and open up your Sanity studio. If all went well, you should see your new todo inside of Sanity.

Still haven't completed this task.

Success!

Great job! Now that Sanity is storing our todo values, we can fetch them and display them on our frontend.

Display our todos

We'll define a function that will fetch the latest todos from Sanity and store them in the todoList state. We want the function to run when the page loads, whenever user data changes, and any time we alter the data (by adding, updating, or deleting todos). To manage this logic, we'll begin by adding a useEffect hook.

Head back to pages/todos.js and import useEffect, as well as our Sanity client.

//src/pages/todos.js

import { useState, useEffect } from "react";
// ...
import client from "../lib/sanity/client";

Right below your useState hooks add the fetchTodos function and tack it onto a useEffect hook.

//src/pages/todos.js

//after the useState hooks
const fetchTodos = async () => {
    let fetchedTodos;
		//make sure the user is loaded
    if (!loading) {
			//pass userEmail as a query parameter
      fetchedTodos = await client.fetch(
			`*[_type=="todo" && userEmail==$userEmail] | order(dueDate asc)
				{_id, text, createdAt, dueDate, isCompleted, completedAt, userEmail}`,
        {
          userEmail: user.email,
        });
			//insert our response in the todoList state
      setTodoList(fetchedTodos);
    }
  };

  useEffect(
    () => {
			//now it will fetch todos on page load...
      fetchTodos();
    },
    //this dependency array tells React to run the
		//hook again whenever the user loads or changes
    [loading, user]
  );

Uncomment fetchTodos() in the handleSubmit function. Next polyfills the fetch API for all browsers beforehand, so no need to worry about that!

// src/pages/todos.js
// in handleSubmit function...
      await fetch("/api/todo", {
        method: "POST",
        body: JSON.stringify({
          text: userInput,
          dueDate: dueDate,
          user: user.email,
        }),
      });
      //***uncomment this line now***
			//after submitting, our TodoList will now refresh
      await fetchTodos();
			setUserInput("");
      setErrMessage("");
      setDueDate("");
    }
  };
//...

Immediately after handleSubmit, let's also add a handleDelete function that we can pass to our <Todo/> component. Since we're passing this function straight from the page to <Todo/> (skipping the <TodoList/> component), we should use React's useContext hook. Context allows us to avoid passing props unnecessarily.

We'll also pass fetchTodos() to our context hook so that we can get fresh data when we toggle a todo's status in its component.

// src/pages/todos.js

import { useState, useEffect, createContext } from "react";
//... before the Page component
export const TodoContext = createContext()

export default function Todos() { //...

// then, below the handleSubmit function...

const handleDelete = async (selectedTodo) => {
    await fetch("/api/todo", {
      method: "DELETE",
      body: selectedTodo._id,
    });
		//todos will refresh after delete, too
    await fetchTodos();
  };

	console.log(todoList)

  return (
    <TodoContext.Provider value={{handleDelete, fetchTodos}>
		{/* all your rendered JSX */}
		</TodoContext.Provider>

Check your console to make sure we fetched it all correctly. After you've confirmed it works, feel free to delete the console log.

There's no joy quite like seeing exactly what you hoped for in your console log.

Very good! It's right there waiting for us. Now that we've fetched the data and built our basic state management, we need components to feed it to. That's what we'll build next.

Creating React todo components

Now we need to create our React components. First, create a components folder in src. In it, create a new file called TodoList.js. This will be a simple functional component that mainly exists to keep our todos.js page a little cleaner.

// src/components/TodoList.js

import Todo from "./Todo";

export default function TodoList({ todoList, user }) {
  return (
    <section>
      <ul>
			{/*if there are todos in the list...*/}
        {todoList.length >= 1
          ? todoList.map((todo, idx) => {
							//map only the user's todos
              return user.email == todo.userEmail ? (
                <Todo key={todo._id} todo={todo} />
              ) : (
                ""
              );
            })
          : "Enter a todo item"}
      </ul>
    </section>
  );
}

We pass the todoList state in our page component to <TodoList/>, which maps each item in the array to a <Todo/> component.

JavaScript's ternary operators may look tricky, but they're only saying, "If the todo list has one or more items, go through each item, double-check that they belong to the current user, then render them with our custom Todo component."

The unique ID from Sanity is passed as the key prop in the Todo because React requires it. React has more information about the key prop in their docs.

Now we need to make Todo.js. In the same folder, create that file. Remember the context we created in todos.js? We can now put it into action.

// src/components/Todo.js

import { useState, useContext } from "react";
// import a simple date formatting library
import dayjs from "dayjs";
// import a trashcan icon for our delete button
import { RiDeleteBin5Line } from "react-icons/ri";
import { TodoContext } from "../pages/todos"

export default function Todo({ todo }) {
//with useContext we do not need to pass extra props to <TodoList/>
const { handleDelete, fetchTodos } = useContext(TodoContext)
	//setting states for the isCompleted boolean and a date completed
  const [isCompleted, setIsCompleted] = useState(todo.isCompleted);
  const [completedTime, setCompletedTime] = useState(todo.completedAt);
	
	//function that syncs the completed checkbox with Sanity
  const handleToggle = async (e) => {
    e.preventDefault();
    const result = await fetch("/api/todo", {
      method: "PUT",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        id: todo._id,
				//passes isCompleted React state to Sanity
        isCompleted: isCompleted,
        completedAt: todo.completedAt,
      }),
    });

    const { status, completedAt } = await result.json();
		// refresh our data
		await fetchTodos();
		//pass our Sanity results back into React
    setIsCompleted(status);
    setCompletedTime(completedAt);
  };
  return (
    <li
      className="bg-gray-50 my-6 border shadow-md rounded-xl 
			p-4 border-gray-200 flex justify-center items-center"
      key={todo._id}
    >
      <input
        className="mx-2 cursor-pointer"
        type="checkbox"
        checked={todo.isCompleted}
        onChange={handleToggle}
      />
			{/*if todo is done, cross it out and turn it gray*/}
      <p
        className={`text-lg mx-2 ${
          todo.isCompleted ? "line-through text-gray-500" : ""
        }`}
      >
        {todo.text}
      </p>
      <p className={`text-gray-400 mr-2`}>
				{/*if todo is done, show completedTime
					if not done, show due date */}
        {todo.isCompleted
          ? `Done ${dayjs(completedTime).format("MMM D, YYYY")}`
          : `Due ${dayjs(todo.dueDate).format("MMM D, YYYY")}`}
      </p>
      <button
        className="mx-2"
        onClick={(e) => {
          e.preventDefault();
          handleDelete(todo);
        }}
      >
        <RiDeleteBin5Line />
      </button>
    </li>
  );
}

Excellent work! All we need to do is add the list items to our page and they should render.

Updating todos.js

// src/pages/todos.js

// ... at the bottom of the imports
import TodoList from "../components/TodoList"

// ... then directly under the form
				<form> {/*...*/> </form>
				<div className="my-12">
          <h1 className="text-xl font-bold tracking-tight 
					my-8">Your Todos</h1>
          {loading ? (
            "loading..."
          ) : (
            <TodoList
              user={user}
              todoList={todoList}
            />
          )}
        </div>
//...

Now run npm run dev and you should see your todo items appear.

👨‍🍳 😗

If you try to toggle or delete them you'll throw an error, because we haven't added the PUT and DELETE API routes yet. Let's do those now.

Add the new routes to our API

// src/pages/api/todo.js

//...
//after the POST request

case "PUT":
      const result = await client
        .patch(req.body.id)
        .set({
          isCompleted: !req.body.isCompleted,
					//create new complete date if Todo is marked as done
          completedAt: !!req.body.isCompleted ? "" : new Date().toISOString(),
        })
        .commit();
      res.status(200).json({
        status: result.isCompleted,
        completedAt: result.completedAt,
      });

      break;
case "DELETE":
      await client
        .delete(req.body)
        .then((res) => {
          res.body;
        })
        .then((res) => console.log(`Todo was deleted`));
      res.status(200).json({ msg: "Success" });
      break;
//...

If your dev environment is still running, stop and start it again with ctrl/command C and npm run dev. You should now be able to mark todos complete and delete them.

The world's most user-friendly database

If all went well, take a look at your Sanity studio. Your content should update in real-time when the frontend input changes and vice versa.

The Sanity Studio is in sync with the app in real-time. So cool!

Now you've got the developer experience of a managed database with the friendly, customizable UI of a content manager. Pretty sweet.

I've made the completed repo public if you want to give it a look.

Next steps for your new todo list app

Hopefully, this project can serve as a starting point for more ambitious uses of Sanity. There are plenty of ways to improve the app.

  • Right now the only way todos relate to their users is by passing in the user email as a field in individual todos. We should create a user.js document in schemas that allows each person to create their own profile. Then that document could be related to each todo of a given user via a reference field.
  • Completed todos could be sent to their own list in an archive, instead of being displayed in the same spot on the Todo List component. In other words, instead of all todos appearing under "Your Todos", you could have separate lists for "Pending Todos" and "Completed Todos". This would be great practice if you're trying to develop more advanced React skills.
  • For a fun challenge, could you add a pomodoro timer to specified todo lists and display the countdown in the studio? I've found dividing my work periods into smaller, timed chunks has been a big help in staying productive.

As always, feel free to say hi on Twitter. Happy coding!

Other guides by author

Build an e-commerce site with the Stripe API

In this tutorial, we'll make a no-frills "merch store" for a personal blog using the Stripe API, Next.js, and Sanity. We'll leverage Stripe's fantastic checkout platform to redirect our customers to Stripe's servers for their checkout process.

Joe Holmes
Go to Build an e-commerce site with the Stripe API