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
- Enter the name of the database and click on the
SAVE
button
- You will see the following screen
- Click on the
GRAPHQL
menu displayed at the second last position
- 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!]!
}
- Now, click on the
IMPORT SCHEMA
button shown in the above screenshot and select thebookmarks.graphql
file
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 theGRAPHQL
menu and click on theNEW KEY
button to create a secret key which you can use to make API requests to the FaunaDB
- Enter the name you want to give for the secret key and select
Server
for theRole
dropdown value and click on theSAVE
button
- Take note of your generated secret key as it will not be displayed again and keep it safe.
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
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
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;
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;
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'
];
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;
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;
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>
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;
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;
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;
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;
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);
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);
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);
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();
}, []);
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]);
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;
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;
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"}
and the action.bookmark
looks like this:
{_id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
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"}
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"}
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;
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;
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;
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')
);
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));
}
};
};
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
});
Create a new file .env
inside the project folder with the following content:
FAUNA_GRAPHQL_SECRET_KEY=your_fauna_secret_key
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
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. TheCI=
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!]!
}
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 };
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
};
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
};
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.'
)
};
}
};
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
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
Now, start the application by running the following command from the terminal from inside the project folder
netlify dev
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
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!')
};
}
};
Now, restart the server by running netlify dev
again and add a bookmark by clicking on the Add Bookmark
link in the header
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.'
)
};
}
};
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.'
)
};
}
};
Now, restart the server by running netlify dev
again and check the edit and delete bookmark functionality.
Edit bookmark functionality
Delete bookmark functionality
Let’s add a couple of more bookmarks in various tags.
Now, we have added some bookmarks, Let’s verify the search bookmarks functionality.
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
}
}
}
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 ofactions/bookmarks.js
file, fromcomponents/Home.js
file. - The
initiateGetBookmarks
function, makes an API call to the/api/getBookmarks
URL which is a serverless function written infunctions/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 toSite 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.