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

Category
Guides
Published

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.

Updated: 12/2/22

Overview

In this article, we are going to explore how we can use Next.js, Clerk, and Supabase to build a very basic 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 even todos deserve to be protected with the best security. And... Clerk makes it insanely easy to do so with our best-in-class Next.js authentication.

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.

Next.js

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

In your terminal, create your app with the following command:

npx create-next-app clerk-supa

Let’s start with a simpler base by removing most of the Next.js template, and adding a basic nav bar, with a content area for our home page.

Since this is not a CSS tutorial, we'll use some very basic styling and add it all up front.

Replace the contents of styles/Home.module.css with the following:

.header {
  padding: 1rem 2rem;
  height: 4rem;
  background-color: lightgray;
  width: 100%;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.main {
  margin: auto;
  max-width: 400px;
}

.label {
  padding: 1rem 2rem;
}

.container {
  padding: 1rem 2rem;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.todoList {
  padding-left: 1rem;
  align-self: baseline;
}

As promised, our basic home page will include a simple nav bar and body.

Replace the contents of pages/index.js with the following:

import styles from '../styles/Home.module.css'

export default function Home() {
  return (
    <>
      <header className={styles.header}>
        <div>My Todo App</div>
      </header>
      <main>
        <div className={styles.container}>Sign in to create your todo list!</div>
      </main>
    </>
  )
}

It's going to be beautiful... Let's run our app, and see where we're starting!

In your terminal, start your application with the following command:

npm run dev

Now you can visit http://localhost:3000, and you should see the following:

Clerk

It's easiest to start an app like this with our user object in place, and with a complete authentication foundation. Then, we can build everything else on top. Clerk gives us very simple and complete building blocks that makes this whole process very easy.

First thing you'll need to do is sign up to Clerk. Once you verify your email address, you'll be greeted with a way to create your first application:

I'm choosing the default settings, which will collect an email and password, or have users sign in with google. You can choose whatever you'd like, and once your application is created, you can fine-tune them a lot more in the Clerk dashboard.

When you create a new application, a new development instance is automatically created for you, which is optimized for local development.

Note: Instances in Clerk take a few minutes to be completely ready. Behind the scenes resources are being spun up, DNS records are being set, and certificates are being created. These take time to fully propagate across the internet, so if you see any errors, give it a minute and refresh.

Add Sign up and Sign in

Now you're going to add sign up and sign in forms, and build a "protected" page, that only signed in users can see.

You will first need to add the @clerk/nextjs to your project. Clerk brings a ton of authentication capabilities to Next.js, but for this tutorial we'll just set up the basics of sign-up and sign-in. In your terminal, at the root directory of your project, install the @clerk/nextjs package with the following command:

Note: this tutorial will be using the latest alpha release, so make sure you're on at least version >=3.0

npm install @clerk/nextjs@next

Now you need environment variables to link your local development with your Clerk instance.

You can find your API keys on the home page, in the "Connect your application" section.

In your project’s root directory, create a file named .env.local . Next.js will automatically load all environment variables set here, into your application. Because we're not using a custom backend at all, you only need your Frontend API key

Get the values from the dashboard, add the following to your .env.local file:

NEXT_PUBLIC_CLERK_FRONTEND_API={your_frontend_api_key}

Note: Keys prepended with NEXT_PUBLIC are exposed to your javascript, so do not put any secret values here!

Any time you add or change your environment variables, you'll need to restart your application, so go to your terminal and kill the running application with CTRL+C or CMD+C and relaunch it with the following command:

npm run dev

Great! although nothing changed, your application can now properly see these environment variables.

Now you can use Clerk's helper components to build a "gated" page. In order to use these helpers, you should expose the Clerk context to your entire application, by wrapping the top level component with a <ClerkProvider/>.

In Next.js, the page exported from pages/_app.js is the top level of your application. By wrapping the <Component /> here, every part of your application will have access to the Clerk context.

Replace pages/_app.js with the following code:

import '../styles/globals.css'
import { ClerkProvider } from '@clerk/nextjs'

function ClerkSupabaseApp({ Component, pageProps }) {
  return (
    <ClerkProvider>
      <Component {...pageProps} />
    </ClerkProvider>
  )
}

export default ClerkSupabaseApp

Great! Now you can go back to pages/index.js, add a sign in button and some basic gated content. We'll use the following Clerk-provided hooks and components:

  • useUser() - hook to easily access the current user, and it's state
  • <SignInButton /> - displays an unstyled Sign in button
  • <SignUpButton /> - displays an unstyled Sign in button
  • <UserButton /> - displays a "UserButton" for the current user

Replace pages/index.js with the following code:

import styles from '../styles/Home.module.css'

import { useAuth, useUser, UserButton, SignInButton, SignUpButton } from '@clerk/nextjs'

export default function Home() {
  const { isSignedIn, isLoading, user } = useUser()
  return (
    <>
      <Header />
      {isLoading ? (
        <></>
      ) : (
        <main className={styles.main}>
          <div className={styles.container}>
            {isSignedIn ? (
              <>
                <div className={styles.label}>Welcome {user.firstName}!</div>
              </>
            ) : (
              <div className={styles.label}>Sign in to create your todo list!</div>
            )}
          </div>
        </main>
      )}
    </>
  )
}

const Header = () => {
  const { isSignedIn } = useUser()

  return (
    <header className={styles.header}>
      <div>My Todo App</div>
      {isSignedIn ? (
        <UserButton />
      ) : (
        <div>
          <SignInButton />
          &nbsp;
          <SignUpButton />
        </div>
      )}
    </header>
  )
}

The header now shows either sign in/up buttons or a User Button, depending on your state. The content section of this page follows similar logic. Go ahead and sign up, if you sign up with Google, it will pull your profile image, and you will be able to see your User Button with a simple greeting.

Clerk provides a lot of hooks, components, and helpers - you can explore all of them in our docs.

There's even more to explore with this application. The User Button has a link Manage Account, which will bring you to a complete User Profile page.

You can do a lot from this page, including:

  • Change your profile image, name, etc.
  • Add/remove email addresses
  • See what devices your logged into, (and log out of unrecognized devices)
  • Change your password
  • Secure your account with MFA

That's right, you don't need to write any more code to add MFA to your application. Clerk handles it out of the box. Just go to the Security tab in your user profile, and follow the steps to add your phone number as a second factor!

Note: This sample app is leveraging Clerk-hosted UIs, which is the simplest implementation path. There are other ways you can add build this features, including mounting the UI on a page you control, or by building it with a completely custom UI, using the Frontend API.

Now that your user management foundation is in place it's time to connect to a database and create some todos!

Supabase

This tutorial is using Supabase as a backend. Supabase is a very powerful, open-source, postgres-based, database platform. It will be great for easily storing your todos, and even work as your API.

The first thing you'll need is a Supabase account, which can be created here. Once you have an account, create a new project.

  • Pick your organization
  • Give it a name: My Todo App
  • Create a Database password, like: mysecurepassword1234
  • And choose the region closest to you, or your users.

Create your "todos” table

Supabase takes a little while to spin up, but once it’s ready, go to the Table editor and press the "Create a new table" button

Name the table todos and, add 2 new columns to the table named:

  • Name: title Type: text
  • Name: user_id Type: text

For each of the new columns, press the cog and deselect "Is Nullable"

Also, make sure you select "Enable Row Level Security (RLS)", which is above the Columns.

With Row Level Security enabled, postgres (and thus Supabase) will deny access to every row by default. So, you will need to setup up some "Policies" that will allow the requesting user to access their own rows, and that will let them create new rows for themselves.

With all this done, press "Save" at the bottom right of the screen.

Configure RLS (Row Level Security)

Before creating your "RLS Policies", Supabase will need a way to figure out which user is making the current request. You normally would be able to use Supabase's built-in auth.uid() function, however there's currently an issue that we're working to resolve! Upvote it so the issue can get more attention :)

The workaround is easy enough though - you will create your own requesting_user_id() function named that accomplishes the same thing as auth.uid() just in a Clerk-compatible way.

All requests up to Supabase will include a JWT, which will contain the sub claim -- which is the id of the requesting user. The following function grabs that sub, and turns it into function so that you can use it in your "RLS Policies".

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

To create this function, go to the SQL Editor > New Query and copy the above into the text area and press RUN. This function is now loaded into your database and can be used as part of your RLS Policies.

You can now create two policies, one that lets your users create new todos for themself, and one that lets users retrieve all of their own todos. As a matter of practice, you only want to allow your users to do the minimum that is needed for your application.

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

Next, you'll want to press "Create a policy from scratch".

  • Give the policy a name: Users can select their own todos
  • Choose SELECT as the allowed operation
  • Give it the following expression: requesting_user_id() = user_id

Press "Review" then "Save Policy".

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

  • Give the policy a name: Users can create todos for themself
  • Choose INSERT as the allowed operation
  • Give it the following expression: requesting_user_id() = user_id

Press "Review" then "Save Policy".

Making requests to Supabase

It’s almost time to jump back into code! You’ll need to set some more environment variables and install the Supabase package.

Go to your Supabase Settings > API. Here you’ll find your project's public key, URL, and JWT Secret:

You’ll need the first 2 values in your application, so add them to your .env.local with the following format, just below the Clerk variables.

NEXT_PUBLIC_SUPABASE_KEY={your_projects_public_key}
NEXT_PUBLIC_SUPABASE_URL={your_config_url}

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

npm install @supabase/supabase-js

You're still missing the ability to generate JWTs that Supabase can understand. To do this, you will need to sign your tokens with the JWT Secret Supabase provides (the bottom most arrow in the above screenshot).

Clerk does all this heavy lifting for you, making it easy for you to generate Supabase JWTs directly from your frontend!

Create a Supabase JWT Template in Clerk

Go back to your Clerk dashboard, and navigate to JWT Templates. Press "New Template" then select the "Supabase" template to get started.

This will populate a template with most of what you need. You will still need to:

  • Use Signing algorithm: HS256
  • Copy your Supabase JWT Secret into "Signing key"

Once you're done, press "Apply Changes" in the bottom right, and you're good to go! You'll now be able to call session.GetToken("Supabase") in your frontend code.

Show all todos

Although this was a fair amount of setup, you've added a TON of capabilites by leveraging a Frontend Stack while barely writing any code! You now have advanced session and user management capabilities, and a connection to a powerful backend - where you can easily make authenticated requests! Time to see it in action.

First you should show a list of all of the users current todos. Go back to pages/index.js and add a helper function that will allow you to make requests to Supabase.

// ... other imports

import { createClient } from '@supabase/supabase-js'

const supabaseClient = async (supabaseAccessToken) => {
  const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_KEY, {
    global: { headers: { Authorization: `Bearer ${supabaseAccessToken}` } },
  })

  return supabase
}

// ... your Home() function

Now you can make your <TodoList /> component. Add the following code to pages/index.js:

// imports to add:
import { useState, useEffect } from 'react'
import { useSession } from '@clerk/nextjs'

// ... rest of code ...

const TodoList = ({ todos, setTodos }) => {
  const { session } = useSession()
  const [loading, setLoading] = useState(true)

  // on first load, fetch and set todos
  useEffect(() => {
    const loadTodos = async () => {
      try {
        setLoading(true)
        const supabaseAccessToken = await session.getToken({
          template: 'supabase',
        })
        const supabase = await supabaseClient(supabaseAccessToken)
        const { data: todos } = await supabase.from('todos').select('*')
        setTodos(todos)
      } catch (e) {
        alert(e)
      } finally {
        setLoading(false)
      }
    }
    loadTodos()
  }, [])

  // if loading, just show basic message
  if (loading) {
    return <div className={styles.container}>Loading...</div>
  }

  // display all the todos
  return (
    <>
      {todos?.length > 0 ? (
        <div className={styles.todoList}>
          <ol>
            {todos.map((todo) => (
              <li key={todo.id}>{todo.title}</li>
            ))}
          </ol>
        </div>
      ) : (
        <div className={styles.label}>You don't have any todos!</div>
      )}
    </>
  )
}

This code makes use of several hooks.

  • Clerk's useSession() hook lets you easily access the current session. This will only return the session if the user is logged in. You need access to the current session so that you can generate a Supabase JWT, and send it up with requests.
  • Reacts useState() hook lets you manage state inside of function components. This code is using it to manage the fetch loading state.
  • This code is also letting the parent component manage the todos state, but since this code still needs to access the data, its passed through as props.
  • Reacts useEffect() hook lets you perform actions when a change takes place in one of it's dependencies. This code is not passing in any dependencies, so it will only fire on first load.

The logic is pretty basic for this app:

  1. On first load, generate a JWT for the current user, that Supabase can understand. Then, fetch all of the todos using a supabase fetch function. Once the todos are loaded, update the state of the component.
  2. The loading state is only based on the inital fetch. So, to avoid any "flickering", show a basic loading state.
  3. If the component is not loading, then you must have your todos, so iterate and display them!

Now, update your Home() function to display the <TodoList /> when a user is signed in:

// ... rest of code ...

export default function Home() {
  const { isSignedIn, isLoading, user } = useUser()
  // Manage todos state here!
  const [todos, setTodos] = useState(null)

  return (
    <>
      <Header />
      {isLoading ? (
        <></>
      ) : (
        <main className={styles.main}>
          <div className={styles.container}>
            {isSignedIn ? (
              <>
                <span className={styles.label}>Welcome {user.firstName}!</span>
                // Add your TodoList here!
                <TodoList todos={todos} setTodos={setTodos} />
              </>
            ) : (
              <div className={styles.label}>Sign in to create your todo list!</div>
            )}
          </div>
        </main>
      )}
    </>
  )
}

// ... rest of code ...

Perfect. Now there's only one problem with all of this. You haven't given your users a way to create any todos, so all they will see is the following!

Create a new todo

Now you should create a simple form so that your user can create new todos. Use the following code to create your <AddTodoForm /> component:

// ... rest of code ...

function AddTodoForm({ todos, setTodos }) {
  const { getToken, userId } = useAuth()
  const [newTodo, setNewTodo] = useState('')
  const handleSubmit = async (e) => {
    e.preventDefault()
    if (newTodo === '') {
      return
    }

    const supabaseAccessToken = await getToken({
      template: 'supabase',
    })
    const supabase = await supabaseClient(supabaseAccessToken)
    const { data } = await supabase.from('todos').insert({ title: newTodo, user_id: userId }).select()

    setTodos([...todos, data[0]])
    setNewTodo('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={(e) => setNewTodo(e.target.value)} value={newTodo} />
      &nbsp;<button>Add Todo</button>
    </form>
  )
}

// ... rest of code ...

This components follows a similar pattern to the <TodoList /> component. It's creating a form, with an onSubmit handler, that will send a request to Supabase to create a new todo item. Then, it updates the internal state of todos so that it can display right away.

Now you should display this component in the appropriate spot in your Home() function:

// ... rest of code ...

export default function Home() {
  const { isSignedIn, isLoading, user } = useUser()
  const [todos, setTodos] = useState(null)

  return (
    <>
      <Header />
      {isLoading ? (
        <></>
      ) : (
        <main className={styles.main}>
          <div className={styles.container}>
            {isSignedIn ? (
              <>
                <span className={styles.label}>Welcome {user.firstName}!</span>
                // Add your AddTodoForm here!
                <AddTodoForm todos={todos} setTodos={setTodos} />
                <TodoList todos={todos} setTodos={setTodos} />
              </>
            ) : (
              <div className={styles.label}>Sign in to create your todo list!</div>
            )}
          </div>
        </main>
      )}
    </>
  )
}

// ... rest of code ...

That's it! Now refresh your app, and try creating a new todo item, you should see it appear in your list.

Closing remarks

There's a lot that still needs to be added to this app, even to just make it a true todo app. For starters you need a way to mark todo items complete, and delete todo items. It also needs to be productionized on it's own domain so that you can use it on the world wide web. It would also benefit from some server side rendering, which would remove some of the minor "flickering" that happens during loading.

Regardless, as you can see, NextJS, Clerk, and Supabase are truly powerful tools that let you build secure, scalable apps incredibly quickly! The Frontend Stack is shaping up to be an extremely powerful paradigm that lets you build complete applications without managing a database, or even backend code! The future of web development is shaping up to be impressive...

Thanks for reading! I hope you enjoyed this tutorial. If you have any questions join us in Discord, or reach out to me directly on twitter @bsinthewild.

You can also follow us @ClerkDev to hear about the latest and greatest from Clerk.

Author
Braden Sidoti