Guide

NextJS + Supabase + Clerk: Build a simple todo app with multifactor authentication

Braden Sidoti

Braden Sidoti

Learn how to build a todo app with Next.js, Clerk, and Supabase. This app will add todos, sign in, sign up, user profile and multifactor authentication.

Overview

In this article, we are going to explore how we can use Next.js, Clerk, and Supabase to build a very simple todo app. Thanks to Clerk’s simple and powerful authentication options we're also going to add a complete User Profile, and even Multi-factor authentication (MFA).

Why would you add MFA to a simple todo app? Because we need to keep all those todos extra safe. And Clerk makes it insanely easy to do so!

We’ll cover:

  • Creating a new Next.js app from scratch.
  • Configuring Clerk for authentication by setting up Social SSO, password-based authentication, email magic links, and multi-factor authentication via SMS passcodes.
  • Configuring Supabase as a backend for a Postgres database, row-level security (RLS) and authorization policies

The source code for the final version can be found here. You can also checkout the live demo!

The Stack

Next.js is a lightweight react framework that’s optimized for developer experience, and gives you all the features you need to build powerful interactive applications.

Clerk is a powerful authentication solution specifically built for the Modern Web. Clerk lets you choose how you want your users to sign in, and makes it really easy to get started with a suite of pre-built components including <SignIn />, SignUp />, <UserButton />, and <UserProfile />. Clerk also provides complete control with intuitive SDKs and APIs.

Supabase is an open source Firebase alternative. It lets you easily create a backend with a Postgres database, storage, APIs and more. It also has an authentication module, however, for this tutorial we will only be using the database.

Clerk

Let’s start by setting up a new Clerk application with a development instance. After signing up, you’ll want to create a new application and give it a name.

We’ll be using a password authentication strategy or sign in with Google. This default setup also allows users to turn on MFA via SMS code once they login. You can further configure these settings in the dashboard, under “Authentication”.

Instances in Clerk take a few minutes to be fully ready, so let’s start building our Next.js app!

Next.js

The quickest way to get started with Next.js is with the create-next-app template:

npx create-next-app clerk-supa
# or
yarn create next-app clerk-supa

Let’s clear out some of the Next.js template code by replacing pages/index.js with the following:

import styles from "../styles/Home.module.css";
export default function Home() {
return <div className={styles.container}>Hello World!</div>;
}

Then run it, to make sure everything’s working as expected:

npm run dev
# or
yarn dev

Visit http://localhost:3000 and you should now see ‘Hello World!’

Add Sign up and Sign in

Great! Now let’s make it so only people who are signed in can see this page.

First thing we’ll need to do is add the clerk package:

npm install @clerk/nextjs
# or
yarn add @clerk/nextjs

In order for Clerk to work, you’ll need to set a couple of environment variables in your project. In your project’s root directory, create a file named .env.local then, add the following to it:

NEXT_PUBLIC_CLERK_FRONTEND_API={your_frontend_api_key}
CLERK_API_KEY={your_backend_api_key}

Note: Keys prepended with NEXT_PUBLIC are exposed to your javascript, make sure you don’t expose any secrets!

You can find your API keys in the clerk dashboard, in the “API Keys” section.

Your Frontend API key will look something like: clerk.abc.123.lcl.dev and your Backend API key will look like something like: test_B9RjqfOjkvFLk5OVsYFACK98swo9GMKsGGT.

Next, you should replace pages/_app.js with the following code:

import "../styles/globals.css";
import { ClerkProvider, SignedIn, SignedOut, RedirectToSignIn, } from "@clerk/nextjs";
function ClerkSupabaseApp({ Component, pageProps }) {
return (
<ClerkProvider>
<SignedIn>
<Component {...pageProps} />
</SignedIn>
<SignedOut>
<RedirectToSignIn />
</SignedOut>
</ClerkProvider>
);
}
export default ClerkSupabaseApp;

Because we added an environment variable, you’ll need to restart your application. Once you do that, go back to localhost:3000 and you should now see a login page!

Let’s step through how this code is working.

At the top-level, there’s a <ClerkProvider> so that the entire application can access user state. The other two blocks, <SignedIn> and <SignedOut> will only render their children according to the users current state.

When you visited localhost:3000, the <SignedOut> block and <RedirectToSignIn /> rendered, which brought you to your sign in page. Once you sign in, you will be brought back to the simple “Hello World!” page you saw earlier.

Add a User Profile

Before we start writing some todo logic, let’s quickly add a header and a user button.

Replace pages/index.js with the following code:

import styles from "../styles/Home.module.css";
import { UserButton, useUser } from "@clerk/nextjs";
export default function Home() {
const { firstName } = useUser();
return (
<>
<header className={styles.header}>
<div>Todo app</div>
<UserButton />
</header>
<main>
<div className={styles.container}>
{firstName ? `Welcome ${firstName}!` : "Welcome!"}
</div>
</main>
</>
);
}

This is just a header with a “Logo” on the left, and the traditional “User Button” on the right. It’s also using the useUser hook to add a welcome message.

This will need some CSS to actually be a header, so let’s go to styles/Home.module.css.

There will be a lot of code in here from the create-next-app template, most of it can be deleted. Replace the whole file with the following content.

.header {
padding: 1rem 2rem;
background-color: lightgray;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.main {
margin: auto;
max-width: 400px;
}
.container {
padding: 1rem 2rem;
}

Now go back to localhost:3000 and check out the progress.

There’s now a sign up, sign in, a complete user profile, sign out functionality, and everything in the user profile.

Multi-factor authentication

This is already done! With Clerk, multi-factor authentication is on by default. To turn it on for your user, click your profile image in the header, then press "Manage Account."

First you'll need to add a phone number to your account. Once you do that, and verify your phone number, go to the “Security” tab and add a new SMS code verification.

Great! To test it out, sign out and try signing back in. Once you put in your password (or sign in with OAuth) it will send you a text, and ask you for your code. Neat.

Supabase

We’ll be using Supabase as our backend, where it will store our todos, and also act as an API. We want to be able to store all of our users todo’s, and also let them create new ones.

You’ll need a Supabase account here. Once you’re signed in create a new project, giving it a name, database password, and region.

Create “todos” table

Supabase takes a little while to spin up, but once it’s ready, go to the Table editor and create a new table named todos.

Add 2 columns to the table named:

  • user_id the owner of the todo
  • title the title of todo item

For both of these columns, press the settings gear and un-select “Is Nullable”. To be a valid “todo”, both of these fields are required and should have a value. You also need to check “Enable Row Level Security”. This is what we’ll use to manage authorization.

Press Save, but we’re not done. There’s a few more things to setup in Supabase. With Row Level Security enabled, postgres will deny access to every row by default. So, we need to add a Policy that will allow the requesting user to access their own rows.

Configure RLS (Row Level Security)

First, we need a way to figure out what user is making the current request. The following function accomplishes this by pulling the user_id from the JWT that will be sent with every request.

create or replace function requesting_user_id() returns text as $$ select nullif(current_setting('request.jwt.claims', true)::json->>'userId', '')::text; $$ language sql stable;

To create this function, go to the SQL Editor tab, and press “New query”, copy the above into the text area and press RUN. When successful, this function is now loaded and can be used across your database. We’ll use it as part of our RLS policies.

You need two policies, one that lets users create new TODO items, and one that lets users get all of their own todos. You only want to authorize users to do the bare minimum as a matter of practice.

To add an RLS policy to Supabase, go to Authentication > Policies, and press “New Policy” on the todos table

Give the policy a name, choose “INSERT” for the operation and give it the following expression:

requesting_user_id() = user_id

We’ll repeat this process for the “SELECT” operation using the same expression:

requesting_user_id() = user_id

Adding Supabase to our code

It’s almost time to jump into code! We’ll need to set some more environment variables and install the Supabase package. Press the settings gear at the bottom of left menu, and go to API. Here you’ll find your project's public key, URL, and JWT Secret. You’ll need these values in your application, add them to your .env.local with the following format, just below the Clerk variables.

NEXT_PUBLIC_SUPABASE_URL={your_url}
NEXT_PUBLIC_SUPABASE_KEY={your_public_key}
SUPABASE_JWT_SECRET={your_jwt_secret}

You’ll need to add the @supabase/supabase-js package as well.

npm install @supabase/supabase-js
# or
yarn add @supabase/supabase-js

To keep our code organized, create a new folder named Supabase and add a new file at supabase/client.js . In here will put the code to instantiate a Supabase client, which will be able to connect to the our Supabase project.

import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_KEY
);
const supabaseClient = async () => {
// get a valid supabase token
const res = await fetch("/api/getSupabaseToken");
const { jwt } = await res.json();
supabase.auth.session = () => ({ access_token: jwt, });
return supabase;
};
export { supabaseClient };

Because the function we used in Supabase used a JWT for authorization, we need to create a new one and send it with every request. These JWTs need to be created using the jwt_signing_secret, which can’t be exposed to the frontend — doing so would mean anyone can create JWTs and that would not be secure! So we’re leveraging a Next.js serverless function to create a secure JWT.

Create a file pages/api/getSupabaseToken.js and put the following function in it:

import { requireSession } from "@clerk/nextjs/api";
import jwt from "jsonwebtoken";
export default requireSession(async (req, res) => {
const payload = {
userId: req.session.userId,
exp: Math.floor(Date.now() / 1000) + 60,
};
const token = jwt.sign(payload, process.env.SUPABASE_JWT_SECRET);
res.status(200).json({ jwt: token });
});

The requireSession middleware from Clerk will send back unauthorized unless there’s user currently signed in. The rest of the function is generating a JWT with the requesting users id that expires in 60 seconds, signed with the Supabase secret. The expiration time is kept short to minimize the damage of any of them leaking. Because they’re generated for each request, we don’t need them to live that long.

Adding Todo code

Now that we can securely make a request to Supabase with our user’s information attached, let’s list out all of the todos for the current user. There’s two parts to building this out.

  1. The UI that shows our list of todos
  2. The request to fetch all todos from Supabase.

Replace pages/index.js with the following:

import { useState, useEffect } from "react";
import styles from "../styles/Home.module.css";
import { useUser, UserButton } from "@clerk/nextjs";
import { supabaseClient } from "../supabase/client";
export default function Home() {
const { firstName } = useUser();
const [todos, setTodos] = useState(null);
// load todos
useEffect(() => {
const loadTodos = async () => {
const sc = await supabaseClient();
try {
const { data: todos } = await sc.from("todos").select("*");
console.log(todos);
setTodos(todos);
} catch (e) {
alert(e);
}
};
loadTodos();
}, []);
const addTodo = (newTodo) => {
setTodos([...todos, newTodo]);
};
return (
<>
<header className={styles.header}>
<div>Todo app</div>
<UserButton />
</header>
<main>
<div className={styles.container}>
{firstName ? `Welcome ${firstName}!` : "Welcome!"}
<AddTodoForm addTodo={addTodo} />
{todos?.length > 0 ? (
<ol>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ol>
) : (
<p>You don't have anything to do!</p>
)}
</div>
</main>
</>
);
}
function AddTodoForm({ addTodo }) {
const { id } = useUser();
const [newTodo, setNewTodo] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
if (newTodo === "") {
alert("If you have nothing to do, you don't need me!");
return;
}
const sc = await supabaseClient();
const resp = await sc.from("todos").insert({ title: newTodo, user_id: id });
addTodo(resp.data[0]);
setNewTodo("");
};
return (
<form onSubmit={handleSubmit}>
<input onChange={(e) => setNewTodo(e.target.value)} value={newTodo} />{" "}
<button>Add Todo</button>
</form>
);
}

This code adds a bunch of things. It uses useEffect to fetch all of the users todos on first load, and adds state to display all of the fetched todos. It also creates the AddTodoForm component, which is a very simple form that will create a new todo.

Closing remarks

That’s the end of this tutorial! NextJS, Clerk, and Supabase are a really powerful combination that let you build secure, scalable apps incredibly fast. Even though this example is pretty basic, it has loads Hope you enjoyed this, and if you have any questions, you can reach me on Twitter here: @bsinthewild.

Ready to see what Clerk can do for you?Start your free trial today

Start completely free with up to 500 monthly active users. No credit card required.

Start building now