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 :
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 SELECT
12TO authenticated
13USING ((auth.uid() = user_id))
14
What the query does:
- Creates a waitlist table with
user_id
,email
andapproved
columns. - Each waitlist row corresponds to a unique
user_id
column from the built-inauth.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:
1create function public.handle_new_user () returns trigger language plpgsql security definer
2set
3 search_path = public as $$
4begin
5 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_created
13after insert on auth.users for each row
14execute procedure public.handle_new_user ();
Let's use create-next-app to create a Next.js project with authentication already backed-in:
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:
- A dashboard exclusively for accessible use by approved waitlist members.
- A waitlist landing page for all logged-in individuals not yet approved
1// app/dashboard/page.tsx
2
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}
1// app/waitlist/page.tsx
2
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 ready
9 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:
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 nothing
10 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 page
15 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 entry
22 const { data: waitlistEntry } = await supabase
23 .from("waitlist")
24 .select("approved")
25 .eq("user_id", data.session?.user.id)
26 .single();
27
28 // The user is approved, allow access to dashboard
29 if (waitlistEntry?.approved) return response;
30
31 // The user is not approved, redirect to waitlist page
32 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:
1npx supabase login
Then we initialize a local Supabase project:
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:
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:
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 dashboard
5const 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 nothing
13 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 table
42 const { data: userData } = await supabaseClient.auth.admin.getUserById(
43 record.user_id
44 );
45
46 const email = userData?.user?.email;
47
48 // If the user doesn't have an email, do nothing
49 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 email
63 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:
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
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.