DEV Community

Cover image for Build a basic social graph with Fauna and Next.js
Azeez Lukman
Azeez Lukman

Posted on • Updated on

Build a basic social graph with Fauna and Next.js

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 possibly want in a fully-managed database.

This tutorial demonstrates how to model a basic social graph with Fauna and access it on the web with Next.js. We would build out some of the functions a small social network requires to work while introducing you to Fauna Query Language (FQL), the common patterns for reading and writing to the database and everything you need to get up and running with Fauna.

Feel free to make refrence to the source code as we progress

Excited Already? when you're ready, let's hop in...

Fauna Setup

To get started, go to the Fauna Dashboard and sign up for a free Fauna account if you do not already have an account. Once you are in the Dashboard, click New Database, enter a name for the database, and Save. You should now be on the "Overview" page of your new database.

Database Structure

We would create some collections to work with. A collection is like an SQL table, and works very similar to other document-oriented databases like MongoDB, just view a collection as a folder that has many files, then you can run a query against the folder to filter out the files you need.

To create a collection, go to the Fauna Dashboard and click on collections, then click on new collection, name your collection(usually in a plural form) and save it.

You would notice the TTL(time to live) option which automatically deletes the data you no longer need this type of data is also known as ephemeral data

You might also notice the History option, this is another unique feature of Fauna. Fauna never updates a stored document directly but instead, it creates a copy of the original document with the changes, then it archives the original document. This is a cool feature that could come in handy for data changes time travelling.

Create collections

The database contains three collections - usersposts, and followers. Go ahead and create these collections from the Fauna dashboard.

Screenshot_from_2021-04-25_04-57-49

Setting-up: Following relationships

It’s time to add the following relationships between our users. In this tutorial, we will use a follower/followee relationship in which a user can follow another user, this is not necessarily reciprocal.

From Fauna's dashboard create a new collection called followers*.* You would also create three new indexes for followers collection: followers_by_followee returns all the followers of a user, the followees_by_follower index will return the people a user is following (followees). The third index called is_followee_of_user, we would use this index to determine if a user is already following another user and make unique the document related to the following condition.

Create Index followers_by_followee

fauna-index-followers-by-followee_lQusBDtoC
6f/fauna-index-followers-by-followee_lQusBDtoC.png)

you can Create the from the Fauna’s shell using CreateIndex method:

CreateIndex({
  name: "followers_by_followee",
  unique: false,
  serialized: true,
  source: Collection("Followers"),
  terms: [
    {
      field: ["data", "followee"]
    }
  ],
  values: [
    {
      field: ["data", "follower"]
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

Create Index followees_by_follower

fauna-index-followees-by-follower_1Bog9IVqk

also with Fauna Shell:

CreateIndex({
  name: "followees_by_follower",
  unique: false,
  serialized: true,
  source: Collection("Followers"),
  terms: [
    {
      field: ["data", "follower"]
    }
  ],
  values: [
    {
      field: ["data", "followee"]
    }
  ]
})

Enter fullscreen mode Exit fullscreen mode

Create the Index is_followee_of_user

Fauna-index-is-following-user_x1eGFrWh5

with the fauna shell:

CreateIndex({
  name: "is_followee_of_user",
  unique: true,
  serialized: true,
  source: Collection("followers"),
  terms: [
    {
      field: ["data", "follower"]
    },
    {
      field: ["data", "followee"]
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

Create index posts_by_owner.

This index is for the posts collection. It would have the field owner as term and the value field will be empty.

fauna-index-post-by-owner_fpLE6O_WC

To create this index with the fauna shell

CreateIndex({
  name: "posts_by_owner",
  unique: true,
  serialized: true,
  source: Collection("posts"),
  terms: [
    {
      field: ["data", "owner"]
    }
  ]
})
Enter fullscreen mode Exit fullscreen mode

Now, our database can contain something more than users.

With these, we have used indexes to prepare our database to handle follower/followee relationships between all users. The next step is to create some functions to authenticate users, follow users and to create posts

User Defined Functions(UDF)

User-defined functions (UDF) are Fauna Query Language(FQL) sort of anonymous functions. UDFs are a great way to improve your site's performance, reduce redundancy in code, reduce latency on requests and promote a cleaner code.

Fauna allows you to create your own functions using the FQL methods described here, this tutorial would only be covering a few of these methods. I recommend keeping the cheatsheet close.

On the Fauna dashboard, open the functions menu and click on New Function.

fauna-new-function_TpAKX9HLt6

You should see an example snippet Fauna provides by default and it should be similar to this

Query(
  Lambda(
    "x", Add(Var("x"), Var("x"))
  )
)

Enter fullscreen mode Exit fullscreen mode

Let's Break it down:

  • Query: it’s only parameter is a lambda function and its purpose is to prevent the lambda function for immediate execution. It encases the function definition.
  • Lambda: this method has two parameters, the first one is the set of arguments the function can get (in this case, x), and the second one is the lambda function, which means the commands we will execute. All argument names should be strings, also, if you need to use more than one parameter, you should put all names in an array (e.g [“x”,”y”]).
  • Add: In the example code provided by Fauna, they use Add as the only method used, this returns the arithmetic sum of all the arguments. However, we will change this part to log the user in.
  • Var: This method is invoked whenever we make a reference to an existing variable, it accepts the name of the variable as a string as an argument.

Signup User Function

Now that you get the picture, update the default function Fauna provided into a function for creating users, name our function signupUser and save it.

Query(
  Lambda(
    ["email","password"],
    Let(
      {
        user:Create(
          Collection("Users"),
          {
            credentials: { password: Var("password") },
            data: {
              email: Var("email"),
              posts: 0,
              activeSince: Now()
            }
        }),
       userRef: Select(
          "ref",
          Var("user")
        ),
      },
      Login(Var("userRef"), {
        password: Var("password"),
        data: {
          message: "logged in"
        }
      })
    )
  )
)

Enter fullscreen mode Exit fullscreen mode

The parameters in the Lambda function demonstrates how to accept more than one variable. In this case, email is the user’s email and password is the user’s password. The method Let allows you to create an object with temporal variables (represented as the object’s fields) and use them in the second argument by calling the method Var. We create a field named user and define it as the response for creating a new user on the Users collection with the data provided and some additional fields (for reference). The response of Create is the created document.

We also create a field called userRef in which we Select the field ref of our newly created user. After defining our binding variables, we set the second parameter of Let to Login the user, this means, the Let method will return the result of Login. When you login a user, you can provide additional data, we did put a field called message and put the string logged in.

Right now the UDF doesn’t seem as much, but when we start expanding our database, we will have more efficiency by having UDFs instead of performing many database queries.

You can make these functions available in your repository by adding them to a setup file. This way, when you are setting up a similar database for another server, you can recreate the same structure with just a single command. But we would not cover that in this tutorial. You can adapt this example from Fauna’s developer team.

Follow users function

CreateFunction({
  name: ‘followUsers’
  role: null,
  body: Query(
    Lambda(
      "followee",
      If(
        IsEmpty(
          Match(Index("is_followee_of_user"), [Identity(), Var("followee")])
        ),
        Do(
          Create(Collection("Followers"), {
            data: { follower: Identity(), followee: Var("followee") }
          }),
          { isFollowing: true }
        ),
        Do(
          Delete(
            Select(
              ["data", 0],
              Paginate(
                Match(Index("is_followee_of_user"), [
                  Identity(),
                  Var("followee")
                ])
              )
            )
          ),
          { isFollowing: false }
        )
      )
    )
  )
})

Enter fullscreen mode Exit fullscreen mode

This function toggles the follow/unfollow state of the users. If you already follow a user, you’ll stop following the user, if you are not a follower, you’ll become one. Also, this function returns the new following status as true or false.

Create Post Function

CreateFunction({
  name: "createPost",
  role: null,
  body: Query(
    Lambda(
      "description",
      Create(Collection("Posts"), {
        data: {
          description: Var("description"),7 I'll
          date: Now(),
          owner: Identity()
        }
      })
    )
  )
})

Enter fullscreen mode Exit fullscreen mode

With this function, you can create a new post and put initial values like the date it was posted as well as set the number of likes and comments to 0.

List Accounts Function

CreateFunction({
  name: "listAccounts",
  role: null,
  body: Query(
    Lambda(
      "cursor",
      Map(
        Paginate(Reverse(Documents(Collection("Users"))), {
          after: Var("cursor")
        }),
        Lambda("ref", {
          userId: Select("id", Var("ref")),
          isFollowee: IsNonEmpty(
            Match(Index("is_followee_of_user"), [Identity(), Var("ref")])
          ),
        isSelf: Equals(Identity(), Var("ref"))
        })
      )
    )
  )
})

Enter fullscreen mode Exit fullscreen mode

This function returns all the user accounts, Paginate will return a page of 64 documents by default on every call, to fetch the next page of 64 users, we can send a cursor variable containing the ref of the last user from the last result. Also, we can change the size of every page as we need. The response will contain a field called data which is an array of objects containing the fields userId (a string with the reference of the user), isFollowee (a boolean stating if you are following this user), and isSelf (a boolean indicating whether this user is you).

Get Feed Function

CreateFunction({
  name: "getFeed",
  role: null,
  body: Query(
    Lambda(
      "cursor",
      Map(
        Paginate(Reverse(Documents(Collection("Users"))), {
          after: Var("cursor")
        }),
        Lambda("ref", {
          userId: Select("id", Var("ref")),
          isFollowee: IsNonEmpty(
            Match(Index("is_followee_of_user"), [Identity(), Var("ref")])
          ),
        isSelf: Equals(Identity(), Var("ref"))
        })
      )
    )
  )
})
Enter fullscreen mode Exit fullscreen mode

We’ve got several functions and indexes, but our users have permissions to none of them, all they can do is get their own user id. Let’s use the Fauna dashboard and the hints they provide to help us set the permissions for everyone.

Manage Roles

We’ve got several functions and indexes, but our users have permissions to none of them, First, let’s navigate to the manage roles section:

Fauna-new-role_4XXsEWhkE

Click on new custom role and name it basicUser, then start adding the collections and functions, add everything except the index called users_by_email and the function called signupUser.

Fauna-new-role-2_wTuql__Bq

Fauna’s ABAC (Atribute-Based Access Control) will grant the documents of a collection all the permits that you grant. An authenticated document (in this case user) can have one or more roles, if any role grants permission to perform a certain action, the action will be performed when required.

After you finish adding your collections and indexes, you should see this:

Fauna-new-role-3_6fRu5wJEK

Each row represents a collection, index or function. Each column stands for an action.+Over the Collections your actions are Read / Write(update) / Create / Delete / Read History / Write on History / Unrestricted (do all)+Over the indexes, you can Read / Unrestricted access (read all index's records, even for documents you can’t directly access)+You can Call functions

Now, let’s grant the permissions for these items, click on the red X to turn it into a green checkmark on the next items:+Read permissions for collections Users, Followers, Posts.+Create permissions on Followers and Posts (we will change that later).+Delete permissions on Followers.+Read permissions on all indexes.+Call permissions on all functions.

Finally, let’s click on the Membership tab on the upper side of the window to define who will have the permissions we’ve just set.

Select the Users collection as the users will be the ones with these permissions, now you can click on the new Row with the name Users to set a predicate function. In this case, all users will have permissions, so let’s just make the function return true all the time.

Fauna-new-role-4_cWaCbEUQ7

Save it, at this point the database is fully prepared for our basic social graph. The next step is to wrap the client around it and start making actual interactions on the database.

Next.js Setup

Next.js would enable us to harness the full power of the jamStack while using Fauna as the database. If you are not familiar with Next.js you should probably read up on it here

Initialize Next.js

Start by creating a Next.js application

npx-create-nextapp social_fauna
Enter fullscreen mode Exit fullscreen mode

The pages directory holds the actual UI code for the pages and the pages/api directory provides a solution to build your API directly with Next.js. We would place all the actula logics that ineracts with the api inside the api directory

we would be using bootstrap to quickly style our pages, so let's intstall the bootstrap npm package, i am using yarn but you can also use npm if you prefer.

Install the bootstrap package

yarn add bootstrap
Enter fullscreen mode Exit fullscreen mode

import bootstrap css file in the custom _app.js file

import 'bootstrap/dist/css/bootstrap.css'
Enter fullscreen mode Exit fullscreen mode

Store API key in env file

First, create a server key from the Fauna security tab

fauna key

then create a .env file at the root level of your next.js project and store your key in this format

FAUNA_SERVER_KEY = <your-server-key-here>
Enter fullscreen mode Exit fullscreen mode

we're done setting up the next.js project, the next thing we need to do is to define some important auth modules utils.

Fauna auth util

create file utils/fauna-auth.js

import faunadb from 'faunadb'
import cookie from 'cookie'

export const FAUNA_SECRET_COOKIE = 'faunaSecret'

export const serverClient = new faunadb.Client({
  secret: process.env.FAUNA_SERVER_KEY,
})

console.log(process.env.FAUNA_SERVER_KEY)

// Used for any authed requests.
export const faunaClient = (secret) =>
  new faunadb.Client({
    secret,
  })

export const serializeFaunaCookie = (userSecret) => {
  const cookieSerialized = cookie.serialize(FAUNA_SECRET_COOKIE, userSecret, {
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    maxAge: 72576000,
    httpOnly: true,
    path: '/',
  })
  return cookieSerialized
}
Enter fullscreen mode Exit fullscreen mode

this file provides the functions for authorizing requests to fauna

Auth util

create file utils/auth.js

import { useEffect } from 'react'
import Router from 'next/router'

export const login = ({ email }) => {
  Router.push('/feed')
}

export const logout = async () => {
  await fetch('/api/logout')

  window.localStorage.setItem('logout', Date.now())

  Router.push('/login')
}

export const withAuthSync = (Component) => {
  const Wrapper = (props) => {
    const syncLogout = (event) => {
      if (event.key === 'logout') {
        console.log('logged out from storage!')
        Router.push('/login')
      }
    }

    useEffect(() => {
      window.addEventListener('storage', syncLogout)

      return () => {
        window.removeEventListener('storage', syncLogout)
        window.localStorage.removeItem('logout')
      }
    }, [])

    return <Component {...props} />
  }

  return Wrapper
}
Enter fullscreen mode Exit fullscreen mode

the login function, the logout function and a react hook to update the current auth state and ensure it's in sync.

now, update the index.js file to reflect this

import { useRouter } from "next/router";
import { logout } from "../utils/auth";

const Home = () => {
  const router = useRouter();
  return (
    <div className="container">
      <div>
        <h1>Basic social graph example</h1>

        <p>Steps to test the features:</p>

        <ol>
          <li>
            Click signup and create an account, this will also log you in.
          </li>
          <li>
            Click home and click feed, notice how your session is being
            used through a token stored in a cookie.s
          </li>
          <li>
            Click logout and try to go to feed again. You'll get redirected
            to the `/login` route.
          </li>
        </ol>
        <div className="row mt-5">
          <div
            className="col-3 border p-4 rounded text-center"
            onClick={() => router.push("/signup")}
          >
            SignUp
          </div>
          <div
            className="col-3 border p-4 rounded text-center"
            onClick={() => router.push("/signin")}
          >
            Login
          </div>
          <div
            className="col-3 border p-4 rounded text-center"
            onClick={logout}
          >
            Logout
          </div>
          <div
            className="col-3 border p-4 rounded text-center"
            onClick={() => router.push("/feed")}
          >
            Feed
          </div>
        </div>
      </div>
      <style jsx>{`
        li {
          margin-bottom: 0.5rem;
        }
      `}</style>
    </div>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

i have added some key points, links and methods to help you test out the functionalities we would be implementing.

User Signin

The user signs in from this page

Signin page

create a new file /pages/signin.js and duplicate this:

import { useState } from "react";
import Router from "next/router";

function signin() {
  const [userData, setUserData] = useState({
    email: "",
    password: "",
    error: "",
  });

  const [loading, setLoading] = useState(false);

  const handleSignin = async (email, password) => {
    setLoading(true);

    const response = await fetch("./api/signin", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });

    if (response.status !== 200) {
      setLoading(false);
      throw new Error(await response.text());
    }

    setLoading(false);
    Router.push("/feed");
  };

  async function handleSubmit(event) {
    event.preventDefault();
    setUserData({ ...userData, error: "" });

    const email = userData.email;
    const password = userData.password;

    try {
      await handleSignin(email, password);
    } catch (error) {
      console.error(error);
      setUserData({ ...userData, error: error.message });
      setLoading(false);
    }
  }

  return (
    <div className="container">
      <div className="row justify-content-center mt-5">
        <div className="col-4 border p-4 rounded">
          <h2 className="text-center">Welcome Back</h2>
          <form onSubmit={handleSubmit}>
            <div className="form-group">
              <label htmlFor="email">Email</label>

              <input
                type="text"
                id="email"
                name="email"
                className="form-control"
                value={userData.email}
                onChange={(event) =>
                  setUserData(
                    Object.assign({}, userData, { email: event.target.value })
                  )
                }
              />
            </div>

            <div className="form-group">
              <label htmlFor="password">Password</label>
              <input
                type="password"
                id="password"
                name="password"
                className="form-control"
                value={userData.password}
                onChange={(event) =>
                  setUserData(
                    Object.assign({}, userData, {
                      password: event.target.value,
                    })
                  )
                }
              />
            </div>

            <button
              type="submit"
              disabled={loading}
              className="btn btn-primary w-100 mt-3"
            >
              {loading ? "Loading..." : "Signin"}
            </button>

            {userData.error && (
              <small className="mt-3 text-danger">
                Error: {userData.error}
              </small>
            )}
          </form>
        </div>
      </div>
    </div>
  );
}

export default signin;
Enter fullscreen mode Exit fullscreen mode

Basically what is going on here is onSubmit of the form, the handleSubmit function is invoked, this also invokes the handle signup async function that makes the actual API call to api/signin file using fetch, the API then takes it up from there

Signin API

Now create a new file in /pages/api/signin.js and duplicate this code:

import { query } from "faunadb";
const { Match, Index, Login } = query;
import { serverClient, serializeFaunaCookie } from "../../utils/fauna-auth";

export default async function signin(req, res) {
  const { email, password } = await req.body;

  try {
    if (!email || !password) {
      throw new Error("Email and password are required.");
    }

    return serverClient
      .query(
        Login(Match(Index("users_by_email"), email), {
          password,
        })
      )
      .then((loginRes) => {
        if (!loginRes.secret) {
          throw new Error("No secret present in login query response.");
        }

        const cookieSerialized = serializeFaunaCookie(loginRes.secret);

        res.setHeader("Set-Cookie", cookieSerialized);
        res.status(200).end();
      })
      .catch((err) => {
        console.log({ err });
        // new Error("something went wrong");
        res.status(400).send(err.message);
      });
  } catch (error) {
    res.status(400).send(error.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the part where your code interacts connects the database. Match, Index and Login are some of the methods Fauna javascript driver provides, serverClient is declared in the fauna-auth file, it creates a new instance of the faunadb client. The Match function finds the user provided to Match in the users_by_email index, the Login function then creates an authentication token for the identity returned from the match function. Simply authenticating the user.

User Signup

The user creates an account here

Signup page

Create a new file in /pages/api/signup.js

import { useState } from "react";
import Router from "next/router";

function signup() {
  const [userData, setUserData] = useState({
    email: "",
    password: "",
    error: "",
  });

  const [loading, setLoading] = useState(false);

  const handleSignup = async (email, password) => {
    setLoading(true);

    const response = await fetch("./api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });

    if (response.status !== 200) {
      setLoading(false);
      throw new Error(await response.text());
    }

    setLoading(false);
    Router.push("/");
  };

  async function handleSubmit(event) {
    event.preventDefault();
    setUserData({ ...userData, error: "" });

    const email = userData.email;
    const password = userData.password;

    try {
      await handleSignup(email, password);
    } catch (error) {
      console.error(error);
      setUserData({ ...userData, error: error.message });
      setLoading(false);
    }
  }

  return (
    <div className="container">
      <div className="row justify-content-center mt-5">
        <div className="col-4 border p-4 rounded">
          <h2 className="text-center">Get Started</h2>
          <form onSubmit={handleSubmit}>
            <div className="form-group">
              <label htmlFor="email">Email</label>

              <input
                type="text"
                id="email"
                name="email"
                className="form-control"
                value={userData.email}
                onChange={(event) =>
                  setUserData(
                    Object.assign({}, userData, { email: event.target.value })
                  )
                }
              />
            </div>

            <div className="form-group">
              <label htmlFor="password">Password</label>
              <input
                type="password"
                id="password"
                name="password"
                className="form-control"
                value={userData.password}
                onChange={(event) =>
                  setUserData(
                    Object.assign({}, userData, {
                      password: event.target.value,
                    })
                  )
                }
              />
            </div>

            <button
              type="submit"
              disabled={loading}
              className="btn btn-primary w-100 mt-3"
            >
              {loading ? "Loading..." : "Signup"}
            </button>

            {userData.error && (
              <small className="mt-3 text-danger">
                Error: {userData.error}
              </small>
            )}
          </form>
        </div>
      </div>
    </div>
  );
}

export default signup;
Enter fullscreen mode Exit fullscreen mode

The signup page follows the same approach as the signin page but the endpoint to fetch is /pages/api/signup let's create the API for that request next.

Signup API

Now create a new file in /pages/api/signup.js and duplicate this code:

import { query } from "faunadb";
const { Function: Fn, Call } = query;
import { serverClient, serializeFaunaCookie } from "../../utils/fauna-auth";

export default async function signup(req, res) {
  const { email, password } = await req.body;

  try {
    if (!email || !password) {
      throw new Error("Email and password are required.");
    }

    return serverClient
      .query(Call(Fn("signupUser"), [email, password]))
      .then((loginRes) => {
        if (!loginRes.secret) {
          throw new Error("No secret present in login query response.");
        }

        const cookieSerialized = serializeFaunaCookie(loginRes.secret);

        res.setHeader("Set-Cookie", cookieSerialized);
        res.status(200).end();
      })
      .catch((err) => {
        console.log({ err });
        res.status(400).send(err.message);
      });
  } catch (error) {
    res.status(400).send(error.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

The looks similar to the signin API but there's some little difference, here we are making use of a User Defined Function signupUser to extract the logic and use the Call function to execute the function directly in a query.

Accounts

User browses through a list of other user accounts on the database, the user can then choose to follow or unfollow accounts, this process creates the social graph that would be used to query feed for the user through their relationship.

Accounts page

Create the accounts page /pages/accounts.js

import { useEffect } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import { withAuthSync } from "../utils/auth";

const fetcher = (url) =>
  fetch(url).then((res) => {
    if (res.status >= 300) {
      throw new Error("API Client error");
    }

    return res.json();
  });

const followAccount = async (followee) => {
  const response = await fetch("./api/follow-account", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ followee }),
  });

  if (response.status !== 200) {
    throw new Error(await response.text());
  }
  mutate("/api/list-accounts");
};

const Accounts = () => {
  const router = useRouter();
  const { data: accounts, error } = useSWR("./api/list-accounts", fetcher);
  useEffect(() => {
    if (error) router.push("/");
  }, [error, router]);
  console.log(accounts);
  return (
    <div>
      <h1>This page shows a list of accounts and the option to follow them</h1>
      {error ? (
        <h1>An error has occurred: {error.message}</h1>
      ) : accounts ? (
        accounts.map((user, index) => (
          <div key={index}>
            <hr />
            <p>
              {user.isSelf ? "Your" : "This"} user id is <b>{user.userId}</b>{" "}
            </p>
            <p>
              Currently, you are{" "}
              {user.isFollowee ? "following" : "not following"}{" "}
              {user.isSelf ? "yourself" : "this user"}{" "}
              <button onClick={() => followAccount(user.userId)}>
                {user.isFollowee ? "Unfollow" : "Follow"}
              </button>
            </p>
          </div>
        ))
      ) : (
        <h1>Loading...</h1>
      )}
      <style jsx>{`
        h1 {
          margin-bottom: 0;
        }
      `}</style>
    </div>
  );
};

export default withAuthSync(Accounts);
Enter fullscreen mode Exit fullscreen mode

List accounts API

create the list accounts API at /api/list-accounts

import { query } from "faunadb";
import { faunaClient, FAUNA_SECRET_COOKIE } from "../../utils/fauna-auth";
import cookie from "cookie";
const { Call, Function: Fn } = query;

export default async function listAccounts(req, res) {
  //read the cookie

  const cookies = cookie.parse(req.headers.cookie ?? "");
  const faunaSecret = cookies[FAUNA_SECRET_COOKIE];
  //when there's no cookie, send status 401, Unauthorized
  if (!faunaSecret) {
    return res.status(401).send("Auth cookie missing.");
  }
  let after = await req.query.after;

  try {
    // use query to call Fauna function
    const usersPage = await faunaClient(faunaSecret)
      .query(Call(Fn("listAccounts"), after || [null]))
      .catch((error) => {
        console.log(error);
        return error;
      });
    //catch errors in case there's any
    if (usersPage.message) {
      //if the error has the message 'unauthorized', send status 401 as the login is invalid
      if (usersPage.message == "unauthorized")
        return res.status(401).send("invalid Auth cookie");
      throw new Error(usersPage.message);
    }

    res.status(200).send(usersPage.data);
  } catch (err) {
    console.log(err);
    return res.status(400).send(err.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Feed

After the user is successfully authenticated, the user can query a feed of posts from their relationships, i.e the follower and followed. The user also makes new posts from this page. So the feed page would be interacting with two APIs one is list-feed and the other is the create-post.

Feed page

First create the feed page /pages/feed.js

import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import { withAuthSync } from "../utils/auth";

const fetcher = (url) =>
  fetch(url).then((res) => {
    if (res.status >= 300) {
      throw new Error("API Client error");
    }

    return res.json();
  });

const Feed = () => {
  const router = useRouter();
  const { data: feed, error: feedErr } = useSWR("./api/list-feed", fetcher);
  useEffect(() => {
    if (feedErr) console.log(feedErr);
  }, [feedErr, router]);
  console.log(feed);

  const [postData, setPostData] = useState({
    description: "",
    error: "",
  });
  const [posting, setPosting] = useState(false);

  const createNewPost = async (content) => {
    setPosting(true);

    const response = await fetch("./api/create-post", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ description: content.target.description.value }),
    });

    if (response.status !== 200) {
      setPosting(false);
      throw new Error(await response.text());
    }

    setPosting(false);
    setPostData({ ...postData, description: "" });
  };

  const handleSubmit = async (content) => {
    content.preventDefault();

    try {
      await createNewPost(content);
    } catch (error) {
      console.error(error);
      setPostData({ ...postData, error: error.message });
    }
  };

  return (
    <div>
      <div className="container">
        <div className="row justify-content-center mt-5">
          <div className="col-4 border p-4 rounded">
            <h2 className="text-center mb-4">Hi, What's Popping</h2>
            <form onSubmit={handleSubmit}>
              <div className="form-group">
                <textarea
                  rows="4"
                  disabled={posting}
                  type="text"
                  name="description"
                  className="form-control"
                  value={postData.description}
                  onChange={(event) =>
                    setPostData(
                      Object.assign({}, postData, {
                        description: event.target.value,
                      })
                    )
                  }
                />
              </div>

              <button
                type="submit"
                disabled={posting}
                className="btn btn-primary w-100 mt-3"
              >
                {posting ? "Posting..." : "Post"}
              </button>

              {postData.error && (
                <small className="mt-3 text-danger">
                  Error: {postData.error}
                </small>
              )}
            </form>
          </div>
        </div>
      </div>
    </div>
  );
};

export default withAuthSync(Feed);
Enter fullscreen mode Exit fullscreen mode

Using the useEffect hook, we fetch the feed data for the user immediately after the component has been mounted. After the user enters their post in the textarea and submits it, the create-post API is invoked, this saves the post data in fauna, since we're using stale while revalidate (SWR) we can immediately have the new post data appear on the page without refreshing by calling the mutate method from the SWR library after successfully creating the post, this revalidates the post data ensuring it's up to date.

List Feed API

create the list feed API at /api/list-feed

import { query } from "faunadb";
import { faunaClient, FAUNA_SECRET_COOKIE } from "../../utils/fauna-auth";
import cookie from "cookie";
const { Call, Function: Fn } = query;

export default async function listAccounts(req, res) {
  //read the cookie

  const cookies = cookie.parse(req.headers.cookie ?? "");
  const faunaSecret = cookies[FAUNA_SECRET_COOKIE];
  //when there's no cookie, send status 401, Unauthorized
  if (!faunaSecret) {
    return res.status(401).send("Auth cookie missing.");
  }
  let after = await req.query.after;

  try {
    // use query to call Fauna function
    const usersPage = await faunaClient(faunaSecret)
      .query(Call(Fn("getFeed"), after || [null]))
      .catch((error) => {
        console.log(error);
        return error;
      });
    //catch errors in case there's any
    if (usersPage.message) {
      //if the error has the message 'unauthorized', send status 401 as the login is invalid
      if (usersPage.message == "unauthorized")
        return res.status(401).send("invalid Auth cookie");
      throw new Error(usersPage.message);
    }

    res.status(200).send(usersPage.data);
  } catch (err) {
    console.log(err);
    return res.status(400).send(err.message);
  }
}
Enter fullscreen mode Exit fullscreen mode

This API returns the post feed for the user, it also utilizes the User Defined Function by using the Call function to execute the getFeed function in the query.

Create Post API

then create post API at /pages/api/create-post

import { query } from "faunadb";
import { faunaClient, FAUNA_SECRET_COOKIE } from "../../utils/fauna-auth";
import cookie from "cookie";
const { Call, Function: Fn } = query;

export default async function createPost(req, res) {
  const cookies = cookie.parse(req.headers.cookie ?? "");
  const faunaSecret = cookies[FAUNA_SECRET_COOKIE];

  if (!faunaSecret) {
    return res.status(401).send("Auth cookie missing.");
  }

  return faunaClient(faunaSecret)
    .query(Call(Fn("createPost"), req.body.description))
    .then((newPostContent) => {
      res.status(200).send(newPostContent);
    })
    .catch((err) => {
      console.log(err);
      if (err.message) {
        if (err.message == "unauthorized")
          return res.status(401).send("invalid Auth cookie");
        throw new Error(err.message);
      }
      return res.status(400).send(err.message);
    });
}
Enter fullscreen mode Exit fullscreen mode

This creates the new post and saves it in Fauna, we have completed the scope for this tutorial. Congratulations if you made it this far

Conclusion

In this tutorial, we have been able to model out a small social graph that has some of the features of a social application, although this is not close to the complexity of a real-life social graph. There's still more to dig into such as the Attribute-Based Access Control (ABAC), like and dislike a post, comment on a post and many more. Fauna is the kind of database that factors in developer's happiness out of the box

Discussion (0)