16th January 2024 5 min read

How to build a waitlist with Supabase and Next.js

Mohammed Elhadi Baci

Let's build a simple app where:

1. Users sign up and join a waitlist.

2. Upon admin approval, users get a notification email and can use the app.

We'll use Next.js, Supabase, and Resend.

The code for this tutorial can be accessed here.

Let's first setup a project from the Supabase dashboard and create a waitlist table from the Supabase SQL editor :

mysql
1create table waitlist (2  user_id uuid not null references auth.users on delete cascade,3  email text unique not null,4  approved boolean not null default false,5  primary key (user_id)6);7
8alter table waitlist enable row level security;9
10CREATE POLICY "Users can read their own waitlist row" ON "public"."waitlist"11AS PERMISSIVE FOR SELECT12TO authenticated13USING ((auth.uid() = user_id))14

What the query does:

  • Creates a waitlist table with user_id, email and approved columns.
  • Each waitlist row corresponds to a unique user_id column from the built-in auth.users table that holds all signed-up users. Therefore, user_id is both a foreign and primary key.
  • When a user from the auth.users table is deleted, its corresponding waitlist row is deleted as well.
  • approved is a boolean value that determines whether the user has access to the app or not.
  • Lastly, it applies row-level security to give read access to each user to its waitlist row.

Now let's create a database function, handle_new_user , that populates a waitlist row whenever a new user signs up:

mysql
1create function public.handle_new_user () returns trigger language plpgsql security definer2set3  search_path = public as $$4begin5  insert into public.waitlist (user_id, email)6  values (new.id, new.email);7  return new;8end;9$$;10
11
12create trigger on_auth_user_created13after insert on auth.users for each row14execute procedure public.handle_new_user ();

Let's use create-next-app to create a Next.js project with authentication already backed-in:

sh
1npx create-next-app -e with-supabase

Once Supabase environment variables are filled in, we can run the project locally.

Notice that signup already works and creates a waitlist entry.

A new row inserted in the waitlist table for the signed-up user

Let's create the following 2 pages:

  1. A dashboard exclusively for accessible use by approved waitlist members.
  2. A waitlist landing page for all logged-in individuals not yet approved
tsx
1// app/dashboard/page.tsx2
3export default function Dashboard() {4  return (5    <div className="text-center min-h-screen flex flex-col justify-center gap-5">6      <h1 className="text-6xl font-medium">You're in!</h1>7      <p>Welcome to the dashboard! We're excited to have you on board.</p>8    </div>9  );10}
tsx
1// app/waitlist/page.tsx2
3export default function Waitlist() {4  return (5    <div className="text-center min-h-screen flex flex-col justify-center gap-5">6      <h1 className="text-6xl font-medium">Hold on!</h1>7      <p>8        We're not quite ready for you yet. We'll let you know when we're ready9        to onboard you.10      </p>11    </div>12  );13}

To make the access logic work, let's some redirects it in the middleware.ts file, which is executed before a user accesses any route:

typescript
1import { NextResponse, type NextRequest } from "next/server";2import { createClient } from "@/utils/supabase/middleware";3
4export async function middleware(req: NextRequest) {5  const { supabase, response } = createClient(req);6
7  const { data } = await supabase.auth.getSession();8
9  // If the user is accessing a page other than dashboard, do nothing10  if (req.nextUrl.pathname !== "/dashboard") return response;11
12  const userLoggedIn = !!data?.session?.user;13
14  // If the user is not logged in, redirect to login page15  if (!userLoggedIn) {16    return NextResponse.redirect(req.nextUrl.origin + "/login");17  }18
19  // If user is logged in.20
21  // Fetch the user's waitlist entry22  const { data: waitlistEntry } = await supabase23    .from("waitlist")24    .select("approved")25    .eq("user_id", data.session?.user.id)26    .single();27
28  // The user is approved, allow access to dashboard29  if (waitlistEntry?.approved) return response;30
31  // The user is not approved, redirect to waitlist page32  return NextResponse.redirect(req.nextUrl.origin + "/waitlist");33}

Logged-in users are now redirected to the waitlist if they try to access the dashboard before getting approved on the waitlist:

Setting approved to true on the waitlist table makes it possible for users to access the dahsboard:

Let's use Resend to notify users by email whenever they have granted access to the app.

1. Setup Resend

Once a Resend account is created, we can generate an API key and store it on Supabase secrets with the alias RESEND_API_KEY.

Supabase edge functions secrets

2. Create a Supabase edge function to send emails

We'll use Supabase CLI to create and deploy an edge function that sends emails.

First, we login with the Supabase CLI:

sh
1npx supabase login

Then we initialize a local Supabase project:

sh
1npx supabase init

A new supabase folder will be generated at the root of the application.

Now let's link it to the remote Supabase project:

sh
1npx supabase link

With the CLI linked, let's run the following command to create a Supabase edge function named send-approval-email and instruct it to send an email using Resend:

typescript
1import { serve } from "https://deno.land/std@0.168.0/http/server.ts";2import { createClient } from "https://esm.sh/@supabase/supabase-js";3
4// The Resend API key is stored in an environment variable from the Supabase dashboard5const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY");6
7const handler = async (_request: Request): Promise<Response> => {8  const json = await _request.json();9
10  const record = json.record;11
12  // If the new value of approved is not true, do nothing13  if (!record?.approved)14    return new Response(15      JSON.stringify({16        message: "User not approved",17      }),18      {19        status: 200,20        headers: {21          "Content-Type": "application/json",22        },23      }24    );25
26  try {27    const supabaseClient = createClient(28      // Supabase API URL - env var exported by default.29      Deno.env.get("SUPABASE_URL") ?? "",30      // Supabase API ANON KEY - env var exported by default.31      Deno.env.get("SUPABASE_ANON_KEY") ?? "",32      // Create client with Auth context of the user that called the function.33      // This way your row-level-security (RLS) policies are applied.34      {35        global: {36          headers: { Authorization: _request.headers.get("Authorization")! },37        },38      }39    );40
41    // Fetch the user's data from the auth.users table42    const { data: userData } = await supabaseClient.auth.admin.getUserById(43      record.user_id44    );45
46    const email = userData?.user?.email;47
48    // If the user doesn't have an email, do nothing49    if (!email)50      return new Response(51        JSON.stringify({52          message: "User not found",53        }),54        {55          status: 200,56          headers: {57            "Content-Type": "application/json",58          },59        }60      );61
62      // The user has an email, and is approved, send the email63    const res = await fetch("https://api.resend.com/emails", {64      method: "POST",65      headers: {66        "Content-Type": "application/json",67        Authorization: `Bearer ${RESEND_API_KEY}`,68      },69      body: JSON.stringify({70        from: 'onboarding@resend.dev',71        to: email,72        subject: "You've been accepted 🎉",73        html: "<strong>You're in!</strong>",74      }),75    });76
77    const data = await res.json();78
79    return new Response(JSON.stringify(data), {80      status: 200,81      headers: {82        "Content-Type": "application/json",83      },84    });85  } catch (error) {86    console.error(error);87
88    return new Response(JSON.stringify(error), {89      status: 500,90      headers: {91        "Content-Type": "application/json",92      },93    });94  }95};96
97serve(handler);

This function receives a waitlist row and, if it's approved, retrieves the corresponding email address from auth.users and sends an email using Resend API and the api-key created earlier.

To deploy the function to Supabase, we run the following command:

sh
1npx supabase functions deploy send-approval-email

The function is now deployed and can be seen on the database functions screen on Supabase's dashboard.

Supabase edge functions dashboard

Supabase edge functions dashboard

3. Trigger email notifications

All that is left is for us to create a database webhook that triggers the send-approval-email function whenever the waitlist table is changed.

To create a webhook, let's open the project's webhooks dashboard, and enable the feature.

Once we have the webhook table visible click on the Create new hook at the top right and fill in the hook info:

  • The hook name: any value is accepted as long as it doesn't contain spaces.
  • The table: the waitlist table that we want to listen to its changes.
  • Events: Choose the update event since that's what we want to listen for.
  • Type: should be Supabase edge function.
  • Edge function: the send-approval-email function should be selected by default, if not select it.
  • Authentication header: since all edge functions deployed with authorization enabled we need to add an Authorization header to the webhook request.

Now that the webhook is set, the entire flow can be tested.