TodoMVC (with Next.js)

Exploring modern Jamstack tools

In this article, we utilize a variety of cutting-edge tools popular with the Jamstack crowd.


How to build a TodoMVC app with FaunaDB, Next.js, and Magic authentication

We're going to show you how to secure a FaunaDB-powered Jamstack application with Magic's simple-to-use passwordless auth. The example app we're building is a basic TodoMVC interpretation (like the one pictured below) using Next.js, a best-in-class React framework. We'll deploy on Vercel, which offers integrated, configuration-less infrastructure for Next.js apps. The primary focus of this tutorial is implementing FaunaDB with a Magic authentication layer, so we'll be breezy with the higher-level Next.js, React, and CRUD concepts. Before going further we suggest you explore the Next.js interactive learning guide, our Next.js deployment example and FaunaDB's getting started documentation. You will also require basic knowledge of React Hooks, Vercel's SWR library, and HTTP cookies.

TodoMVC app with NextJS, Magic, and FaunaDB

👉 View the live demo
👉 See the completed code: https://github.com/magiclabs/example-nextjs-faunadb-todomvc


1. Setting up FaunaDB

First, make sure you've registered a FaunaDB account. Once you're logged in, create a new database and name it todomvc.

Creating a new database in FaunaDB

After clicking Save, you are redirected to the Database Overview page. Next, select Shell from the sidebar. From here, you can execute arbitrary Fauna Query Language (FQL) queries to populate your database schema.

FaunaDB Dashboard, Shell page

For our purposes, we'll need to run a few initial queries. But first, a brief introduction into how data modeling works in FaunaDB. The most important central concept is Collections. We use Collections to define our database schema, similar to tables in other databases. Collections hold Documents, which are akin to records. To enable the organization and retrieval of Documents (other than by direct reference), we use Indexes. If you have a background in SQL, you may find this resource helpful in understanding the similarities and differences to FQL.

Step 1.1: Creating a database schema

First, we need to define a "users" Collection. You can copy/paste the following query into the FaunaDB Dashboard Shell and click Run Query.

CreateCollection({ name: "users" });

Then, we'll define a "todos" Collection, with create permissions specified for Documents in the "users" Collection we created before.

CreateCollection({ name: "todos", permissions: { create: Collection("users") } });

Step 1.2: Making our database searchable

Finally, we'll add some Indexes to search our data later on.

CreateIndex({
name: "users_by_email",
source: Collection("users"),
terms: [{ field: ["data", "email"] }],
unique: true
});
CreateIndex({
name: "all_todos",
source: Collection("todos"),
permissions: { read: Collection("users") }
});
CreateIndex({
name: "todos_by_completed_state",
source: Collection("todos"),
terms: [{ field: ["data", "completed"] }],
permissions: { read: Collection("users") }
});

Now that our FaunaDB database is configured, we can start building our app! 💪


2. Cloning the example code

We've prepared a partial implementation to guide this tutorial, you can clone it by running the following command in your preferred local shell:

git clone --single-branch --branch boilerplate https://github.com/magiclabs/example-nextjs-faunadb-todomvc.git

We'll also take this opportunity to install our dependencies:

cd example-nextjs-faunadb-todomvc
yarn install # or `npm install`

All the files we need are already in place, just missing some code blocks that we'll fill in as we go!


3. Setting up local environment variables

There are four environment variables required by this project:

  • NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY
  • MAGIC_SECRET_KEY (consider this top secret information!)
  • FAUNADB_SECRET_KEY (this is also top secret information!)
  • ENCRYPTION_SECRET (once again, top secret!)

We'll set these variables in a place our app can consume them: .env.local. You can generate this file with the following shell command:

cp .env.local.example .env.local

Now, your .env.local file should look like this:

# .env.local
# We’ll use the NEXT_PUBLIC_ prefix
# to expose this variable to the browser.
# See: https://nextjs.org/docs/basic-features/environment-variables
NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=
MAGIC_SECRET_KEY=
FAUNADB_SECRET_KEY=
# We'll use this to encrypt session cookies
# for our application!
ENCRYPTION_SECRET=this-is-a-secret-value-with-at-least-32-characters

Next, we'll populate these variables.

Step 3.1: Getting your Magic API keys

The first two variables—NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY and MAGIC_SECRET_KEY—are API keys related to Magic. We'll get these from the Magic Dashboard.

Magic Dashboard, Application overview (showing API keys)

Assign the key starting with pk_test_... to the NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY variable and the key starting with sk_test_... to MAGIC_SECRET_KEY.

# .env.local
# We’ll use the NEXT_PUBLIC_ prefix
# to expose this variable to the browser.
# See: https://nextjs.org/docs/basic-features/environment-variables
NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_test_...
MAGIC_SECRET_KEY=sk_test_...
FAUNADB_SECRET_KEY=
ENCRYPTION_SECRET=this-is-a-secret-value-with-at-least-32-characters

Step 3.2: Getting your FaunaDB access secret

The last environment variable, FAUNADB_SECRET_KEY, can be generated from the Security page of your database.

FaunaDB Dashboard, Security page (showing form to create secret key)

Click Save to show your FaunaDB access secret (which should look like fnRB4Ld...)—copy/paste this into .env.local—it's the last time you'll ever see it from the FaunaDB dashboard!

# .env.local
# We’ll use the NEXT_PUBLIC_ prefix
# to expose this variable to the browser.
# See: https://nextjs.org/docs/basic-features/environment-variables
NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY=pk_test_...
MAGIC_SECRET_KEY=sk_test_...
FAUNADB_SECRET_KEY=fnRB4Ld...
ENCRYPTION_SECRET=this-is-a-secret-value-with-at-least-32-characters

4. Authenticating FaunaDB using Magic passwordless login

Step 4.1: Authenticate with Magic client-side

Open pages/login.js, you'll see we've created a basic HTML form with React, we're just missing the Magic implementation.

// pages/login.js
...
export default function Login() {
...
const login = useCallback(async (email) => {
if (isMounted() && errorMsg) setErrorMsg(undefined)
try {
/* Step 4.1: Generate a DID token with Magic */
/* Step 4.4: POST to our /login endpoint */
// const res = await fetch()
// if (res.status === 200) {
// // If we reach this line, it means our
// // authentication succeeded, so we'll
// // redirect to the home page!
// router.push('/')
// } else {
// throw new Error(await res.text())
// }
} catch (err) {
console.error('An unexpected error occurred:', err)
if (isMounted()) setErrorMsg(err.message)
}
}, [errorMsg])
...
}

Search for /* Step 4.1: Generate a DID token with Magic */. Here, we'll add a call to Magic SDK's loginWithMagicLink method to send a magic link email to the user. The method resolves to a DID token once the user has confirmed the login request from their inbox—all without the complication and insecurity of passwords!

// pages/login.js
...
export default function Login() {
...
const login = useCallback(async (email) => {
if (isMounted() && errorMsg) setErrorMsg(undefined)
try {
/* Step 4.1: Generate a DID token with Magic */
const magic = new Magic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY)
const didToken = await magic.auth.loginWithMagicLink({ email })
/* Step 4.4: POST to our /login endpoint */
// const res = await fetch()
// if (res.status === 200) {
// // If we reach this line, it means our
// // authentication succeeded, so we'll
// // redirect to the home page!
// router.push('/')
// } else {
// throw new Error(await res.text())
// }
} catch (err) {
console.error('An unexpected error occurred:', err)
if (isMounted()) setErrorMsg(err.message)
}
}, [errorMsg])
...
}

Step 4.2: Validating the user's identity server-side

One of the brilliant features of Next.js is the ability to deploy server-side routes as serverless functions. We'll take advantage of this feature to validate the DID token received in the previous step. Open pages/api/login.js, where we'll be implementing a POST method to handle our server-side logic.

// pages/api/login.js
...
const handlers = {
POST: async (req, res) => {
/* Step 4.2: Validate the user's DID token */
/* Step 4.3: Get or create a user's entity in FaunaDB */
// Once we have the user's verified information, we can create
// a session cookie! As this is not the primary topic of our tutorial
// today, we encourage you to explore the implementation of
// `createSession` on-your-own to learn more!
// await createSession(res, { ... })
res.status(200).send({ done: true })
},
}
...

First, we need to verify the user's DID token, which we'll expect as the Authorization header of the HTTP request in a Bearer {token} format. Search for /* Step 4.2: Validate the user's DID token */ to add this logic.

// pages/api/login.js
...
import { Magic } from '@magic-sdk/admin';
const handlers = {
POST: async (req, res) => {
/* Step 4.2: Validate the user's DID token */
const magic = new Magic(process.env.MAGIC_SECRET_KEY)
const didToken = magic.utils.parseAuthorizationHeader(req.headers.authorization)
magic.token.validate(didToken);
const { email, issuer } = await magic.users.getMetadataByToken(didToken)
/* Step 4.3: Get or create a user's entity in FaunaDB */
// Once we have the user's verified information, we can create
// a session cookie! As this is not the primary topic of our tutorial
// today, we encourage you to explore the implementation of
// `createSession` on-your-own to learn more!
// await createSession(res, { ... })
res.status(200).send({ done: true })
},
}
...

Step 4.3: Modifying users in FaunaDB and issuing sessions

Once we've retrieved the user information from the DID token (validating it in the process), we'll need a way to represent our user in FaunaDB. To start, we need to create and share our FaunaDB client instance(s). The boilerplate code has done this for you, take a peek at lib/faunadb.js.

// lib/faunadb.js
import faunadb from 'faunadb'
/** Alias to `faunadb.query` */
export const q = faunadb.query
/**
* Creates an authenticated FaunaDB client
* configured with the given `secret`.
*/
export function getClient(secret) {
return new faunadb.Client({ secret })
}
/** FaunaDB Client configured with our server secret. */
export const adminClient = getClient(process.env.FAUNADB_SECRET_KEY)

There are three export statements in this file, each is notable:

  1. q is an alias to faunadb.query; we like to have it as a shortcut because we'll reference it quite often.

  2. getClient(secret) { ... } returns a dynamically-created FaunaDB client instance. This will be used later on to execute queries in the context of a user's authenticated session.

  3. adminClient is a static FaunaDB client instance authenticated by our FAUNADB_SECRET_KEY environment variable. This enables us to execute to root-level queries, such as creating a new user.

Now, let's shift our focus to lib/models/user-model.js.

// lib/models/user-model.js
import { q, adminClient, getClient } from '../faunadb'
export class UserModel {
async createUser(email) {
/* Step 4.3: Create a user in FaunaDB */
}
async getUserByEmail(email) {
/* Step 4.3: Get a user by their email in FaunaDB */
}
async obtainFaunaDBToken(user) {
/* Step 4.3: Obtain a FaunaDB access token for the user */
}
async invalidateFaunaDBToken(token) {
/* Step 4.3: Invalidate a FaunaDB access token for the user */
}
}

There are four methods in our UserModel requiring implementation. Let's tackle these one-by-one.

UserModel.createUser(email)

Creating a new user in our FaunaDB database requires defining a Document in the "users" Collection. Check this out:

// lib/models/user-model.js
import { q, adminClient, getClient } from '../faunadb'
export class UserModel {
async createUser(email) {
/* Step 4.3: Create a user in FaunaDB */
return adminClient.query(q.Create(q.Collection("users"), {
data: { email },
}))
}
...
}

First, importantly, we are formulating this query with the adminClient object. We populate the call to adminClient.query(...) with a FaunaDB expression, which we compose using the q object (reminder: q is just a shortcut to the global faunadb.query object).

q.Create returns an expression to create a new FaunaDB Document. It's first argument—q.Collection("users")—targets the "users" Collection for the operation. The second argument describes the initial properties of the Document. Every document in FaunaDB contains a data key, which here represents the Document's... well... initial data! For our use-case, we only need to save the user's email. 💌

UserModel.getUserByEmail(email)

We also want to be able to access existing user Documents in our database. For this, we'll implement getUserByEmail.

// lib/models/user-model.js
import { q, adminClient, getClient } from '../faunadb'
export class UserModel {
...
async getUserByEmail(email) {
/* Step 4.3: Get a user by their email in FaunaDB */
return adminClient.query(
q.Get(q.Match(q.Index("users_by_email"), email))
).catch(() => undefined)
}
...
}

Again, we're executing this query with root-level permissions using the adminClient object. This time, though, we are making use of q.Get to generate the query expression. q.Get does just what you think—it "gets" a Document! But... how do we tell q.Get about the Document we're searching for? Earlier in the tutorial we created a few FaunaDB Indexes. There's one in particular that's helpful here:

# You don't have to do anything with this,
# we're just surfacing it again for context!
CreateIndex({
name: "users_by_email",
source: Collection("users"),
terms: [{ field: ["data", "email"] }],
unique: true
});

We'll leverage the "users_by_email" Index to pick the data we need. From its implementation we can infer the following: "users_by_email" searches Documents in the "users" Collection based on the value of data.email. Additionally, the value of our search term must be unique!

Now we can express our search using q.Match, which (as the name suggests), matches Document(s) for a given Index. We provide q.Index("users_by_email") as the first argument, followed by our search term: the user's email. If we don't find a Document matching this user, FaunaDB will raise an error. For simplicity's sake, in the event of an error, we'll resolve undefined.

UserModel.obtainFaunaDBToken(user)

Once we've either created or retrieved a FaunaDB user, we need a way to authorize them to make further requests (like create todos and mark todos as completed). We'll implement obtainFaunaDBToken to provide an access token to the authenticated user.

// lib/models/user-model.js
import { q, adminClient, getClient } from '../faunadb'
export class UserModel {
...
async obtainFaunaDBToken(user) {
/* Step 4.3: Obtain a FaunaDB access token for the user */
return adminClient.query(
q.Create(q.Tokens(), { instance: q.Select("ref", user) }),
).then(res => res?.secret).catch(() => undefined)
}
...
}

Here, we make use of FaunaDB's built-in authentication powers using q.Tokens to create a database access token scoped to the authenticated user. Just like users, todo items, and any other data we model in FaunaDB, access tokens are Documents, so we use q.Create once again. As the second argument to q.Create, we provide a structure containing a reference to our user Document. With q.Select, we pick the "ref" field from the user object. Every Document in FaunaDB contains a "ref" field that can later be used to query for that specific Document (using—you guessed it—q.Get!).

UserModel.invalidateFaunaDBToken(token)

The last piece of functionality we'll need to build in our UserModel is a way to invalidate previously generated FaunaDB tokens (effectively logging users out). Here's our implementation:

// lib/models/user-model.js
import { q, adminClient, getClient } from '../faunadb'
export class UserModel {
...
async invalidateFaunaDBToken(token) {
/* Step 4.3: Invalidate a FaunaDB access token for the user */
await getClient(token).query(q.Logout(true))
}
}

This is the first time we're using the getClient function to build a FaunaDB client instance dynamically instead of using our root-permissioned adminClient. The token parameter is the unencrypted result of UserModel.obtainFaunaDBToken. Using the FaunaDB client associated to a specific user will scope all queries based on that user's permissions and authentication status. We only require a very simple FaunaDB expression here: q.Logout(true), which invalidates the user's FaunaDB session and burns any associated access tokens.

Now that we've fully implemented our UserModel, we can shift our focus back to pages/api/login.js. Let's implement the next step of our handler! Search for /* Step 4.3: Get or create a user's entity in FaunaDB */ and add the following:

// pages/api/login.js
...
const handlers = {
POST: async (req, res) => {
/* Step 4.2: Validate the user's DID token */
const magic = new Magic(process.env.MAGIC_SECRET_KEY)
const didToken = magic.utils.parseAuthorizationHeader(req.headers.authorization)
const { email, issuer } = await magic.users.getMetadataByToken(didToken)
/* Step 4.3: Get or create a user's entity in FaunaDB */
const userModel = new UserModel()
// We auto-detect signups if `getUserByEmail` resolves to `undefined`
const user = await userModel.getUserByEmail(email) ?? await userModel.createUser(email);
const token = await userModel.obtainFaunaDBToken(user);
// Once we have the user's verified information, we can create
// a session cookie! As this is not the primary topic of our tutorial
// today, we encourage you to explore the implementation of
// `createSession` on-your-own to learn more!
await createSession(res, { token, email, issuer })
res.status(200).send({ done: true })
},
}
...

Let's discuss what's happening here. First, we create an instance of UserModel. Then, we try to query for an existing user Document via UserModel.getUserByEmail(email). If the user does not already exist (i.e.: result is undefined), we create one with UserModel.createUser(email). Finally, we obtain a FaunaDB access token via UserModel.obtainFaunaDBToken(user) to encode as part of the user's encrypted server-side session.

In the encrypted session, notice that we're passing token, email, and issuer as the session data. We'll need these pieces of information to later authorize the user for TodoMVC CRUD actions, as well as for logging the user out.

This marks the completion of our server-side login implementation, now we can call this API from the client-side!

Step 4.4: Authenticating users end-to-end

Open pages/login.js, which we left partially implemented before. Find /* Step 4.4: POST to our /login endpoint */ and add a fetch call to POST the user's DID token to our newly-minted /api/login endpoint.

// pages/login.js
...
export default function Login() {
...
const login = useCallback(async (email) => {
if (isMounted() && errorMsg) setErrorMsg(undefined)
try {
/* Step 4.1: Generate a DID token with Magic */
const magic = new Magic(process.env.NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY)
const didToken = await magic.auth.loginWithMagicLink({ email })
/* Step 4.4: POST to our /login endpoint */
const res = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${didToken}`
},
body: JSON.stringify({ email })
})
if (res.status === 200) {
// If we reach this line, it means our
// authentication succeeded, so we'll
// redirect to the home page!
router.push('/')
} else {
throw new Error(await res.text())
}
} catch (err) {
console.error('An unexpected error occurred:', err)
if (isMounted()) setErrorMsg(err.message)
}
}, [errorMsg])
...
}

Once login completes, users are redirected to the / route (handled by pages/index.js). The rest of this TodoMVC example is already hooked up to CRUD endpoints for adding, removing, and completing todo items. You can try it for yourself by starting a local instance of the demo app with the following shell command:

yarn start # or `npm start`

By this point in the tutorial, you should have a foundation to self-guide your learning and deepen your knowledge of FaunaDB. You should also have a comprehensive understanding of (and reference implementation for) Magic's passwordless login flow. But... now that users can log in, we should give them the option to log out.


5. Logging users out

Our users need a way to end their authenticated session with the app. Earlier, we discussed how to obtain and invalidate FaunaDB access tokens. Now, we're going to make use of the UserModel.invalidateFaunaDBToken method we created.

Open pages/api/logout.js. We can see a familiar barebones implementation here, except this time we'll be using a GET HTTP method instead of a POST. This means that user's simply need to navigate to https://[our app domain]/api/logout in order to log out.

// pages/api/logout.js
...
const handlers = {
GET: async (req, res) => {
// We previously stored the user's FaunaDB token
// and DID issuer into an encrypted session cookie.
// We will retrieve that session with the `getSession` function.
const { token, issuer } = await getSession(req)
/* Step 5: Invalidate the user's token */
// As a final step, we'll clear our session cookie
// (erasing it from the user's browser).
removeSession(res)
res.writeHead(302, { Location: '/' })
res.end()
},
}
...

At /* Step 5: Invalidate the user's token */, we'll need to accomplish two things: invalidate the user's FaunaDB token and end the user's authenticated session with Magic.

// pages/api/logout.js
...
import { UserModel } from '../../lib/models/user-model'
import { Magic } from '@magic-sdk/admin'
const handlers = {
GET: async (req, res) => {
// We previously stored the user's FaunaDB token
// and DID issuer into an encrypted session cookie.
// We will retrieve that session with the `getSession` function.
const { token, issuer } = await getSession(req)
/* Step 5: Invalidate the user's token */
const magic = new Magic(process.env.MAGIC_SECRET_KEY)
const userModel = new UserModel()
await Promise.all([
userModel.invalidateFaunaDBToken(token),
magic.users.logoutByIssuer(issuer),
])
// As a final step, we'll clear our session cookie
// (erasing it from the user's browser).
removeSession(res)
res.writeHead(302, { Location: '/' })
res.end()
},
}
...

By invoking userModel.invalidateFaunaDBToken(token) and magic.users.logoutByIssuer(issuer) simultaneously, we are effectively resetting the user's auth status in both the FaunaDB and Magic services. Logout achieved! 🎉


6. Deploy

We'll be using the Vercel platform to deploy our app for some real, live todo-ing. Click here to import a project from your preferred hosted Git provider.

Importing a project from Git into Vercel

Once you're logged in, paste the URL to your repository. Follow the prompts until you reach the following screen, featuring a prominent Deploy button (tempting though it may be, don't click that button just yet).

Vercel pre-deploy screen

Before we continue, we'll need to configure the same environment variables we previously set in .env.local, including:

  • NEXT_PUBLIC_MAGIC_PUBLISHABLE_KEY
  • MAGIC_SECRET_KEY
  • FAUNADB_SECRET_KEY
  • ENCRYPTION_SECRET

Vercel pre-deploy screen

Now you can click that big, beautiful Deploy button! Your app will be live for the whole world to see in a matter of minutes! 🌍 🌎 🌏

Acknowledgements