DEV Community

Cover image for FaunaNote: A Serverless note-taking application with Fauna and Next.js
Azeez Lukman
Azeez Lukman

Posted on • Updated on

FaunaNote: A Serverless note-taking application with Fauna and Next.js

This article is published in connecton with the Write with Fauna program

You have probably come across note-taking applications like Notion or Evernote. This article will explore some of the core functionalities of a small note-taking application while introducing you to the Jamstack, Fauna, and the Fauna Query Language (FQL). We’ll discuss leveraging Next.js on the JamStack and the common patterns for reading and writing to the database. Basically, everything you need to get up and running with Fauna.

What will we be building?

We will be building Fauna Note: A Serverless note-taking application with Fauna and Next.js.

FaunaNote%20A%20Serverless%20note-taking%20application%20wit%206a3c33f3848d4e898d2447fa18232be4/image2.png

Try this deployed version of the app to get an idea of what we'll be building: https://fauna-notes.vercel.app/.

Why Serverless

Before the advent of serverless, websites were designed to give users data, and that data was stored in a database hosted on a server. Hosting this database ourselves means we were responsible for keeping it running and maintaining it and all of its stored data. Our database could only hold a certain amount of data which meant that if we were lucky enough to get a lot of traffic, it would soon struggle to handle all of the requests coming it’s way. As a result, our end users might experience some downtime or no data at all.

Serverless is a cloud computing model that manages resource distribution, allowing developers freedom from server provisioning and maintenance. This doesn't mean there are no servers, but server maintenance does not rely on the developer. Serverless computing runs code on-demand only, typically in a stateless container, on a per-request basis, and scales transparently with the number of requests being served. Serverless computing enabling you to pay only for resources being used, never paying for idle capacity.

This leads us to Jamstack.

Jamsatck

JavaScript, APIs and markup; this is the Jam in Jamstack sites. One major characteristic that distinguishes Jamstack sites from plain static sites is their use of data from APIs.

It's common to see jamstack sites use file-based data like Markdown and YAML, a Jamstack site also frequently uses things like a headless CMS or e-commerce.

Your Jamstack site might need to store and access data that doesn't fit into the CMS archetype. Headless CMS and API-driven services like Ghost.js fill many needs but are built for particular purposes. In this case, what we need is a... cloud database!

Fauna

Fauna is a next-generation cloud database that combines the simplicity of NoSQL without sacrificing the ability to model complex relationships. It’s completely serverless, fast, ACID-compliant, and has a generous free tier for small apps—basically, everything you could want in a fully managed database.

Fauna is also one of the only serverless databases to follow the ACID transactions, guaranteeing consistent reads and writing to the database. . Provides us with a High Availability solution with each server globally located containing a partition of our database, replicating our data asynchronously with each request with a copy of our database or the transaction made.

Intro to Fauna Query Language

Fauna Query Language (FQL) is Fauna's query language. It is a fully functioning transactional query language that you can use to query Fauna. It includes things like data types, built-in functions, and even user-defined functions. We will use FQL to query Fauna in this tutorial; although we would only cover a few of its concepts, FQL is much more powerful, and I recommend keeping the cheatsheet close.

Setup Fauna

If you do not already have an account, head over to Fauna to create a free account, the next step is to set up Fauna and create the necessary data models.

Create a database

The first step you need to take to setup Fauna is to create a new database using FQL in the Fauna shell, create a database called fauna-notes in Fauna:

Setup a collection

In Fauna, each document belongs to a specific collection. To store our notes, we need to first create a collection for Notes.

Create a collection using the param_object containing the name of the collection. name this collection "Notes":

q.CreateCollection({ name: 'notes' })
Enter fullscreen mode Exit fullscreen mode

Create an index

Indexes allow us to easily access our stored documents. Instead of reading through every document to find the one(s) that you are interested in, you query an index to find those documents.

We would create an index on the collection we just created, do this by creating an index using the CreateIndex function.

Note: The index name has to be unique.

q.CreateIndex({

name: 'posts_by_title',

source: q.Collection('posts'),

terms: [{ field: ['data', 'title'] }],

})
Enter fullscreen mode Exit fullscreen mode

The index we just created is known as a collection index: A index on virtual columns that extract data from the documents in the collection. With these in place, Fauna is ready to accept client connections. Let's head on to initialize our client and connect it to our database.

Setup Next.js boilerplate

I already built the basic app user interface with Next.js and chakra-UI. Currently, the static UI is unaware of the database and cannot make requests or get responses from the server, Our task will be to convert the "static" UI into a working real-time app in Next.js

Download and run the boilerplate

On the project repository, you will find the boilerplate code on the boilerplate branch; download it.

The repository is still being actively maintained, and the chances are that the code on the main is updated much more than we would cover in this article. You are welcome to make contributions as well.

Install the dependencies.

yarn install
Enter fullscreen mode Exit fullscreen mode

Start the static application.

yarn dev
Enter fullscreen mode Exit fullscreen mode

This should bring up the dev environment on port 3000

install faunadb package

yarn add fauna
Enter fullscreen mode Exit fullscreen mode

Connect Fauna to your Next.js application

We need to authorize the application to access the database, and we do this by creating a client key on Fauna and store it safely in Next.js.

create client key

create a server key from the Fauna security tab.

FaunaFNote%20A%20Serverless%20note-taking%20application%20wit%206a3c33f3848d4e898d2447fa18232be4/image1.png

Store the key

Enter this into .env.local, be sure to change the placeholder into your client key

FAUNA_SERVER_KEY = <your-api-key>
Enter fullscreen mode Exit fullscreen mode

The environment variables are set up, and we can now communicate with the database.

Next.js API routes

API routes provide a solution to build your API with Next.js, and you can build your entire API with API Routes. They are server-side only bundles and won't increase your client-side bundle size. Files inside the folder [pages/api] are treated as API endpoints, and this is where our queries and interactions with Fauna will reside.

Create note

This saves a new note into Fauna with an auto-generated id.

Create note API

Create a new file pages/notes/index.js and include this in this code:

+ import { query } from "faunadb";
+ const { Paginate, Index, Lambda, Match, Get, Var, Map, Create, Collection } = query;
+ import { serverClient } from "../../../utils/fauna-auth";

+ export default async function handler(req, res) {
+  // get all notes
+   if (req.method === "GET") {
+     try {
+       serverClient
+         .query(
+           Map(Paginate(Match(Index("all_notes"))), +            Lambda("X", Get(Var("X"))))
+         )
+         .then((ret) => {
+           res
+             .status(200)
+             .send({ message: "Fetched notes
+         successfully", data: ret.data });
+         })
+         .catch((error) => {
+           console.log({ error });
+           res.status(400).send({ error });
+         });
+     } catch (error) {
+       console.log({ error });
+       res.status(400).send({ error: { description: + error.message } });
+     }
+   }
+   //   create note
+   else if (req.method === "POST") {
+     const { title, content } = await req.body;
+
+     try {
+       if (!title) {
+         throw new Error("Title is required.");
+       }
+
+       serverClient
+         .query(
+           Create(Collection("notes"), {
+             data: { title, content },
+           })
+         )
+         .then((ret) => {
+           res
+             .status(200)
+             .send({ message: "created note
+  successfully", data: ret });
+         })
+         .catch((error) => {
+           console.log({ error });
+           res.status(400).send({ error });
+         });
+     } catch (error) {
+       console.log({ error });
+       res.status(400).send({ error: { description:
+ error.message } });
+     }
+   }
+ }
+
Enter fullscreen mode Exit fullscreen mode
  • A post method on this URL implies creating a note
  • The Create function generates a document in the specified collection. We specified "notes" collection, making a note document in the notes collection using an auto-generated is id since none was determined.

Create note page

Next, we make use of this API to create a new note in the UI. Update the pages/new file to become this:

import { AlertIcon } from "@chakra-ui/alert";
import { Alert } from "@chakra-ui/alert";
import { Button } from "@chakra-ui/button";
import { Input } from "@chakra-ui/input";
import { Grid } from "@chakra-ui/layout";
import { Text } from "@chakra-ui/layout";
import { Flex } from "@chakra-ui/layout";
+ import { useEffect } from "react";
+ import { useState } from "react";
import Content from "../components/content";
import Layout from "../components/layout";
+ import { useToast } from "@chakra-ui/react";
+ import { useRouter } from "next/router";

export default function New() {
+  const toast = useToast();
+  const router = useRouter();
   const [note, setNote] = useState({ title: "untitled" });
+  const [saving, setSaving] = useState(false);

+  function handleUpdateTitle(e) {
+    e.preventDefault();
+    setNote({ ...note, title: e.target.value });
+  }
+
+  // creates a new note
+  const handleCreate = async () => {
+    setSaving(true);
+    const { title, content } = note;
+    const response = await fetch("./api/notes", +{
+      method: "POST",
+      headers: { "Content-Type": "application/+json" },
+      body: JSON.stringify({ title, content }),
+    });
+
+    if (response.status !== 200) {
+      setSaving(false);
+      let description = await response.json().
+      then((data) => {
+        console.log(data);
+        return data.error.description || data.
+        error.message;
+      });
+
+      return toast({
+        title: "Error",
+        description,
+        status: "error",
+        duration: 9000,
+        isClosable: true,
+      });
+    }
+
+    response.json().then((resData) => {
+      // send a feedback alert
+      setSaving(false);
+      toast({
+        title: "Success",
+        description: resData.message,
+        status: "success",
+        duration: 9000,
+        isClosable: true,
+      });
+
+      // navigate to the page for created note
+      router.push(`/${resData.data.ref["@ref"].+id}`);
+    });
+  };

 return (
    <>
      <Layout>
        <Grid templateRows="auto 1fr" sx={{ position: "relative" }}>
          {/* nav */}
          <Flex alignItems="center" justifyContent="space-between" p={4}>
            {/* title */}
            <Input value={note.title} mr="5" onChange={handleUpdateTitle} />

            <Button
+              isLoading={saving}
+              loadingText="Saving"
+              onClick={handleCreate}
            >
              Save
            </Button>
          </Flex>

          <Content note={note} setNote={setNote} />
        </Grid>
      </Layout>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

OMG, what's going on here?

Let's break this stuff down:

  • HandleUpdateTitle event handler is responsible for updating the note's title, and the title is set to untitled by default. This ensures that a note is not saved into Fauna without a title.
  • handleCreate is an event handler tied to the click of the save button, this creates the new note in Fauna by calling the create API endpoint.

The content component should also be modified, let's update that:

import { Box } from "@chakra-ui/layout";
import { Textarea } from "@chakra-ui/textarea";
import React, { useEffect, useState } from "react";

export default function Content({ note, setNote }) {
+  function handleNoteChange(e) {
+    e.preventDefault();
+    setNote({ ...note, content: e.target.value });
+  }

  return (
    <>
      {/* content */}
      <Box p={4}>
        <Textarea
          placeholder="Start typing"
+         onChange={handleNoteChange}
          value={note.content}
          sx={{
            height: "100%",
            border: "none",
            resize: "none",
            ":focus": {
              outline: "none",
              border: "none",
              boxShadow: "none",
            },
          }}
        />
      </Box>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Note and setNote are passed into the Content page to keep the data in sync.

Get notes

This is to fetch all the notes, then display them on the sidebar, such that onClick of the note, it routes dynamically to the note detail page.

We created a collection index earlier, index all_notes. We would use this index to Match all our notes collection documents then display them on the sidebar

Get notes API

Open the API file we created earlier and create the handler for fetching notes.

import { query } from "faunadb";
const { Paginate, Index, Lambda, Match, Get, Var, Map, Create, Collection } =
  query;
import { serverClient } from "../../../utils/fauna-auth";

export default async function handler(req, res) {
+  // get all notes
+  if (req.method === "GET") {
+    try {
+      serverClient
+        .query(
+          Map(Paginate(Match(Index("all_notes"))), Lambda("X", Get(Var("X"))))
+        )
+        .then((ret) => {
+          res
+            .status(200)
+            .send({ message: "Fetched notes successfully", data: ret.data });
+        })
+        .catch((error) => {
+          console.log({ error });
+          res.status(400).send({ error });
+        });
+    } catch (error) {
+      console.log({ error });
+      res.status(400).send({ error: { description: error.message } });
+    }
+  }

  //   create note
  else if (req.method === "POST") {
    const { title, content } = await req.body;

    try {
      if (!title) {
        throw new Error("Title is required.");
      }

      serverClient
        .query(
          Create(Collection("notes"), {
            data: { title, content },
          })
        )
        .then((ret) => {
          res
            .status(200)
            .send({ message: "created note successfully", data: ret });
        })
        .catch((error) => {
          console.log({ error });
          res.status(400).send({ error });
        });
    } catch (error) {
      console.log({ error });
      res.status(400).send({ error: { description: error.message } });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • To handle different HTTP methods in the next.js API handler, we check the request method and handle it appropriately
  • The method, here, is a GET method
  • FQL Map function applies the Paginate function to all array items
  • The Paginate function returns a page of results for the documents matching the all_notes index. In this case, the Match function matches all the notes in the notes collection since all_notes is a collection index
  • After the request has processed, we respond with the status and data

Implement Get notes in the UI

Next, we want to display the notes returned from the API in the sideBar using a noteBar we would create.

Add this into the layout.js component.

import { IconButton } from "@chakra-ui/button";
import { Button } from "@chakra-ui/button";
import { FormControl } from "@chakra-ui/form-control";
import { SearchIcon, ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
import { Img } from "@chakra-ui/image";
import { Input } from "@chakra-ui/input";
import { Center, Stack } from "@chakra-ui/layout";
import { Flex } from "@chakra-ui/layout";
import { HStack } from "@chakra-ui/layout";
import { Grid } from "@chakra-ui/layout";
import { Field, Form, Formik } from "formik";
import React from "react";
import NoteBar from "./noteBar";
import { useRouter } from "next/router";
import { useState } from "react";
+ import { useEffect } from "react";
- import libNotes from "../lib/notes.json";
+ import { Spinner } from "@chakra-ui/spinner";
+ import { useToast } from "@chakra-ui/toast";

export default function Layout({ children }) {
+  const router = useRouter();
+  const toast = useToast();
+  const [error, setError] = useState("");
   const [notes, setNotes] = useState("");
+  const [loading, setLoading] = useState(true);

+  useEffect(() => {
+    getNotes();
+  }, []);

+  const getNotes = async () => {
+    setLoading(true);
+    const response = await fetch("./api/notes", {
+      method: "GET",
+      headers: { "Content-Type": "application/json" },
+    });
+
+    if (response.status !== 200) {
+      setLoading(false);
+      let description = await response.json().then((data) => {
+        console.log(data);
+        return data.error.description || data.error.message;
+      });
+
+      return toast({
+        title: "Error",
+        description,
+        status: "error",
+        duration: 9000,
+        isClosable: true,
+      });
+    }
+
+    response.json().then((resData) => {
+      setLoading(false);
+      setNotes(resData.data);
+    });
+  };

  return (
    <Grid templateColumns={"2fr 6fr"} h="100vh">
      {/* sidebar */}
      <Grid templateRows="1fr auto">
        <Grid templateRows="auto 1fr" bg="#f1f1f1">
          <Stack
            p={4}
            spacing="6"
            sx={{ display: "grid", gridTemplateRows: "auto 1fr" }}
          >
            <Img src="/logo.svg" height="64px" width="108px" />
            <Button
              mt={4}
              colorScheme="blue"
              onClick={() => router.push("/new")}
            >
              Create a new note
            </Button>
          </Stack>

          <Stack sx={{ overflowY: "scroll" }} px={3}>
+            {loading ? (
+              <Center>
+                <Spinner />
+              </Center>
+            ) : notes && notes.length > 0 ? (
+              notes.map((note) => <NoteBar key={note.ts} note={note} />)
+            ) : (
+              <span>No notes</span>
+            )}
-            {
-              notes && notes.length > 0 ? (
-                          notes.map((note) => <NoteBar key={note.ts} note= {note} />)
-            }
          </Stack>
        </Grid>

        <Flex py="2" alignItems="center" justifyContent="center" bg="gray.200">
          <Button colorScheme="pink">
            Logout
          </Button>
        </Flex>
      </Grid>

      {/* main */}
      {children}
    </Grid>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • The useEffect hook calls the getNotes function once the component has mounted
  • The getNotes function uses the fetch API to fetch the notes data from the API route
  • Update the Notes and loading states
  • Then shows a toast describing the status, on success or on error
  • We display a spinner if the API request is still pending
  • If the request is resolved, we check if the API returned notes
  • We then Render a NoteBar for each of the notes returned from the API

In noteBar component, we handle displaying the data and dynamically routing with the noteId.

import { IconButton } from "@chakra-ui/button";
import { DeleteIcon } from "@chakra-ui/icons";
import { Box, Flex } from "@chakra-ui/layout";
import React, { useState } from "react";
+ import { useRouter } from "next/router";
+ import { useToast } from "@chakra-ui/toast";

const NoteBar = ({ note }) => {
  const [focus, setFocus] = useState(false);
+  const router = useRouter();
+  const { noteId } = router.query;
+  const toast = useToast();

+  let noteBg =
+    noteId === note.ref["@ref"].id.toString()
+      ? "blue.100"
+      : focus
+      ? "gray.200"
+      : "";

  return (
    <>
      <Box
        sx={{ position: "relative" }}
        onMouseOver={() => setFocus(true)}
        onMouseLeave={() => setFocus(false)}
      >
        <Flex
+          onClick={() => router.push(`${note.ref["@ref"].id}`)}
          alignItems="center"
          justifyContent="space-between"
          h="50px"
          px={4}
          py={2}
+          bg={noteBg}
-          // bg={focus ? "gray.200" : ""}
          sx={{
            borderRadius: "5px",
            cursor: "pointer",
            textTransform: "capitalize",
          }}
        >
          <span>{note.data.title}</span>
        </Flex>

        {(focus || deleting) && (
          <IconButton
            sx={{ position: "absolute", right: "10px", top: "10px" }}
            size="sm"
            aria-label="delete note"
            icon={<DeleteIcon />}
          />
        )}
      </Box>
    </>
  );
};

export default NoteBar;
Enter fullscreen mode Exit fullscreen mode

export default NoteBar;

  • Note that we are now changing the background color of the by checking if the noteId: note.ref["@ref "].id.toString() is equal to the query noteId.
  • We also added a click event handler onClick={() => router.push(${note.ref["@ref "].id})} that routes to note details page

Get Note

When we dynamically route to a note using the noteId, the note would be fetched from the dynamic route in the API. Let's implement this:

Get note API

Create a dynamic route for noteId at api/index/[noteId], and handle the request appropriately for the request method

+ import { query } from "faunadb";
+ const { Collection, Ref, Get, Update, Delete } = query;
+ import { serverClient } from "../../../utils/fauna-auth";

+ export default async function handler(req, res) {
+  // get note
+  if (req.method === "GET") {
+    const { noteId } = req.query;
+
+    try {
+      serverClient
+        .query(Get(Ref(Collection("notes"), noteId)))
+        .then((ret) => {
+          res
+            .status(200)
+            .send({ message: "Fetched notes successfully", data: ret.data });
+        })
+        .catch((error) => {
+          console.log({ error });
+          res.status(400).send({ error });
+        });
+    } catch (error) {
+      console.log({ error });
+      res.status(400).send({ error: { description: error.message } });
+    }
+  }
Enter fullscreen mode Exit fullscreen mode
  • In Next.js API routes, we can handle different request methods by using an if block to check req.method and handle the request, this can also be done using a switch statement.
  • The Get function retrieves a single document identified by a Reference or by the Ref function.

Reflect the note in the UI

Routing to note/[noteId] displays the note data and also allows us to update the note content. Implement the dynamic note route in the UI.

import { Button } from "@chakra-ui/button";
import { Input } from "@chakra-ui/input";
import { Center, Text } from "@chakra-ui/layout";
import { Grid } from "@chakra-ui/layout";
import { Flex } from "@chakra-ui/layout";
+ import { Spinner } from "@chakra-ui/spinner";
+ import { useToast } from "@chakra-ui/toast";
+ import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import Content from "../components/content";
import Layout from "../components/layout";

export default function New() {
+  const toast = useToast();
  const [note, setNote] = useState("");
+  const [loading, setLoading] = useState(true);
+  const router = useRouter();
  const { noteId } = router.query;

+  useEffect(() => {
+    if (noteId) {
+      getNote();
+    }
+  }, [noteId]);
+
+  const getNote = async () => {
+    setLoading(true);
+    const response = await fetch(`./api/notes/${noteId}`, {
+      method: "GET",
+      headers: { "Content-Type": "application/json" },
+    });
+
+    if (response.status !== 200) {
+      setLoading(false);
+      let description = await response.json().then((data) => {
+        console.log(data);
+        return data.error.description || data.error.message;
+      });
+
+      return toast({
+        title: "Error",
+        description,
+        status: "error",
+        duration: 9000,
+        isClosable: true,
+      });
+    }
+
+    response.json().then((resData) => {
+      setLoading(false);
+      setNote(resData.data);
+    });
+  };

  function handleUpdateTitle(e) {
    e.preventDefault();
    setNote({ ...note, title: e.target.value });
  }

  return (
    <Layout>
+      {loading ? (
+        <Center>
+          <Spinner />
+        </Center>
+      ) : note ? (
        <Grid templateRows="auto 1fr" sx={{ position: "relative" }}>
          {/* nav */}
          <Flex alignItems="center" justifyContent="space-between" p={4}>
            {/* title */}
            <Input
              value={note ? note.title : "untitled"}
              mr="5"
              onChange={handleUpdateTitle}
            />

            <Button
              isLoading={updating}
              loadingText="Saving"
            >
              Save
            </Button>
          </Flex>

          <Content note={note} setNote={setNote} />
        </Grid>
+      ) : (
+        <p>Oops! This Note is not found</p>
+      )}
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • The useEffect hook runs the getNote function once, immediately the component mounts and every time the params.noteId changes
  • Get note fetches the dynamic API route /api/notes/${noteId}, for instance if the noteId in the params was "191282824" the dynamic endpoint resolves to /api/notes/191282824 this hits /api/notes/[noteId] and "191282824" the noteId is resolved as req.query.noteId in the API request handler.

Update Note

Update a note using the noteId specified in the dynamic route

Update note API

This is also implemented in the dynamic API route with the request method as PUT.

import { query } from "faunadb";
const { Collection, Ref, Get, Update, Delete } = query;
import { serverClient } from "../../../utils/fauna-auth";

export default async function handler(req, res) {
  // get note
  if (req.method === "GET") {
    const { noteId } = req.query;

    try {
      serverClient
        .query(Get(Ref(Collection("notes"), noteId)))
        .then((ret) => {
          res
            .status(200)
            .send({ message: "Fetched notes successfully", data: ret.data });
        })
        .catch((error) => {
          console.log({ error });
          res.status(400).send({ error });
        });
    } catch (error) {
      console.log({ error });
     res.status(400).send({ error: { description: error.message } });
   }
 }

+  // update note
+  else if (req.method === "PUT") {
+    const { noteId } = req.query;
+    const { title, content } = req.body;
+
+    try {
+      serverClient
+        .query(
+          Update(Ref(Collection("notes"), noteId), { data: { title, content } })
+        )
+        .then((ret) => {
+          res
+            .status(200)
+            .send({ message: "Updated note successfully", data: ret });
+        })
+        .catch((error) => {
+          console.log({ error });
+          res.status(400).send({ error });
+        });
+    } catch (error) {
+      console.log({ error });
+      res.status(400).send({ error: { description: error.message } });
+    }
+  }
Enter fullscreen mode Exit fullscreen mode
  • The Update function updates specific fields in the document specified with the Ref function. It preserves the old fields if they are not specified in params. In the case of nested values (known as objects, due to the JSON data format), the old and the new values are merged. If null is specified as a value for a field, it is removed.

Update note in the UI

On the note detail page or the dynamic note route, you can directly update either the title or the content and click the save button to update the note in Fauna.

import { Button } from "@chakra-ui/button";
import { Input } from "@chakra-ui/input";
import { Center, Text } from "@chakra-ui/layout";
import { Grid } from "@chakra-ui/layout";
import { Flex } from "@chakra-ui/layout";
import { Spinner } from "@chakra-ui/spinner";
import { useToast } from "@chakra-ui/toast";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import Content from "../components/content";
import Layout from "../components/layout";

export default function New() {
  const toast = useToast();
  const [note, setNote] = useState("");
  const [loading, setLoading] = useState(true);
  const router = useRouter();
  const { noteId } = router.query;
+  const [updating, setUpdating] = useState(false);

  useEffect(() => {
    if (noteId) {
      getNote();
    }
  }, [noteId]);

  const getNote = async () => {
    setLoading(true);
    const response = await fetch(`./api/notes/${noteId}`, {
      method: "GET",
      headers: { "Content-Type": "application/json" },
    });

    if (response.status !== 200) {
      setLoading(false);
      let description = await response.json().then((data) => {
        console.log(data);
        return data.error.description || data.error.message;
      });

      return toast({
        title: "Error",
        description,
        status: "error",
        duration: 9000,
        isClosable: true,
      });
    }

    response.json().then((resData) => {
      setLoading(false);
      setNote(resData.data);
    });
  };

  function handleUpdateTitle(e) {
    e.preventDefault();
    setNote({ ...note, title: e.target.value });
  }

  // updates the note
+  const handleUpdate = async () => {
+    setUpdating(true);
+    const { title, content } = note;
+    const response = await fetch(`./api/notes/${noteId}`, {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ title, content }),
+    });
+
+    if (response.status !== 200) {
+      setUpdating(false);
+      let description = await response.json().then((data) => {
+        console.log(data);
+        return data.error.description || data.error.message;
+      });
+
+      return toast({
+        title: "Error",
+        description,
+        status: "error",
+        duration: 9000,
+        isClosable: true,
+      });
+    }
+
+    response.json().then((resData) => {
+      // send a feedback alert
+      setUpdating(false);
+      toast({
+        title: "Success",
+        description: resData.message,
+        status: "success",
+        duration: 9000,
+        isClosable: true,
+      });
+    });
+  };

  return (
    <Layout>
      {loading ? (
        <Center>
          <Spinner />
        </Center>
      ) : note ? (
        <Grid templateRows="auto 1fr" sx={{ position: "relative" }}>
          {/* nav */}
          <Flex alignItems="center" justifyContent="space-between" p={4}>
            {/* title */}
            <Input
              value={note ? note.title : "untitled"}
              mr="5"
              onChange={handleUpdateTitle}
            />

            <Button
+              isLoading={updating}
+              loadingText="Saving"
+              onClick={handleUpdate}
            >
              Save
            </Button>
          </Flex>

          <Content note={note} setNote={setNote} />
        </Grid>
      ) : (
        <p>Oops! This Note is not found</p>
      )}
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • The handleUpdate handles clicking on the save button. It invokes the API handler for deleting a note by sending a PUT request to the dynamic API endpoint
  • While saving a saving message is displayed on the save button and the button is disabled to prevent multiple invocations

Delete Note

Delete a note using the noteId specified in the dynamic route

Delete note API

This handler is implemented in the dynamic API route with the request method as DELETE.

import { query } from "faunadb";
const { Collection, Ref, Get, Update, Delete } = query;
import { serverClient } from "../../../utils/fauna-auth";

export default async function handler(req, res) {
  // get note
  if (req.method === "GET") {
    const { noteId } = req.query;

    try {
      serverClient
        .query(Get(Ref(Collection("notes"), noteId)))
        .then((ret) => {
          res
            .status(200)
            .send({ message: "Fetched notes successfully", data: ret.data });
        })
        .catch((error) => {
          console.log({ error });
          res.status(400).send({ error });
        });
    } catch (error) {
      console.log({ error });
      res.status(400).send({ error: { description: error.message } });
    }
  }
  // update note
  else if (req.method === "PUT") {
    const { noteId } = req.query;
    const { title, content } = req.body;

    try {
      serverClient
        .query(
          Update(Ref(Collection("notes"), noteId), { data: { title, content } })
        )
        .then((ret) => {
          res
            .status(200)
            .send({ message: "Updated note successfully", data: ret });
        })
        .catch((error) => {
          console.log({ error });
          res.status(400).send({ error });
        });
    } catch (error) {
      console.log({ error });
      res.status(400).send({ error: { description: error.message } });
    }
  }

+  // delete note
+  else if (req.method === "DELETE") {
+    const { noteId } = req.query;
+
+    try {
+      serverClient
+        .query(Delete(Ref(Collection("notes"), noteId)))
+        .then((ret) => {
+          res
+            .status(200)
+            .send({ message: "Deleted note successfully", data: ret });
+        })
+        .catch((error) => {
+          console.log({ error });
+          res.status(400).send({ error });
+        });
+    } catch (error) {
+      console.log({ error });
+      res.status(400).send({ error: { description: error.message } });
+    }
+  }
}
Enter fullscreen mode Exit fullscreen mode
  • The Delete function works very similarly to the update method except that remove the specified document from the collection.

Implement delete note UI

In the sideBar, when you hover on each of the noteBars, the delete button becomes visible, let’s create an event handler that handles deleting the note with a click of the delete Icon.

Update the noteBar component:

import { IconButton } from "@chakra-ui/button";
import { DeleteIcon } from "@chakra-ui/icons";
import { Box, Flex } from "@chakra-ui/layout";
import React, { useState } from "react";
import { useRouter } from "next/router";
import { useToast } from "@chakra-ui/toast";

const NoteBar = ({ note }) => {
  const [focus, setFocus] = useState(false);
  const router = useRouter();
  const { noteId } = router.query;
  const [deleting, setDeleting] = useState(false);
  const toast = useToast();

+  const handleDelete = async () => {
+    setDeleting(true);
+    const response = await fetch(
+      `./api/notes/${note.ref["@ref"].id.toString()}`,
+      {
+        method: "DELETE",
+        headers: { "Content-Type": "application/json" },
+      }
+    );
+
+    if (response.status !== 200) {
+      setDeleting(false);
+      let description = await response.json().then((data) => {
+        console.log(data);
+        return data.error.description || data.error.message;
+      });
+
+      return toast({
+        title: "Error",
+        description,
+        status: "error",
+        duration: 9000,
+        isClosable: true,
+      });
+    }
+
+    response.json().then((resData) => {
+      setDeleting(false);
+      toast({
+        title: "Success",
+        description: resData.message,
+        status: "success",
+        duration: 9000,
+        isClosable: true,
+      });
+    });
+
+    // if the current note in view was deleted go home
+    if (noteId === note.ref["@ref"].id.toString()) {
+      router.push("/");
+    }
+  };

  let noteBg =
    noteId === note.ref["@ref"].id.toString()
      ? "blue.100"
      : focus
      ? "gray.200"
      : "";

  return (
    <>
      <Box
        sx={{ position: "relative" }}
        onMouseOver={() => setFocus(true)}
        onMouseLeave={() => setFocus(false)}
      >
        <Flex
          onClick={() => router.push(`${note.ref["@ref"].id}`)}
          alignItems="center"
          justifyContent="space-between"
          h="50px"
          px={4}
          py={2}
          bg={noteBg}
          // bg={focus ? "gray.200" : ""}
          sx={{
            borderRadius: "5px",
            cursor: "pointer",
            textTransform: "capitalize",
          }}
        >
          <span>{note.data.title}</span>
        </Flex>

  +      {(focus || deleting) && (
          <IconButton
            sx={{ position: "absolute", right: "10px", top: "10px" }}
            isLoading={deleting}
            onClick={handleDelete}
            size="sm"
            aria-label="delete note"
            icon={<DeleteIcon />}
          />
  +      )}
      </Box>
    </>
  );
};

export default NoteBar;
Enter fullscreen mode Exit fullscreen mode
  • Using the onMouseOver and onMouseLeave we handle when the noteBar is hovered and we track this using the focus state, we set focus to true on mouseOver and focus to false onMouseLeave
  • If focus is true, we display the delete button to the user
  • The handleDelete method handles the click of the delete button, this function invokes the handler we created earlier in the dynamic API endpoint at /api/notes/[id] using the delete method, with note.ref["@ref "].id.toString() being the note id.
  • We properly handle the response
  • We also check to see if the note that is currently being viewed was deleted and route to the base route since the note no longer exists

And that’s it! I hope you enjoyed it and learned something along the way as I did. We have built a note-taking application on the Jamstack that can be accessed from anywhere and any device.

Next Steps

The next step you probably want to take is to implement authentication to enable users to interact with their notes. You can implement more features for markdown files, support for blob files like images and audio files. You should put your knowledge to practice, feel free to submit a pull request to this repository

If you liked this article, follow on Twitter @robogeek95

Top comments (0)