Creating a Fauna + Next.js/TypeScript To-do app

Recently I have started exploring the NoSQL solution called Fauna to integrate it with one of my side projects. It took some digging and exploring to get it set-up and well integrated with my intended set-up, but I now have a solid set-up for future Next.js/Fauna projects. So let’s dive into making our little to-do app

Step 1: Create FaunaDB database

Firstly let’s start by creating a new database from the Fauna dashboard. You can name it anything you want, for the purpose of this guide I will simply call it

Creating new Fauna Database

After creating the database we will have to create a secret for it so we can manage the instance from our local machine. You do this in the tab of the database. After creating the key, copy it somewhere for usage in our Next.js app later.

Creating a new Admin type key for managing the Database

As far as Fauna is concerned this is all for the moment. Let’s continue by setting up our Next.js project.

Step 2: Setting up Next.js

We’ll go with a basic setup for this guide. Find an appropriate spot to create your project and run the following command to bootstrap our Next.js app.

npx create-next-app

Step 3: Setting up TypeScript

Next.js has splendid and well documented TypeScript support so we will simply be following their basic steps on adding TS to the project, with some minor adjustments.

First off create a in our project root. This will be automatically populated by Next.js next time we run our project. To do this quickly you can run

touch tsconfig.json

Secondly we will need to install typescript and the appropriate typing packages for and as follows

npm install --save-dev typescript @types/react @types/node# or if you're using Yarn
yarn add -D typescript @types/react @types/nod

We are almost ready to support TypeScript in our project, the last step is setting up our . For an initial setup we can simply run the project to auto-populate it. The output will say something along the lines of

npm run dev# or
yarn dev

After having run the command you can stop the project again and the should look something like this:

{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

Personally, I would add 2 more settings to ensure better type safety. (however this is up to you whether to include these or not)

{
"noImplicitAny": true,
"strictNullChecks": true
}

At this point you might have noticed we still have in our project, such as and . To finalize our setup we will need to rename these to .

You will also see a file called , which is an API route. This one can be renamed to as we do not require JSX support in these API routes.

Now you will see a few errors regarding missing types in both the and files. (the latter we will remove later, but let’s keep it for now)

To fix these errors we need to add typings to both of these as follows

# for _app.tsx
import { AppProps } from 'next/app'
import '../styles/globals.css'
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
export default MyApp# for hello.ts
import { NextApiRequest, NextApiResponse } from 'next'
export default (req: NextApiRequest, res: NextApiResponse) => {
res.statusCode = 200
res.json({ name: 'John Doe' })
}

Step 4: Start building our GraphQL schema

Technically Fauna does not require you to use GraphQL per se, however it is required to “easily” achieve type safety between the datastore and our app. (that and GraphQL is snazzy and cool)

For this guide we will have a very simple schema, but we will also use some custom resolvers so you we can dive deeper into the functionality Fauna provides.

Let’s start by thinking about what data we need to store. Very simply, we need the todo itself, the date and time it was created and it’s status. We can easily make a GraphQL type out of this and it would look something like this:

enum TodoStatus {
NEW,
INPROGRESS,
DONE
}
type Todo {
todo: String!
status: TodoStatus!
}

You may notice it does not contain a (or similar) field. This is because Fauna will keep track of this by default and inject it into our schema with the field.

To dive more into Fauna specific functionality we will also add 2 queries called and . The first query will find todos by their status and the latter will do a search by their text. (in reality you would most likely implement this in next.js — however for the purpose of showing what you can do in Fauna we are making a resolver for it)

Lastly, we will also add a query called . This one will automatically be handled by Fauna, which means we do not need to build our own resolver for it.

Our final schema would look something like this:

enum TodoStatus {
NEW,
INPROGRESS,
DONE
}
type Todo {
todo: String!
status: TodoStatus!
}
type Query {
allTodos(): [Todo!]!
todosByStatus(status: TodoStatus): [Todo!]! @resolver(paginated: true)
todosByText(text: String): [Todo!]! @resolver(paginated: true)
}

Let’s save our schema in our project as before we switch back to Fauna to import our GraphQL schema.

Step 5: Importing our schema

We can now import our schema into Fauna either using the UI or the import endpoint. For the UI, you can import your schema under the tab.

Import new GQL schema

To import a schema using the endpoint, you can run below command (dont’t forget to add your previously saved )

curl -X POST --data "@./graphql/schema.gql" -H "Authorization: Bearer <FAUNA_KEY>" https://graphql.fauna.com/import

After importing there are a few things to note.

First of all when we return to the GraphQL section in the Fauna UI, we can now use the GraphQL Playground. This also allows you to see what our imported schema will look like in it’s final form after being adjusted by Fauna.

We see some default mutations and a default query called findTodoByID (alongside our manually defined queries)
We can also see the default Fauna fields called _id and _ts, which are the unique ID and the timestamp respectively. Aside from this we also see a TodoInput type, which is used for the createTodo mutation.

Secondly, we can explore “Functions” section, which now contains 2 functions. These match our previously defined queries, but they don’t do anything yet.

Newly created functions by Fauna. At this time they will throw a “Not Implemented” exception.

Lastly, when we check out the “Collections” section, we can also see Fauna has created a new collection for us called “Todo” . This matches our GraphQL type name.

Newly created collection by Fauna matching our GraphQL type.

Step 6: Making our custom queries work

For our custom queries to work we will need to write the Fauna functions that go along with them. You can do this manually in the UI, but it would be wise to keep these function definitions in a central place such as our Next.js project.

To do this we will start by making a file named . I am calling it a migration, simply because I needed a name for it and the term seemed appropriate. (there is an actual migration tool for Fauna called , however it is 2 years old and unofficial)

For our functions to work we will need a few things to be created/updated by our migration. As we will search through our Fauna collection, we will need to make use of indexes (Index tutorial from Fauna). We will need 2 of them, the first one will be to retrieve all our todos, the second one will be used to retrieve the todos by their tag. We need the first one to implement our function. Indexes can only perform exact match searches, which means we need to implement custom filtering logic after fetching all our todos in the first place. Our migration script will also handle updating the previously created functions with appropriate logic for them to actually work.

Let’s start by making our first index. We will name our first index and the source of the index will be . The script to create this will look as follows:

CreateIndex({
name: "allTodos",
source: Collection("Todo")
})

To make our migration a little smarter, we will also add some conditional logic to create the index only if it does not exist yet and otherwise update the existing index by the same name. The result would be the below:

Let(
{
index_params: {
name: "allTodos",
source: Collection("Todo")
}
},
If(
Exists(Index("allTodos")),
Update(Index("allTodos"), Var("index_params")),
CreateIndex(Var("index_params"))
)
)

We will apply the same logic and steps for our 2nd index, called , which will handle searching by status. This is possible using an index as it will be an exact search and it would look something like this:

Let(
{
index_params: {
name: "todosByStatus",
source: Collection("Todo"),
terms: [
{ field: ["data", "status"] }
]
}
},
If(
Exists(Index("todosByStatus")),
Update(Index("todosByStatus"), Var("index_params")),
CreateIndex(Var("index_params"))
)
)

The only addition here is the field in our parameters. This simply defines which fields can be searched by.

Next we will make use of our indexes in our functions. Let’s start with the simplest one, which is the function. For this to work we can simply use the built-in function from Fauna to use our Index and find the relevant results (or matches). The function body would look like this:

Query(
Lambda(
["status", "size", "before", "after"],
Let(
{
match: Match(Index("todosByStatus"), Var("status")),
page: If(
Equals(Var("before"), null),
If(
Equals(Var("after"), null),
Paginate(Var("match"), { size: Var("size") }),
Paginate(Var("match"), { after: Var("after"), size: Var("size") })
),
Paginate(Var("match"), { before: Var("before"), size: Var("size") })
)
},
Map(Var("page"), Lambda("x", Get(Var("x"))))
)
)
)

WOW! Where did all the other stuff come from?
Aha that is because, when returning multiple items Fauna expects it to be paginated. This can easily be done by using the function, however it does require us to accept some additional parameters such as , and . We also need to create some logic to pass the arguments on accordingly. More info on pagination can be found here and the conditional code is explained here.

Now for our migration script, we will also apply the same logic as we did previously so we only try to create a new function when it does not exist yet and update it if it does. Our final script for the function will look as follows.

Let(
{
function_params: {
name: "todosByStatus",
body: Query(
Lambda(
["status", "size", "before", "after"],
Let(
{
match: Match(Index("todosByStatus"), Var("status")),
page: If(
Equals(Var("before"), null),
If(
Equals(Var("after"), null),
Paginate(Var("match"), { size: Var("size") }),
Paginate(Var("match"), { after: Var("after"), size: Var("size") })
),
Paginate(Var("match"), { before: Var("before"), size: Var("size") })
)
},
Map(Var("page"), Lambda("x", Get(Var("x"))))
)
)
)
}
},
If(
Exists(Function("todosByStatus")),
Update(Function("todosByStatus"), Var("function_params")),
CreateFunction(Var("function_params"))
)
)

For our loose function, we will need to manually implement the searching functionality in our UDF or User-Defined function. We can make use of a few built-in functions such as , and to make this work. Without all our pagination overhead, this would look something like:

Filter(
Match(Index("allTodos")),
Lambda(
"x",
ContainsStr(LowerCase(Select(["data", "todo"], Get(Var("x")))), LowerCase(Var("text")))
)
)

Basically we select all our todos, and filter it, similar to the JS filter method, using a predicatae Lambda function which checks whether our searched text can be found in the todo text. Combining this with our pagination and conditional checks we get the following script:

Let(
{
function_params: {
name: "todosByText",
body: Query(
Lambda(
["text", "size", "before", "after"],
Let(
{
filter: Filter(
Match(Index("allTodos")),
Lambda(
"x",
ContainsStr(LowerCase(Select(["data", "todo"], Get(Var("x")))), LowerCase(Var("text")))
)
),
page: If(
Equals(Var("before"), null),
If(
Equals(Var("after"), null),
Paginate(Var("filter"), { size: Var("size") }),
Paginate(Var("filter"), { after: Var("after"), size: Var("size") })
),
Paginate(Var("filter"), { before: Var("before"), size: Var("size") })
)
},
Map(Var("page"), Lambda("x", Get(Var("x"))))
)
)
)
}
},
If(
Exists(Function("todosByText")),
Update(Function("todosByText"), Var("function_params")),
CreateFunction(Var("function_params"))
)
)

This is all we need to make our functions work, so let’s import it into Fauna. To do this we can use the Fauna shell either in the UI (which allows loading from a file) or in your local terminal. For the latter you will need to install and configure it before running following command (more info — fauna-shell)

fauna eval --file=./graphql/migrations/202101121515_InitialCreate.fql

If you are working with the Shell in the Fauna UI, you can simply use the “Open file” option (or copy and paste it)

Intermezzo: Try it our in Fauna UI

Just to see it in action, let’s make a few todos and see our new function and indexes in action.

Start by going to the Fauna UI, open the collection and create a few document with something like the following:

{
"todo": "Wash up",
"status": "NEW"
}

Now we can jump into the GraphQL playground and test out our queries. I created 1 todo in status NEW called “Wash up” and 1 todo in status DONE called “Take a shower”. To get the 2nd item, I could either query it by status or perform a search on the word shower. Let’s see both in action:

Query by status DONE, gives back the 2nd item.
We can also use the todosByText query to get the same result.

Step 7: Define the queries and mutations in code

Before we can start using GraphQL in our project we need to install Apollo+GraphQL as well as define the queries and mutations we are going to use in the project. We will store them in and respectively.

Let’s start with the dependencies, to add them to our project we will simply use or

npm install graphql @apollo/client# or for yarn
yarn add graphql @apollo/client

For the queries, we will need 3 files, one for each of the queries we defined earlier (, and ). Each file will simply contain a single query which we will subsequently be able to use in our project. The resulting queries would be something like this

import { gql } from '@apollo/client';export const allTodosQuery = gql`
query AllTodos($cursor: String) {
allTodos(_cursor: $cursor) {
data {
_id,
_ts,
todo,
status
},
before,
after
}
}
`;
export const todosByStatus = gql`
query TodosByStatus($status: TodoStatus!, $cursor: String) {
todosByStatus(status: $status, _cursor: $cursor) {
data {
_id,
_ts,
todo,
status
},
after,
before
}
}
`;
export const todosByText = gql`
query TodosByText($text: String!, $cursor: String) {
todosByText(text: $text, _cursor: $cursor) {
data {
_id,
_ts,
todo,
status
},
after,
before
}
}
`;

We will also add a mutation in , which uses the exact same format.

import { gql } from '@apollo/client';export const createTodoQuery = gql`
mutation CreateTodo($todo: String!) {
createTodo(data: {
todo: $todo,
status: NEW
}) {
_id,
_ts,
todo,
status
}
}
`;

Step 8: Generate TypeScript files

With our queries and mutations added we should generate some types. To do this, we can use

First we need to install the codegen cli and the appropriate plugins for our project. We are going to use the default plugins, as well as the plugin. We can install them by running the following install command:

npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo# or for yarn
yarn add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo

Secondly we need to add a configuration file called in our project root. This is used by the generator and should contain a few bits and should look something like this:

overwrite: true
schema:
- https://graphql.fauna.com/graphql:
headers:
Authorization: "Bearer ${FAUNA_KEY}"
generates:
graphql/generated/graphql.ts:
documents:
- graphql/queries/*.ts
- graphql/mutations/*.ts
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"

So what does all of this mean?

  • , this is the location of the GraphQL schema. We are pointing it to the Fauna URL as opposed to our local file, because our local file does not contain fields such as and . We also add a section, so we can pass along our FAUNA_KEY, which is necessary for authenticating with Fauna.
  • , Here we define what we want to generate. In our case we are simply generating some TypeScript code. This is done based on the specification within, pointing to our previously created files, and uses 3 plugins: “typescript”, “typescript-operations” and “typescript-react-apollo”.

This is all we need to be able to generate our TypeScript, so let’s give it a go by running the codegen command

FAUNA_KEY=<FAUNA_KEY> yarn graphql-codegen

Step 9: Setup Apollo

Let’s start by removing the default file as we don’t need it.

Secondly we should provide our FAUNA_KEY to Next.js by adding it to a file called . This should look as follows (it will be prefixed with NEXT_PUBLIC_ as we will be using the key in our front-end. Of course, in reality you should not use an admin key for this and properly secure the API or wrap the Fauna API with your own GraphQL layer)

NEXT_PUBLIC_FAUNA_KEY=<FAUNA_KEY>

With this in place, we can create a . Let’s make a file in in and add the following code

import { ApolloClient, InMemoryCache } from '@apollo/client';export const ApiClient = new ApolloClient({
uri: 'https://graphql.fauna.com/graphql',
cache: new InMemoryCache(),
headers: {
authorization: `Bearer ${process.env.FAUNA_KEY}`
}
});

Lastly we need to provide our client to the React app using the . We wrap this in our custom app component as it should be available everywhere. The resulting will look as follows

import '../styles/globals.css'
import { AppProps } from 'next/app'
import { ApolloProvider } from '@apollo/client';
import { ApiClient } from '../graphql/client';
function MyApp({ Component, pageProps }: AppProps) {
return (
<ApolloProvider client={ApiClient}>
<Component {...pageProps} />
</ApolloProvider>
)
}
export default MyApp

Step 10: Putting all the pieces together

Now we have all our pieces in place, we can start putting it to use. Let’s start by showing a list of our Todos.

I will not be applying any styling in this guide, so it will just be plain HTML, but it will at least work!

First off we can get rid of all the contents in our homepage file and replace it with the following code

import * as React from 'react';
import { useAllTodosQuery } from '../graphql/generated/graphql';
export default function Home() {
const { data, loading } = useAllTodosQuery();
if (loading) {
return (
<p>Loading ...</p>
);
}
if (!data || !data.allTodos.data.length) {
return (
<p>No tasks</p>
);
}
return (
<div>
<ul>
{data.allTodos.data.map(t => (
<li key={t?._id}>
<p>{t?.todo}</p>
<small>{t?.status}</small>
</li>
))}
</ul>
</div>
);
}

You can now run the app and open the page, you should see something like this:

Awesome!

Let’s also add a simple submission form to add new todo’s, so we can put our mutation to the test:

import * as React from 'react';
import { useAllTodosQuery, useCreateTodoMutation } from '../graphql/generated/graphql';
export default function Home() {
const { data, loading, refetch } = useAllTodosQuery();
const [addTodo, addTodoStatus] = useCreateTodoMutation();
const onSubmit = React.useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = event.currentTarget.todo.value;
if (text && typeof text === 'string') {
event.currentTarget.todo.value = '';
await addTodo({ variables: { todo: text } });
refetch();
}
}, []);
if (loading) {
return (
<p>Loading ...</p>
);
}
if (!data || !data.allTodos.data.length) {
return (
<p>No tasks</p>
);
}
return (
<div>
<form onSubmit={onSubmit}>
<input type="text" name="todo" />
</form>
<ul>
{data.allTodos.data.map(t => (
<li key={t?._id}>
<p>{t?.todo}</p>
<small>{t?.status}</small>
</li>
))}
</ul>
</div>
);
}

And lastly let’s add a search box to search by tag or by text:

import * as React from 'react';
import { TodoStatus, useAllTodosLazyQuery, useCreateTodoMutation, useTodosByStatusLazyQuery, useTodosByTextLazyQuery } from '../graphql/generated/graphql';
export default function Home() {
const [searchQuery, setSearchQuery] = React.useState('');
const [getAllTodos, allTodos] = useAllTodosLazyQuery();
const [getTodosByStatus, todosByStatus] = useTodosByStatusLazyQuery();
const [getTodosByText, todosByText] = useTodosByTextLazyQuery();
const [addTodo, addTodoStatus] = useCreateTodoMutation();
const todosQuery = searchQuery.startsWith('tag:') ? todosByStatus : (
!!searchQuery ? todosByText : allTodos
);
const todos = todosQuery.data && (
'allTodos' in todosQuery.data
? todosQuery.data.allTodos
: ('todosByStatus' in todosQuery.data ? todosQuery.data.todosByStatus : todosQuery.data.todosByText)
);
React.useEffect(() => {
if (searchQuery.startsWith('tag:')) {
getTodosByStatus({ variables: { status: searchQuery.replace('tag:', '').toUpperCase() as TodoStatus } })
} else if (!!searchQuery) {
getTodosByText({ variables: { text: searchQuery } })
} else {
getAllTodos();
}
}, [searchQuery]);
const onAddTodoSubmit = React.useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = event.currentTarget.todo.value;
if (text && typeof text === 'string') {
event.currentTarget.todo.value = '';
await addTodo({ variables: { todo: text } });
}
}, []);
const onSearchSubmit = React.useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const query = event.currentTarget.search.value.trim();
if (query && typeof query === 'string') {
setSearchQuery(query);
}
}, []);
if (todosQuery.loading) {
return (
<p>Loading ...</p>
);
}
return (
<div>
<form onSubmit={onAddTodoSubmit}>
<input type="text" name="todo" />
</form>
<form onSubmit={onSearchSubmit}>
<input type="search" name="search" placeholder="Search" />
</form>
<ul>
{todos?.data.map(t => (
<li key={t?._id}>
<p>{t?.todo}</p>
<small>{t?.status}</small>
</li>
))}
</ul>
</div>
);
}

The implementation of the search is not the prettiest, but it shows off how it woks with Apollo in combination with Fauna.

All code for this project can be found here

Give a thumbs up if you learnt something new 👍

Developer