Create a Bookmark Manager App using FaunaDB and Netlify Serverless functions

Yogesh Chavan - Sep 24 '20 - - Dev Community

Introduction

In this article, you will create a Bookmark Manager App using FaunaDB GraphQL API and Netlify serverless functions.

By creating this app with FaunaDB, you will understand how FaunaDB helps to quickly create GraphQL apps without worrying about managing the GraphQL server and its configuration on your own.

Fauna comes with GraphQL playground so you just have to provide the schema and Fauna does all the magic behind the scenes.

You can see the live demo of the final working application in the video below

  • If you’re new to GraphQL and Apollo Client, check out my previous article here to understand the basics of GraphQL.
  • If you're new to Serverless functions(lambda functions) check out my previous article here

FaunaDB GraphQL Configuration

  • Login to FaunaDB with your GitHub / Netlify account or email and password.
  • Once logged In, create a new database by clicking on the NEW DATABASE button

Create Database

  • Enter the name of the database and click on the SAVE button

New Database

  • You will see the following screen

Database Created

  • Click on the GRAPHQL menu displayed at the second last position

GraphQL Screen

  • Create a new file with the name bookmarks.graphql on your desktop and add the following code inside it:
type Bookmark {
  title: String!
  url: String!
  tag: String!
}
type Query {
  bookmarks: [Bookmark!]!
}
Enter fullscreen mode Exit fullscreen mode
  • Now, click on the IMPORT SCHEMA button shown in the above screenshot and select the bookmarks.graphql file

Create Schema

As you can see, Fauna instantly creates queries and mutations for you and gives you a nice GraphQL playground where you can fire your queries and mutations for performing CRUD(Create, Read, Update and Delete) operations. Fauna also stores all the data in its own database so you don’t have to connect to other databases for your application.

  • Now, click on SECURITY menu which is just below the GRAPHQL menu and click on the NEW KEY button to create a secret key which you can use to make API requests to the FaunaDB

New Key

  • Enter the name you want to give for the secret key and select Server for the Role dropdown value and click on the SAVE button

Create Secret key

  • Take note of your generated secret key as it will not be displayed again and keep it safe.

Generated Secret Key

PS: Don’t use the secret key displayed in the screenshot above, I have regenerated a new key so the above key will not work 😊. Also don’t share your secret key with anyone as anyone will mess up with your data

Now, let’s start writing code for our bookmark manager app.

Initial Setup

Create a new project using create-react-app:

create-react-app bookmark-manager
Enter fullscreen mode Exit fullscreen mode

Once the project is created, delete all files from the src folder and create index.js and styles.scss files inside the src folder. Also create actions, components, custom-hooks , reducers, router, store and utils folders inside the src folder.

Install the necessary dependencies:

yarn add @apollo/client@3.1.4 apollo-boost@0.4.9 axios@0.20.0 bootstrap@4.5.2 cross-fetch@3.0.5 dotenv@8.2.0 graphql@15.3.0 lodash@4.17.20 node-sass@4.14.1 react-bootstrap@1.3.0 redux@4.0.5 react-redux@7.2.1 react-router-dom@5.2.0 redux-thunk@2.3.0 subscriptions-transport-ws@0.9.18 uuid@8.3.0
Enter fullscreen mode Exit fullscreen mode

Open styles.scss and add the contents from here inside it.

Writing code

Create a new file Header.js inside the components folder with the following content:

import React from 'react';
import { Link } from 'react-router-dom';

const Header = () => {
  return (
    <header className="header">
      <h1 className="main-heading">Bookmark Manager</h1>
      <div className="header-links">
        <Link to="/add" className="link">
          Add Bookmark
        </Link>
        <Link to="/" className="link">
          Bookmarks List
        </Link>
      </div>
    </header>
  );
};

export default Header;
Enter fullscreen mode Exit fullscreen mode

Create a new file BookmarkSearch.js inside the components folder with the following content:

import React, { useState } from 'react';
import { Form } from 'react-bootstrap';

const BookmarkSearch = ({ handleSearch }) => {
  const [searchTerm, setSearchTerm] = useState('');

  const handleInputChange = (event) => {
    const value = event.target.value;
    setSearchTerm(value);
    handleSearch(value);
  };

  return (
    <div className="bookmark-search">
      <Form>
        <Form.Group controlId="location">
          <Form.Control
            type="text"
            name="searchTerm"
            className="searchTerm"
            value={searchTerm || ''}
            placeholder="Search by title or url"
            onChange={handleInputChange}
            autoComplete="off"
          />
        </Form.Group>
      </Form>
    </div>
  );
};

export default BookmarkSearch;
Enter fullscreen mode Exit fullscreen mode

In this file, we have added an input search box for searching through the list of bookmarks.

Create a new file constants.js inside the utils folder with the following content:

export const SET_BOOKMARKS = 'SET_BOOKMARKS';
export const ADD_BOOKMARK = 'ADD_BOOKMARK';
export const EDIT_BOOKMARK = 'EDIT_BOOKMARK';
export const DELETE_BOOKMARK = 'DELETE_BOOKMARK';
export const GET_ERRORS = 'GET_ERRORS';
export const TAGS = [
  'All',
  'React',
  'Node.js',
  'JavaScript',
  'Beginners',
  'Other'
];
Enter fullscreen mode Exit fullscreen mode

In this file, we have created constants to be used in redux and a set of tags in which we can group each bookmark.

Create a new file Filters.js inside the components folder with the following content:

import React from 'react';
import { TAGS } from '../utils/constants';

const Filters = ({ activeFilter, filterResults, handleFilterClick }) => {
  const handleClick = (tag) => {
    filterResults(tag);
    handleFilterClick(tag);
  };

  return (
    <div className="filters-list">
      <div className="filters">
        {TAGS.map((tag, index) => (
          <div
            key={index}
            onClick={() => handleClick(tag)}
            className={activeFilter === tag ? 'active' : ''}
          >
            {tag}
          </div>
        ))}
      </div>
    </div>
  );
};

export default Filters;
Enter fullscreen mode Exit fullscreen mode

In this file, we’re looping over the list of tags we added in the constant.js file and displaying it on the screen.

Create a new file Loader.js inside the components folder with the following content:

import { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';

const Loader = (props) => {
  const [node] = useState(document.createElement('div'));
  const loader = document.querySelector('#loader');

  useEffect(() => {
    loader.appendChild(node).classList.add('message');
  }, [loader, node]);

  useEffect(() => {
    if (props.show) {
      loader.classList.remove('hide');
      document.body.classList.add('loader-open');
    } else {
      loader.classList.add('hide');
      document.body.classList.remove('loader-open');
    }
  }, [loader, props.show]);

  return ReactDOM.createPortal(props.children, node);
};

export default Loader;
Enter fullscreen mode Exit fullscreen mode

In this file, we have created a loader component that will display a loading message with background overlay.

To add it to the DOM, open public/index.html file and after the div with id root add another div with id loader

<div id="root"></div>
<div id="loader"></div>
Enter fullscreen mode Exit fullscreen mode

Create a new file BookmarkItem.js inside the components folder with the following content:

import React from 'react';
import { Button } from 'react-bootstrap';

const BookmarkItem = ({ _id, title, url, tag, handleEdit, handleDelete }) => {
  return (
    <div className="bookmark">
      <div>
        <div className="title">
          <strong>Title: </strong>
          {title}
        </div>
        <div className="url">
          <strong>URL: </strong>
          {url}
        </div>
        <div className="tag">
          <strong>Tag: </strong>
          {tag}
        </div>
      </div>
      <div className="buttons">
        <div className="btn">
          <Button
            variant="info"
            type="submit"
            size="sm"
            onClick={() => handleEdit(_id)}
          >
            Edit
          </Button>
        </div>
        <div className="btn">
          <Button
            variant="danger"
            type="submit"
            size="sm"
            onClick={() => handleDelete(_id, title)}
          >
            Delete
          </Button>
        </div>
      </div>
    </div>
  );
};
export default BookmarkItem;
Enter fullscreen mode Exit fullscreen mode

In this file, we’re displaying individual bookmarks with edit and delete buttons.

Create a new file BookmarkList.js inside the components folder with the following content:

import React from 'react';
import BookmarkItem from './BookmarkItem';

const BookmarkList = ({ bookmarks, handleEdit, handleDelete }) => {
  return (
    <div className="bookmarks-list">
      {bookmarks.map((bookmark) => (
        <BookmarkItem
          key={bookmark._id}
          {...bookmark}
          handleEdit={handleEdit}
          handleDelete={handleDelete}
        />
      ))}
    </div>
  );
};

export default BookmarkList;
Enter fullscreen mode Exit fullscreen mode

In this file, we’re iterating through the list of bookmarks and displaying it on the screen.

Create a new file useLoader.js inside the custom-hooks folder with the following content:

import { useState } from 'react';

const useLoader = () => {
  const [isLoading, setIsLoading] = useState(false);

  const showLoader = () => {
    setIsLoading(true);
  };

  const hideLoader = () => {
    setIsLoading(false);
  };

  return { isLoading, showLoader, hideLoader };
};

export default useLoader;
Enter fullscreen mode Exit fullscreen mode

In this file, we have separated out the showing and hiding loader into a custom hook.

Create a new file BookmarkForm.js inside the components folder with the following content:

import React, { useState } from 'react';
import { Form, Button } from 'react-bootstrap';
import { TAGS } from '../utils/constants';

const BookmarkForm = (props) => {
  const [state, setState] = useState({
    title: props.title ? props.title : '',
    url: props.url ? props.url : '',
    tag: props.tag ? props.tag : 'React',
    tags: TAGS,
    errorMsg: ''
  });

  const onInputChange = (event) => {
    const { name, value } = event.target;
    setState((prevState) => ({
      ...prevState,
      [name]: value
    }));
  };

  const onFormSubmit = (event) => {
    event.preventDefault();
    const { title, url, tag } = state;
    const { _id } = props;
    const isEditPage = !!props.title;
    if (title.trim() !== '' && url.trim() !== '' && tag.trim() !== '') {
      let data = { title, url, tag };
      if (isEditPage) {
        data = { ...data, _id };
      }
      props.onSubmit(data);
    } else {
      setState((prevState) => ({
        ...prevState,
        errorMsg: 'Please fill out all the fields.'
      }));
    }
  };

  const { title, url, tags, tag, errorMsg } = state;
  return (
    <form onSubmit={onFormSubmit}>
      {errorMsg && <p className="errorMsg">{errorMsg}</p>}
      <Form.Group controlId="title">
        <Form.Label>Title</Form.Label>
        <Form.Control
          type="text"
          name="title"
          value={title || ''}
          onChange={onInputChange}
        />
      </Form.Group>
      <Form.Group controlId="description">
        <Form.Label>URL</Form.Label>
        <Form.Control
          type="text"
          name="url"
          value={url || ''}
          onChange={onInputChange}
        />
      </Form.Group>
      <Form.Group controlId="amount">
        <Form.Label>Tag</Form.Label>
        <Form.Control
          as="select"
          name="tag"
          value={tag || ''}
          onChange={onInputChange}
        >
          {tags.map((tag, index) => (
            <option key={index}>{tag}</option>
          ))}
        </Form.Control>
      </Form.Group>
      <Button variant="info" type="submit">
        Submit
      </Button>
    </form>
  );
};

export default BookmarkForm;
Enter fullscreen mode Exit fullscreen mode

In this file, we have created a form to add and edit bookmark functionality.

Create a new file AddBookmark.js inside the components folder with the following content:

import React from 'react';
import { connect } from 'react-redux';
import BookmarkForm from './BookmarkForm';
import { initiateAddBookmark } from '../actions/bookmarks';
import Loader from './Loader';
import useLoader from '../custom-hooks/useLoader';

const AddBookmark = (props) => {
  const { isLoading, showLoader, hideLoader } = useLoader();

  const onSubmit = (bookmark) => {
    showLoader();
    props.dispatch(initiateAddBookmark(bookmark)).then(() => {
      hideLoader();
      props.history.push('/');
    });
  };

  return (
    <div>
      <Loader show={isLoading}>Loading...</Loader>
      <BookmarkForm {...props} onSubmit={onSubmit} />
    </div>
  );
};

export default connect()(AddBookmark);
Enter fullscreen mode Exit fullscreen mode

In this file, we have added an onSubmit handler that will call the initiateAddBookmark function to add a bookmark to the FaunaDB. we will write the code for initiateAddBookmark soon in this article.

Create a new file EditBookmark.js inside the router folder with the following content:

import React from 'react';
import { connect } from 'react-redux';
import _ from 'lodash';
import { Redirect } from 'react-router-dom';
import BookmarkForm from './BookmarkForm';
import { initiateEditBookmark } from '../actions/bookmarks';
import useLoader from '../custom-hooks/useLoader';
import Loader from './Loader';

const EditBookmark = (props) => {
  const { isLoading, showLoader, hideLoader } = useLoader();

  const onSubmit = (bookmark) => {
    showLoader();
    props.dispatch(initiateEditBookmark(bookmark)).then(() => {
      hideLoader();
      props.history.push('/');
    });
  };

  return (
    <div>
      {!_.isEmpty(props.bookmark) ? (
        <React.Fragment>
          <Loader show={isLoading}>Loading...</Loader>
          <BookmarkForm onSubmit={onSubmit} {...props} {...props.bookmark} />
        </React.Fragment>
      ) : (
        <Redirect to="/" />
      )}
    </div>
  );
};

const mapStateToProps = (state, props) => ({
  bookmark: state.bookmarks.find(
    (bookmark) => bookmark._id === props.match.params.id
  )
});

export default connect(mapStateToProps)(EditBookmark);
Enter fullscreen mode Exit fullscreen mode

In this file, when the user submits the bookmark after editing it, we‘re calling the initiateEditBookmark function to update the bookmark in FaunaDB.

Create a new file Home.js inside the components folder with the following content:

import React, { useState, useEffect } from 'react';
import { connect } from 'react-redux';
import {
  initiateGetBookmarks,
  initiateDeleteBookmark
} from '../actions/bookmarks';
import BookmarkList from './BookmarkList';
import BookmarkSearch from './BookmarkSearch';
import Filters from './Filters';
import Loader from './Loader';
import useLoader from '../custom-hooks/useLoader';
import { isMatch } from '../utils/functions';

const Home = ({ bookmarksList, errorMsg, dispatch, history }) => {
  const [bookmarks, setBookmarks] = useState([]);
  const [activeFilter, setActiveFilter] = useState('All');
  const { isLoading, showLoader, hideLoader } = useLoader();

  const getBookmarks = () => {
    showLoader();
    dispatch(initiateGetBookmarks())
      .then(() => {
        setBookmarks(bookmarksList);
        hideLoader();
      })
      .catch(() => hideLoader());
  };

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

  useEffect(() => {
    setBookmarks(bookmarksList);
  }, [bookmarksList]);

  const handleEdit = (id) => {
    history.push(`/edit/${id}`);
  };

  const handleDelete = (id, title) => {
    const shouldDelete = window.confirm(
      `Are you sure you want to delete the bookmark with title ${title}?`
    );
    if (shouldDelete) {
      showLoader();
      dispatch(initiateDeleteBookmark(id))
        .then(() => {
          handleFilterClick('All');
          hideLoader();
        })
        .catch(() => hideLoader());
    }
  };

  const handleSearch = (searchTerm) => {
    if (searchTerm) {
      setBookmarks(
        bookmarksList.filter((bookmark) => {
          const isTagMatch = isMatch(bookmark.tag, activeFilter);
          if (activeFilter !== '' && activeFilter !== 'All' && !isTagMatch) {
            return false;
          }
          const isTitleMatch = isMatch(bookmark.title, searchTerm);
          const isURLMatch = isMatch(bookmark.url, searchTerm);
          if (isTitleMatch || isURLMatch) {
            return true;
          }
          return false;
        })
      );
    } else {
      if (activeFilter !== 'All') {
        setBookmarks(
          bookmarksList.filter((bookmark) =>
            isMatch(bookmark.tag, activeFilter)
          )
        );
      } else {
        setBookmarks(bookmarksList);
      }
    }
  };

  const filterResults = (tag) => {
    if (tag !== 'All') {
      setBookmarks(bookmarksList.filter((bookmark) => bookmark.tag === tag));
    } else {
      setBookmarks(bookmarksList);
    }
  };

  const handleFilterClick = (tag) => {
    setActiveFilter(tag);
  };

  return (
    <React.Fragment>
      <BookmarkSearch handleSearch={handleSearch} />
      <Filters
        filterResults={filterResults}
        activeFilter={activeFilter}
        handleFilterClick={handleFilterClick}
      />
      {errorMsg && <p className="errorMsg">{errorMsg}</p>}
      <Loader show={isLoading}>Loading...</Loader>
      {bookmarks.length > 0 ? (
        <BookmarkList
          bookmarks={bookmarks}
          handleEdit={handleEdit}
          handleDelete={handleDelete}
        />
      ) : (
        <p className="no-result">No bookmarks found.</p>
      )}
    </React.Fragment>
  );
};

const mapStateToProps = (state) => ({
  bookmarksList: state.bookmarks,
  errorMsg: state.errorMsg
});

export default connect(mapStateToProps)(Home);
Enter fullscreen mode Exit fullscreen mode

This is the main component file that encapsulated all other components.
In this file, first, we’re calling the getBookmarks function from useEffect hook by passing empty array as the second argument so the function will run only once.

useEffect(() => {
 getBookmarks();
}, []);
Enter fullscreen mode Exit fullscreen mode

Inside the getBookmarks function we’re setting the bookmarks array to the list of bookmarks returned using setBookmarks(bookmarksList);

If there is any update to the redux store either because the bookmark is added, edited, or deleted, then we’re taking that updated bookmarks and re-assigning it to the bookmarks array

useEffect(() => {
  setBookmarks(bookmarksList);
}, [bookmarksList]);
Enter fullscreen mode Exit fullscreen mode

This is similar to componentDidUpdate method of class where If there is any change in the bookmarksList prop(passed as a prop to the component from mapStateToProps), this useEffect will be executed.

Then inside the handleEdit method, we’re redirecting the user to the EditBookmark component by passing the edited bookmark id.

Inside the handleDelete method, we’re calling the initiateDeleteBookmark method to delete the bookmark once the user confirms the deletion.

Inside the handleSearch method, we’re checking if the title or bookmark matches with the search term within a particular tag (activeFilter) from the list of bookmarks using the Array filter method and updating the bookmarks array based on the result.

Inside the filterResults method, we filter out the bookmarks based on which tag button is clicked.

Create a new file AppRouter.js inside the router folder with the following content:

import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import Home from '../components/Home';
import AddBookmark from '../components/AddBookmark';
import EditBookmark from '../components/EditBookmark';
import BookmarkList from '../components/BookmarkList';
import Header from '../components/Header';

const AppRouter = () => (
  <BrowserRouter>
    <div className="container">
      <Header />
      <div className="bookmark-form">
        <Switch>
          <Route component={Home} path="/" exact={true} />
          <Route component={BookmarkList} path="/list" />
          <Route component={AddBookmark} path="/add" />
          <Route component={EditBookmark} path="/edit/:id" />
        </Switch>
      </div>
    </div>
  </BrowserRouter>
);

export default AppRouter;
Enter fullscreen mode Exit fullscreen mode

Here, we have set up routing for various pages using react-router-dom library.

Create a new file bookmarks.js inside the reducers folder with the following content:

import {
  SET_BOOKMARKS,
  ADD_BOOKMARK,
  EDIT_BOOKMARK,
  DELETE_BOOKMARK
} from '../utils/constants';

const bookmarksReducer = (state = [], action) => {
  switch (action.type) {
    case SET_BOOKMARKS:
      return action.bookmarks.reverse();
    case ADD_BOOKMARK:
      return [action.bookmark, ...state];
    case EDIT_BOOKMARK:
      return state.map((bookmark) => {
        if (bookmark._id === action._id) {
          return {
            ...bookmark,
            ...action.bookmark
          };
        } else {
          return bookmark;
        }
      });
    case DELETE_BOOKMARK:
      return state.filter((bookmark) => bookmark._id !== action._id);
    default:
      return state;
  }
};

export default bookmarksReducer;
Enter fullscreen mode Exit fullscreen mode

In this reducer file, for the SET_BOOKMARKS action type, we’re returning the bookmarks in the reverse order so while displaying it on the UI, the latest added bookmark will be displayed at the top when the first time the component is loaded.

In the ADD_BOOKMARK action type, we’re returning the array by adding the newly added bookmark as the first item of the array and then using the spread operator, we’re appending all other bookmarks to the array.

In the EDIT_BOOKMARK action type, we’re checking if the passed id matches with any of the id from the bookmarks array using the array map method, and if it matches then we’re returning a new object by spreading out all the properties of the bookmark and then spreading out the updated values of the bookmark.

For example, If the bookmark looks like this:

{_id: "276656761265455623221", title: "FaunaDB", url: "https://fauna.com/", tag: "React"}
Enter fullscreen mode Exit fullscreen mode

and the action.bookmark looks like this:

{_id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
Enter fullscreen mode Exit fullscreen mode

where, only the title is changed then after using spread operator {...bookmark, ...action.bookmark} result will be:

{_id: "276656761265455623221", title: "FaunaDB", url: "https://fauna.com/", tag: "React", _id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
Enter fullscreen mode Exit fullscreen mode

and so If there is already key with the same name then the value of the later key will override the value of the former key. So the final result will be

{_id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
Enter fullscreen mode Exit fullscreen mode

In the DELETE_BOOKMARK action type, we’re removing the bookmark with matching _id using the array filter method.

Create a new file errors.js inside the reducers folder with the following content:

import { GET_ERRORS } from '../utils/constants';

const errorsReducer = (state = '', action) => {
  switch (action.type) {
    case GET_ERRORS:
      return action.errorMsg;
    default:
      return state;
  }
};

export default errorsReducer;
Enter fullscreen mode Exit fullscreen mode

In this file, we are adding an error message coming from the FaunaDB if any while adding, editing, or deleting the bookmark.

Create a new file store.js inside the store folder with the following content:

import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import bookmarksReducer from '../reducers/bookmarks';
import errorsReducer from '../reducers/errors';

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
  combineReducers({
    bookmarks: bookmarksReducer,
    errorMsg: errorsReducer
  }),
  composeEnhancers(applyMiddleware(thunk))
);

store.subscribe(() => {
  console.log(store.getState());
});

export default store;
Enter fullscreen mode Exit fullscreen mode

Here, we have created a redux store with bookmarksReducer and errorsReducer combined together so we can access store data from any component defined in the AppRouter.js file.

Create a new file functions.js inside the utils folder with the following content:

export const isMatch = (original, search) =>
  original.toLowerCase().indexOf(search.toLowerCase()) > -1;
Enter fullscreen mode Exit fullscreen mode

Now, open src/index.js file and add the following contents inside it:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import AppRouter from './router/AppRouter';
import store from './store/store';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles.scss';

ReactDOM.render(
  <Provider store={store}>
    <AppRouter />
  </Provider>,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

Here, we have added a Provider component which will pass the redux store to all the Routes declared in the AppRouter component.

Create a new file bookmarks.js inside the actions folder with the following content:

import axios from 'axios';
import {
  SET_BOOKMARKS,
  ADD_BOOKMARK,
  EDIT_BOOKMARK,
  DELETE_BOOKMARK
} from '../utils/constants';
import { getErrors } from './errors';

export const setBookmarks = (bookmarks) => ({
  type: SET_BOOKMARKS,
  bookmarks
});

export const addBookmark = (bookmark) => ({
  type: ADD_BOOKMARK,
  bookmark
});

export const editBookmark = (bookmark) => ({
  type: EDIT_BOOKMARK,
  _id: bookmark._id,
  bookmark
});

export const deleteBookmark = (_id) => ({
  type: DELETE_BOOKMARK,
  _id
});

export const initiateGetBookmarks = () => {
  return async (dispatch) => {
    try {
      const { data } = await axios({
        url: '/api/getBookmarks',
        method: 'POST'
      });
      return dispatch(setBookmarks(data));
    } catch (error) {
      error.response && dispatch(getErrors(error.response.data));
    }
  };
};

export const initiateAddBookmark = (bookmark) => {
  return async (dispatch) => {
    try {
      const { data } = await axios({
        url: '/api/addBookmark',
        method: 'POST',
        data: bookmark
      });
      return dispatch(addBookmark(data));
    } catch (error) {
      error.response && dispatch(getErrors(error.response.data));
    }
  };
};

export const initiateEditBookmark = (bookmark) => {
  return async (dispatch) => {
    try {
      const { data } = await axios({
        url: '/api/editBookmark',
        method: 'PUT',
        data: bookmark
      });
      return dispatch(editBookmark(data));
    } catch (error) {
      error.response && dispatch(getErrors(error.response.data));
    }
  };
};

export const initiateDeleteBookmark = (_id) => {
  return async (dispatch) => {
    try {
      const { data } = await axios({
        url: '/api/deleteBookmark',
        method: 'DELETE',
        data: { _id }
      });
      return dispatch(deleteBookmark(data._id));
    } catch (error) {
      error.response && dispatch(getErrors(error.response.data));
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Create a new file errors.js inside the actions folder with the following content:

import { GET_ERRORS } from '../utils/constants';

export const getErrors = (errorMsg) => ({
  type: GET_ERRORS,
  errorMsg
});
Enter fullscreen mode Exit fullscreen mode

Create a new file .env inside the project folder with the following content:

FAUNA_GRAPHQL_SECRET_KEY=your_fauna_secret_key
Enter fullscreen mode Exit fullscreen mode

Use your faunaDB secret key here.

Open .gitignore file and add .env on the new line so the .env file will not be pushed to the git repository

Create a new file netlify.toml inside the project folder with the following content:

[build]
  command="CI= yarn run build"
  publish="build"
  functions="functions"

[[redirects]]
  from="/api/*"
  to="/.netlify/functions/:splat"
  status=200
  force=true
Enter fullscreen mode Exit fullscreen mode

This is the configuration file for Netlify where we specify the build configuration.
Let’s break it down

  • The command specifies the command need to be executed to create a production build folder. The CI= is specific to Netify so netlify does not throw error while deploying the application.
  • The publish specifies the name of the folder to be used for deploying the application
  • The functions specifies the name of the folder where all our Serverless functions are stored
  • All the serverless functions, when deployed to the Netlify, are available at the URL /.netlify/functions/ so instead of specifying the complete path every time while making API call, we instruct Netlify that, whenever any request comes for /api/function_name, redirect it to /.netlify/functions/function_name.
  • :splat specified that, whatever comes after /api/ should be used after /.netlify/functions/

Create a functions folder in the root of your project inside which we will be writing our serverless functions.

Inside thefunctions folder, create a new utils folder and add the bookmarks.graphql file with the following content:

type Bookmark {
  title: String!
  url: String!
  tag: String!
}
type Query {
  bookmarks: [Bookmark!]!
}
Enter fullscreen mode Exit fullscreen mode

Create a new file client.js inside the functions/utils folder with the following content:

const { ApolloClient, InMemoryCache, HttpLink } = require('@apollo/client');
const { API_URL } = require('./constants');
const fetch = require('cross-fetch');
require('dotenv').config();

const getClient = ({ method = 'POST' } = {}) => {
  const client = new ApolloClient({
    link: new HttpLink({
      uri: API_URL,
      fetch,
      headers: {
        Authorization: `Bearer ${process.env.FAUNA_GRAPHQL_SECRET_KEY}`
      },
      method
    }),
    cache: new InMemoryCache()
  });
  return client;
};
module.exports = { getClient };
Enter fullscreen mode Exit fullscreen mode

Create a new file constants.js inside the functions/utils folder with the following content:

const API_URL = 'https://graphql.fauna.com/graphql';
const SET_BOOKMARKS = 'SET_BOOKMARKS';
const ADD_BOOKMARK = 'ADD_BOOKMARK';
const EDIT_BOOKMARK = 'EDIT_BOOKMARK';
const DELETE_BOOKMARK = 'DELETE_BOOKMARK';

module.exports = {
  API_URL,
  SET_BOOKMARKS,
  ADD_BOOKMARK,
  EDIT_BOOKMARK,
  DELETE_BOOKMARK
};
Enter fullscreen mode Exit fullscreen mode

Note the API_URL here, it’s the same URL that is displayed in the FaunaDB GraphQL playground which we’re using.

Create a new file queries.js inside the functions/utils folder with the following content:

const { gql } = require('apollo-boost');

const GET_BOOKMARKS = gql`
  query {
    bookmarks {
      data {
        _id
        title
        url
        tag
      }
    }
  }
`;

const ADD_BOOKMARK = gql`
  mutation($title: String!, $url: String!, $tag: String!) {
    createBookmark(data: { title: $title, url: $url, tag: $tag }) {
      _id
      title
      url
      tag
    }
  }
`;

const EDIT_BOOKMARK = gql`
  mutation($id: ID!, $title: String!, $url: String!, $tag: String!) {
    updateBookmark(id: $id, data: { title: $title, url: $url, tag: $tag }) {
      _id
      title
      url
      tag
    }
  }
`;

const DELETE_BOOKMARK = gql`
  mutation($id: ID!) {
    deleteBookmark(id: $id) {
      _id
    }
  }
`;

module.exports = {
  GET_BOOKMARKS,
  ADD_BOOKMARK,
  EDIT_BOOKMARK,
  DELETE_BOOKMARK
};
Enter fullscreen mode Exit fullscreen mode

Create a new file getBookmarks.js inside the functions folder with the following content:

const { GET_BOOKMARKS } = require('./utils/queries');
const { getClient } = require('./utils/client');

exports.handler = async (event, context, callback) => {
  try {
    const client = getClient();
    let { data } = await client.query({
      query: GET_BOOKMARKS
    });
    const result = data.bookmarks.data;
    return {
      statusCode: 200,
      body: JSON.stringify(result)
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify(
        'Something went wrong while fetching bookmarks. Try again later.'
      )
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

In this file, we’re actually making an API call to the FaunaDB GraphQL API and returning the response to the initiateGetBookmarks function defined inside the src/actions/bookmarks.js file because from inside the initiateGetBookmarks function, we’re making a call to the /api/getBookmarks which is functions/getBookmarks.js serverless function.

Running the Application

Now, let’s run the application to see the output. Before that, we need to install netlify-cli npm library which will run our serverless functions and also our React app.

Install the library by executing the following command from the terminal:

npm install netlify-cli -g
Enter fullscreen mode Exit fullscreen mode

If you’re on Linux / Mac then you might need to add sudo before it to install it globally:

sudo npm install netlify-cli -g
Enter fullscreen mode Exit fullscreen mode

Now, start the application by running the following command from the terminal from inside the project folder

netlify dev
Enter fullscreen mode Exit fullscreen mode

netlify dev command will first run our serverless functions and then our React application and it will automatically manage the proxy so you will not get CORS error while accessing the serverless functions from the React application.

Now, navigate to http://localhost:8888/ and check the application

Initial Screen

Adding bookmarks

Currently, we have not added any bookmarks so the application is showing No bookmarks found message. So let’s add some bookmarks.

Create a new file addBookmark.js inside the functions folder with the following content:

const { ADD_BOOKMARK } = require('./utils/queries');
const { getClient } = require('./utils/client');

exports.handler = async (event, context, callback) => {
  try {
    if (event.httpMethod !== 'POST') {
      return {
        statusCode: 405,
        body: JSON.stringify({
          error: 'only POST http method is allowed.'
        })
      };
    }
    const { title, url, tag } = JSON.parse(event.body);
    const variables = { title, url, tag };
    const client = getClient();
    const { data } = await client.mutate({
      mutation: ADD_BOOKMARK,
      variables
    });
    const result = data.createBookmark;
    return {
      statusCode: 200,
      body: JSON.stringify(result)
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify('Something went wrong. Try again later!')
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

Now, restart the server by running netlify dev again and add a bookmark by clicking on the Add Bookmark link in the header

Add Bookmark

Adding Edit and Delete Bookmark functionality

Let’s add the edit and delete bookmark serverless functions now.

Create a new file editBookmark.js inside the functions folder with the following content:

const { EDIT_BOOKMARK } = require('./utils/queries');
const { getClient } = require('./utils/client');

exports.handler = async (event, context, callback) => {
  try {
    if (event.httpMethod !== 'PUT') {
      return {
        statusCode: 405,
        body: JSON.stringify({
          error: 'only PUT http method is allowed.'
        })
      };
    }
    const { _id: id, title, url, tag } = JSON.parse(event.body);
    const variables = { id, title, url, tag };
    const client = getClient({ method: 'PUT' });
    const { data } = await client.mutate({
      mutation: EDIT_BOOKMARK,
      variables
    });
    const result = data.createBookmark;
    return {
      statusCode: 200,
      body: JSON.stringify(result)
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify(
        'Something went wrong while editing bookmarks. Try again later.'
      )
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

Create a new file deleteBookmark.js inside the functions folder with the following content:

const { DELETE_BOOKMARK } = require('./utils/queries');
const { getClient } = require('./utils/client');

exports.handler = async (event, context, callback) => {
  try {
    if (event.httpMethod !== 'DELETE') {
      return {
        statusCode: 405,
        body: JSON.stringify({
          error: 'only DELETE http method is allowed.'
        })
      };
    }
    const { _id: id } = JSON.parse(event.body);
    const variables = { id };
    const client = getClient({ method: 'DELETE' });
    const { data } = await client.mutate({
      mutation: DELETE_BOOKMARK,
      variables
    });
    const result = data.deleteBookmark;
    return {
      statusCode: 200,
      body: JSON.stringify(result)
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify(
        'Something went wrong while deleting bookmark. Try again later.'
      )
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

Now, restart the server by running netlify dev again and check the edit and delete bookmark functionality.

Edit bookmark functionality

Edit Bookmark

Delete bookmark functionality

Delete Bookmark

Let’s add a couple of more bookmarks in various tags.

Bookmarks

Now, we have added some bookmarks, Let’s verify the search bookmarks functionality.

Working Search

Testing the data from FaunaDB GraphQL Playground

Let’s verify that they are actually added to the FaunaDB.

Navigate to the GraphQL menu from the FaunaDB dashboard and paste the query for getting all bookmarks from functions/utils/queries.js file into the playground and verify it.

query {
  bookmarks {
    data {
      _id
      title
      url
      tag
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Verify Get Bookmarks

As you can see, the bookmarks are correctly saved into the FaunaDB, so now our bookmarks will persist even after refreshing the page.

Let’s recap on how the app works.

  • When the app is loaded, we’re calling initiateGetBookmarks function of actions/bookmarks.js file, from components/Home.js file.
  • The initiateGetBookmarks function, makes an API call to the/api/getBookmarks URL which is a serverless function written in functions/getBookmarks.js file which finally calls the FaunaDB GraphQL API for getting the list of Bookmarks.
  • When we edit/delete the bookmark, respective serverless functions are called from functions folder making an API call to FaunaDB.

Deploy the application to Netlify

Now, we're done with the application.

To deploy the application to Netlify follow any of your favorite way from this article

Make sure to add FAUNA_GRAPHQL_SECRET_KEY environment variable with your fauna secret key to Site settings => Build & deploy => Environment Variables section in Netlify and re-deploy the site.

Conclusion

As you have seen, FaunDB makes it really easy to create a GraphQL server and store the data in the database so we don’t have to worry about using an extra database for storing the data.

We’re done with creating our amazing Bookmark Manager JAMStack App using blazing-fast FaunaDB GraphQL API and Netlify.

You can find the complete source code for this application in this repository

Don't forget to subscribe to get my weekly newsletter with amazing tips, tricks and articles directly in your inbox here.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .