DEV Community

Cover image for Build a full-stack app with Next.js and Supabase
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Build a full-stack app with Next.js and Supabase

Written by Taofiq Aiyelabegan✏️

When it comes to building and choosing frameworks for your next full-stack application, combining Next.js with Supabase is one of the best options to work with in my opinion.

Supabase is an open source Firebase alternative with a lot of powerful tools, including seamless authentication. As a developer, this is key to building a successful full-stack application.

Alongside authentication, Supabase comes with other features, such as a Postgres database, real-time subscriptions, and object storage. I believe that Supabase is one of the easiest backend-as-a-services to get started or integrate with.

In this article, we will learn how to build a full-stack app using Next.js and Supabase. We’ll talk about how to set up a Supabase project, configure the UI, and implement authentication and functionalities.

The concept of this app is for users to track and create workout activities based on specified parameters, edit these activities if there are any mistakes or necessary changes, and delete them if needed. Let’s get started!

Introduction to Next.js and Supabase

Next.js is one of the easiest and most popular ways to build production-ready React applications. Over recent years, Next.js has experienced significant exponential growth and many companies have adopted it to build their applications.

Why should we use Supabase?

Supabase is a serverless, open-source alternative to Firebase built on top of the PostgreSQL database. It provides all the backend services needed to create a full-stack application.

As a user, you can manage your database from the Supabase interface, ranging from creating tables and relationships to writing your SQL queries and real-time engine on top of PostgreSQL.

Supabase comes with really cool features that make your full-stack application development even easier. Some of these features are:

  • Row-level security (RLS) – Supabase comes with the PostgreSQL RLS feature that allows you to restrict rows in your database tables. When you create policies, you create them directly with SQL
  • Real-time database – Supabase has an update feature on the PostgreSQL database that can be used to listen to real-time changes
  • Supabase UI – Supabase has an open-source user interface component library to create applications quickly and efficiently
  • User authentication – Supabase creates an auth.users table as soon as you create your database. When you create an application, Supabase will also assign a user and ID as soon as you register on the app that can be referenced within the database. For log in methods, there are different ways you can authenticate users such as email, password, magic links, Google, GitHub, and more
  • Edge functions – Edge functions are TypeScript functions distributed globally at the edge, close to users. They can be used to perform functions such as integrating with third parties or listening for WebHooks

Initiating our project with Next.js

To initiate our project in the terminal with the Next.js template, we will run the following command:

npx create-next-app nextjs-supabase
Enter fullscreen mode Exit fullscreen mode

nextjs-supabase is our app's folder name where we’ll encompass the Next.js app template.

We’ll need to install the Supabase client package to connect to our Next.js app later. We can do so by running either of the following commands:

yarn add @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

or

npm i @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

Once the app has finished setting up, open the folder in your favorite code editor. Now, we can remove the basic template in our /pages/index.js file and replace it with an h1 heading saying “Welcome to Workout App.”

After that’s done, run the command yarn dev in the terminal to start up your app at http://localhost:3000. You should see a page like this:

Welcome Screen

Setting up a Supabase project and creating a database table

To set up a Supabase project, visit app.supabase.com to sign in to the app dashboard using your GitHub account.

Supabase Dashboard

Once you log in, you can create your organization and set up a new project within it by clicking All Projects.

All Projects And Organization Screen

Click on New Project and give your project a name and database password. Click the Create a new project button; it will take a couple of minutes for your project to be up and running.

Create A New Project

Once the project has been created, you should see a dashboard like this:

Workout Next Supabase Dashboard

For this tutorial, I already created a project named workout-next-supabase.

Now, let’s create our database table by clicking on the SQL Editor icon on our dashboard and clicking New Query. Enter the SQL query below in the editor and click RUN to execute the query.

CREATE TABLE workouts (
 id bigint generated by default as identity primary key,
 user_id uuid references auth.users not null,
 user_email text,
 title text,
 loads text,
 reps text,
 inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);

alter table workouts enable row level security;

create policy "Individuals can create workouts." on workouts for
   insert with check (auth.uid() = user_id);

create policy "Individuals can update their own workouts." on workouts for
   update using (auth.uid() = user_id);

create policy "Individuals can delete their own workouts." on workouts for
   delete using (auth.uid() = user_id);

create policy "Workouts are public." on workouts for
   select using (true);
Enter fullscreen mode Exit fullscreen mode

Execute Your Query

This will create the workout table we’ll use to build our CRUD application.

Alongside creating a table, row-level permissions will be enabled to ensure that only authorized users can create, update, or delete the details of their workouts.

To check out how the workout table looks, we can click the Table Editor icon on the dashboard to see the workout table we just created.

For this application, we will have seven columns:

  • user_id
  • user_email
  • id
  • title
  • loads
  • reps
  • Date stamp

Table Editor

Once our table and columns are set, the next step is to connect our Supabase database with our Next.js frontend application!

Connecting Next.js with a Supabase database

To connect Supabase with our Next.js app, we will need our Project URL and Anon Key. Both of these can be found on our database dashboard. To get these two keys, click on the gear icon to go to Settings and then click API. You’ll see these two keys show up like this:

Url Api Setup

Of course, we don’t want to expose these values publicly on the browser or our repository since it’s sensitive information. To our advantage, Next.js provides inbuilt support for environment variables that allow us to create a .env.local file in the root of our project. This will load our environment variables and expose them to the browser by prefixing it with NEXT_PUBLIC.

Now, let’s create a .env.local file in the root of our project and include our URL and keys in the file.

.env.local

NEXT_PUBLIC_SUPABASE_URL= // paste your project url here
NEXT_PUBLIC_SUPABASE_ANON_KEY= // paste your supabase anon key here
Enter fullscreen mode Exit fullscreen mode

N.B., Don't forget to include .env.local in your gitignore file to prevent it from being pushed to the GitHub repo (and available for everyone to see) when deploying.

Now let’s create our Supabase client file by creating a file called supabase.js at the root of our project. Inside the supabase.js file, we will write the following code:

// supabase.js
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseKey);
Enter fullscreen mode Exit fullscreen mode

Here, we are importing a createClient function from Supabase and creating a variable called supabase. We call the createClient function and then pass in our parameters: URL ( supabaseUrl) and Anon Key (supabaseKey).

Now, we can call and use the Supabase client anywhere in our project!

Configuring our app’s UI

First, we need to configure our app to look the way we want it to. We’ll have a navigation bar with the project name, and Login and Signup options when the app is first loaded. When a user signs up and logs in, we will display the navbar to have Home, Logout, and Create Workout buttons.

There will also be a footer on every page on the website.

To do this, we will create a component folder that’ll house the Navbar.js and Footer.js files. Then, inside _app.js, we will wrap our pages component with the Navbar and the Footer components so they are displayed on every page of the app.

// _app.js
import Footer from "../components/Footer";
import Navbar from "../components/Navbar";
import "../styles/globals.css";

function MyApp({ Component, pageProps }) {
 return (
   <div>
     <Navbar/>
     <Component {...pageProps}/>
     <Footer />
   </div>
 );
}

export default MyApp;
Enter fullscreen mode Exit fullscreen mode

I created a GitHub gist here to see what these two components look like alongside the styles I used.

Now, our homepage should look like this:

Adrenargy Home Screen

Implementing user authentication

To implement user authentication, we will initialize the user state in our _app.js file and create a validateUser function to check and validate a user. We’ll then set the user state to the session object that is returned.

// _app.js

import { useState, useEffect } from "react";
import Footer from "../components/Footer";
import Navbar from "../components/Navbar";
import "../styles/globals.css";
import { supabase } from "../utils/supabase";

function MyApp({ Component, pageProps }) {
 const [session, setSession] = useState(null);

 useEffect(() => {
   setSession(supabase.auth.session());
   supabase.auth.onAuthStateChange((_event, session) => {
     setSession(session);
   });
 }, []);
 return (
   <div>
     <Navbar session={session} />
     <Component {...pageProps} session={session} />
     <Footer />
   </div>
 );
}
export default MyApp;
Enter fullscreen mode Exit fullscreen mode

When a user loads the homepage of our app, we want to display a button to tell them to either log in or sign up. When the Login button is clicked, it should redirect the user to a page where the user can enter their email and password. If they are an existing user and the login details are valid, they will be redirected to the home page.

If the user has invalid credentials, an alert message will display to tell the user about the issue. They’ll be shown a sign up option instead.

When the user signs up, a confirmation email will be sent to the email they entered. they’ll need to confirm their email by clicking on the link in the body of the email.

Confirm Signup Email

Now, when we click the Login button, we should be redirected to the user page to this page:

Login Page

Now, we can click on the Sign up button and enter an email.

Sign Up Page

Once we click this, an email will be sent to confirm the email address. Upon confirming, it will log us in and we should see a page like this:

Welcome Screen With No Workouts Yet

Notice that if we have not signed in, we are unable to see our activity dashboard, see a button to create a new workout, or log out. This was the authentication mentioned initially that’s provided to us by Supabase!

Implementing workout functionalities

Now, we’ll dive into creating a user’s ability to create, modify, and delete their workouts.

Fetching all workouts

We’ll need to fetch all the workouts we’ll be creating and render them on the homepage. We will do this inside the index.js file:

// /pages/index.js
import Head from "next/head";
import Link from "next/link";
import { useEffect, useState } from "react";
import styles from "../styles/Home.module.css";
import { supabase } from "../utils/supabase";
import WorkoutCard from "../components/WorkoutCard";

export default function Home({ session }) {
 const [workouts, setWorkouts] = useState([]);
 const [loading, setLoading] = useState(true);

 useEffect(() => {
   fetchWorkouts();
 }, []);

 const fetchWorkouts = async () => {
   const user = supabase.auth.user();
   try {
     setLoading(true);
     const { data, error } = await supabase
       .from("workouts")
       .select("*")
       .eq("user_id", user?.id);

     if (error) throw error;
     setWorkouts(data);
   } catch (error) {
     alert(error.message);
   } finally {
     setLoading(false);
   }
 };

 if (loading) {
   return <div className={styles.loading}>Fetching Workouts...</div>;
 }
 return (
   <div className={styles.container}>
     <Head>
       <title>Nextjs x Supabase</title>
       <meta name="description" content="Generated by create next app" />
       <link rel="icon" href="/favicon.ico" />
     </Head>

     <div className={styles.home}>
       {!session?.user ? (
         <div>
           <p>
             Welcome to Adrenargy. Kindly log in to your account or sign in for
             a demo
           </p>
         </div>
       ) : (
         <div>
           <p className={styles.workoutHeading}>
             Hello <span className={styles.email}>{session.user.email}</span>,
             Welcome to your dashboard
           </p>
           {workouts?.length === 0 ? (
             <div className={styles.noWorkout}>
               <p>You have no workouts yet</p>
               <Link href="/create">
                 <button className={styles.button}>
                   {" "}
                   Create a New Workout
                 </button>
               </Link>
             </div>
           ) : (
             <div>
               <p className={styles.workoutHeading}>Here are your workouts</p>
               <WorkoutCard data={workouts}/>
             </div>
           )}
         </div>
       )}
     </div>
   </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

In this component, we are destructuring the session object we passed from the page props in the _app.js file and using that to validate authorized users. If there are no users, the dashboard will not be displayed. If there is a user logged in, the dashboard of workouts will appear. And if there are no workouts created, a text saying “You have no workout yet” and a button to create a new one will appear.

To render our created workouts, we have two states: workouts, an empty array, and a loading state that takes in a boolean value of true. We are using useEffect to fetch the workouts data from the database when the page is loaded.

The fetchWorkouts function is used to call the Supabase instance to return all the data from the workout tables in our database using the select method. The .eq() filter method is used to filter out and return only the data with the user id matching the current logged in user. Then, setWorkouts is set to the data sent from the database, and setLoading is set back to false once we fetch our data.

If the data is still being fetched, the page should display “Fetching Workouts…” and if the request made to our database returns the array of our workouts, we want to map through the array and render the WorkoutCard component.

In the WorkoutCard component, we are rendering the workout title, load, reps, and the date and time it was created. The time created is being formatted using the date-fns library that you can check out here. We will see how our cards look when we start creating them in the next section.

// Workoutcard.js

import Link from "next/link";
import styles from "../styles/WorkoutCard.module.css";
import { BsTrash } from "react-icons/bs";
import { FiEdit } from "react-icons/fi";
import { formatDistanceToNow } from "date-fns/";

const WorkoutCard = ({ data }) => {
 return (
   <div className={styles.workoutContainer}>
     {data?.map((item) => (
       <div key={item.id} className={styles.container}>
         <p className={styles.title}>
           {" "}
           Title: {""}
           {item.title}
         </p>
         <p className={styles.load}>
           {" "}
           Load(kg): {"  "}
           {item.loads}
         </p>
         <p className={styles.reps}>Reps:{item.reps}</p>
         <p className={styles.time}>
           created:{" "}
           {formatDistanceToNow(new Date(item.inserted_at), {
             addSuffix: true,
           })}
         </p>
       </div>
     ))}
   </div>
 );
};

export default WorkoutCard;
Enter fullscreen mode Exit fullscreen mode

Creating a new workout

Now that we’ve logged in, our dashboard is fresh and clean. To implement the ability to create a new workout, we will add create.js and Create.module.css files in the pages and styles folder respectively, and implement some logic and styling.

// /pages/create.js

import { supabase } from "../utils/supabase";
import { useState } from "react";
import styles from "../styles/Create.module.css";
import { useRouter } from "next/router";

const Create = () => {
 const initialState = {
   title: "",
   loads: "",
   reps: "",
 };

 const router = useRouter();
 const [workoutData, setWorkoutData] = useState(initialState);

 const { title, loads, reps } = workoutData;

 const handleChange = (e) => {
   setWorkoutData({ ...workoutData, [e.target.name]: e.target.value });
 };

 const createWorkout = async () => {
   try {
     const user = supabase.auth.user();

     const { data, error } = await supabase
       .from("workouts")
       .insert([
         {
           title,
           loads,
           reps,
           user_id: user?.id,
         },
       ])
       .single();
     if (error) throw error;
     alert("Workout created successfully");
     setWorkoutData(initialState);
     router.push("/");
   } catch (error) {
     alert(error.message);
   }
 };

 return (
   <>
     <div className={styles.container}>
       <div className={styles.form}>
         <p className={styles.title}>Create a New Workout</p>
         <label className={styles.label}>Title:</label>
         <input
           type="text"
           name="title"
           value={title}
           onChange={handleChange}
           className={styles.input}
           placeholder="Enter a title"
         />
         <label className={styles.label}>Load (kg):</label>
         <input
           type="text"
           name="loads"
           value={loads}
           onChange={handleChange}
           className={styles.input}
           placeholder="Enter weight load"
         />
         <label className={styles.label}>Reps:</label>
         <input
           type="text"
           name="reps"
           value={reps}
           onChange={handleChange}
           className={styles.input}
           placeholder="Enter number of reps"
         />

         <button className={styles.button} onClick={createWorkout}>
           Create Workout
         </button>
       </div>
     </div>
   </>
 );
};

export default Create;
Enter fullscreen mode Exit fullscreen mode

Here, the basic UI scope is that we will have a form to create a new workout. The form will consist of three fields (title, load, and reps) as we specified when creating our database.

An initial state object is defined to handle all these fields that were passed to the workoutsData state. The onChange function is used to handle the input field changes.

The createWorkout function uses the Supabase client instance to create a new workout using the initial state fields we defined and insert it into the database table.

Finally, we have an alert toast that informs us when our new workout has been created.

Then, we set the form data back to the initial empty string state once our workout has been created. After that, we’re using the router.push method to navigate the user back to the homepage.

Create New Project

Workout Successfully Created

Dashboard With Workouts Dumbell Press

Updating a workout

To update a workout, we will create a folder called edit within our pages folder that’ll hold our [id].js file. We’ll create an edit link icon on our workout component card that links to this page. When the cards are rendered on the homepage, we can click on this edit icon and it will take us to the edit page of that particular card.

We will then fetch the details of the needed workout card to be updated from our workouts table by its id and the authorized owner of the card. Then, we’ll create a updateWorkout function to update our workout card details:

// /pages/edit/[id].js
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import styles from "../../styles/Edit.module.css";
import { supabase } from "../../utils/supabase";

const Edit = () => {
 const [workout, setWorkout] = useState("");
 const router = useRouter();

 const { id } = router.query;
 useEffect(() => {
   const user = supabase.auth.user();
   const getWorkout = async () => {
     const { data } = await supabase
       .from("workouts")
       .select("*")
       .eq("user_id", user?.id)
       .filter("id", "eq", id)
       .single();
     setWorkout(data);
   };
   getWorkout();
 }, [id]);

 const handleOnChange = (e) => {
   setWorkout({
     ...workout,
     [e.target.name]: e.target.value,
   });
 };

 const { title, loads, reps } = workout;
 const updateWorkout = async () => {
   const user = supabase.auth.user();
   const { data } = await supabase
     .from("workouts")
     .update({
       title,
       loads,
       reps,
     })
     .eq("id", id)
     .eq("user_id", user?.id);

   alert("Workout updated successfully");

   router.push("/");
 };
 return (
   <div className={styles.container}>
     <div className={styles.formContainer}>
       <h1 className={styles.title}>Edit Workout</h1>
       <label className={styles.label}> Title:</label>
       <input
         type="text"
         name="title"
         value={workout.title}
         onChange={handleOnChange}
         className={styles.updateInput}
       />
       <label className={styles.label}> Load (kg):</label>
       <input
         type="text"
         name="loads"
         value={workout.loads}
         onChange={handleOnChange}
         className={styles.updateInput}
       />
       <label className={styles.label}> Reps:</label>
       <input
         type="text"
         name="reps"
         value={workout.reps}
         onChange={handleOnChange}
         className={styles.updateInput}
       />

       <button onClick={updateWorkout} className={styles.updateButton}>
         Update Workout
       </button>
     </div>
   </div>
 );
};

export default Edit;
Enter fullscreen mode Exit fullscreen mode

First, we create a state to store the workout card details that’ll be fetched from our table. Then, we extract the id of that card using the useRouter hook. The getWorkout function calls the Supabase client instance to filter the id of that workout card and returns the data (title, loads, and reps). Once the workout card details have been returned, we can create our updateWorkout function to modify the details using the .update()function.

Once the workout has been updated by the user and the Update workout button is clicked, an alert message is sent and the user will be redirected back to the homepage.

Let’s see how it works.

Click on the edit icon to go to the edit page. We’ll be renaming the title from “Dumbell Press” to “Arm Curl”:

Edit Workout Dumbell Press

Edit Workout Successful

Edit Workout With Arm Curl

Deleting a workout

To delete a workout on each card, we will create the handleDelete function that’ll take in the id as an argument. We’ll call the Supabase instance to delete a workout card using the .delete()function. This .eq('id', id) specifies the id of the row to be deleted on the table.

 const handleDelete = async (id) => {
   try {

     const user = supabase.auth.user();
     const { data, error } = await supabase
       .from("workouts")
       .delete()
       .eq("id", id)
       .eq("user_id", user?.id);
     fetchWorkouts();
     alert("Workout deleted successfully");
   } catch (error) {
     alert(error.message);
   }
 };
Enter fullscreen mode Exit fullscreen mode

The eq('user_id', user?.id) is used to check if the card that is being deleted belongs to that particular user. The function will be passed to the WorkoutCard component in the index.js file and destructured for usage in the component itself as follows:

const WorkoutCard = ({ data, handleDelete }) => {
 return (
   <div className={styles.workoutContainer}>
     {data?.map((item) => (
       <div key={item.id} className={styles.container}>
         <p className={styles.title}>
           {" "}
           Title: {""}
           {item.title}
         </p>
         <p className={styles.load}>
           {" "}
           Load(kg): {"  "}
           {item.loads}
         </p>
         <p className={styles.reps}>Reps:{item.reps}</p>
         <p className={styles.time}>
           created:{" "}
           {formatDistanceToNow(new Date(item.inserted_at), {
             addSuffix: true,
           })}
         </p>

         <div className={styles.buttons}>
           <Link href={`/edit/${item.id}`}>
             <a className={styles.edit}>
               <FiEdit />
             </a>
           </Link>
           <button
             onClick={() => handleDelete(item.id)}
             className={styles.delete}
           >
             <BsTrash />
           </button>
         </div>
       </div>
     ))}
   </div>
 );
};
Enter fullscreen mode Exit fullscreen mode

An alert toast will be displayed once the card has been deleted successfully and the user will be redirected to the homepage.

Deploying to Vercel

Now, we have to deploy our application to Vercel so anybody on the Internet can use it! To deploy to Vercel, you must first push your code to your repository, log in to your Vercel dashboard, click on Create New Project, and click the repository to which you just pushed your code. Enter the environment variables we created earlier alongside their values (NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY) in the Environment Variable field and click Deploy to deploy your app to production.

Deploy To Vercel

And there we have it!

Conclusion

Thank you for reading! I hope this tutorial gives you the required knowledge needed to create a full-stack application using Next.js and Supabase.

You can customize the styling to your use case, as this tutorial majorly focuses on the logic of creating a full-stack application.

You can find the full repository of this project here and the live Vercel link here. If you’d like to read more on Supabase and Next.js, you can check out their docs.


LogRocket: Full visibility into production Next.js apps

Debugging Next applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your Next.js apps — start monitoring for free.

Top comments (0)