DEV Community

Naud
Naud

Posted on

Build a Production Waitlist with Supabase & Next.js in 10 Minutes

If you’re building a SaaS, a waitlist is one of the fastest ways to validate demand and collect emails. In this guide, we’ll build a polished waitlist UI as a client component, and use a server action to insert emails into Supabase securely.

We’ll cover:

  • Creating the waitlist table in Supabase
  • Setting up a Supabase server client in Next.js
  • Writing a joinWaitlist server action
  • Building a Waitlist client component that calls this action

1. Prerequisites

You’ll need:

  • A Next.js App Router project (v13+ / 14+), with TypeScript and Tailwind/shadcn
  • A Supabase project
  • Your Supabase URL and publishablekey from the Supabase dashboard (Settings → API).

Add these to .env.local:

NEXT_PUBLIC_SUPABASE_URL=your_url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key
Enter fullscreen mode Exit fullscreen mode

Your server client helper (used below) will typically read the same URL and a service or publishable key from environment variables, following the standard Supabase + Next.js setup.


2. Create the waitlist Table in Supabase

First, create the table that will store your signups.

In the Supabase SQL editor, run:

CREATE TABLE waitlist (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

ALTER TABLE waitlist ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Allow anonymous inserts" ON waitlist
  FOR INSERT TO anon WITH CHECK (true);
Enter fullscreen mode Exit fullscreen mode

This is similar to other Supabase waitlist tutorials: a waitlist table with a unique email, RLS enabled, and an insert policy for anonymous users.


3. Supabase Server Client & joinWaitlist Server Action

Next, we define a server-side helper that creates a Supabase client, then a server action that inserts an email into the waitlist table. This follows the pattern recommended for writing data with Server Actions.

In something like lib/supabase/server.ts (or your existing helper):

// Example shape – adapt to your project setup.
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";

export function createClient() {
  const cookieStore = cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value;
        },
      },
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

This mirrors common Supabase examples where a server-side client is created with cookie-based auth.

Now, in components/waitlist/action.ts (or similar), define your server action:


import { createClient } from "@/lib/supabase/server";

export async function joinWaitlist(email: string) {
  const supabase = await createClient();

  const { error } = await supabase.from("waitlist").insert({ email });

  if (error?.code === "23505") {
    return { error: "This email is already on the waitlist" };
  }

  if (error) {
    return { error: error.message };
  }

  return { success: true };
}
Enter fullscreen mode Exit fullscreen mode

Key ideas:

  • You use the Supabase server client to perform the insert, which keeps credentials and logic off the client.
  • Error code 23505 corresponds to a unique constraint violation, so you can return a friendly “already on the waitlist” message, like other examples do.

4. The Waitlist Client Component

Now let’s build the UI that users interact with. This is a client component that:

  • Renders a shadcn-style waitlist form
  • Validates the email on the client
  • Calls the joinWaitlist server action and shows feedback
  • Optionally displays social proof avatars

Create components/waitlist/waitlist.tsx:

"use client";

import { useState } from "react";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { joinWaitlist } from "./action";

type SubmitStatus = "idle" | "loading" | "success" | "error";

interface WaitlistProps {
  badge?: string;
  heading?: string;
  description?: string;
  inputPlaceholder?: string;
  buttonText?: string;
  successMessage?: string;
  socialProof?: {
    avatars: string[];
    text: string;
  };
  className?: string;
}

export function Waitlist({
  badge = "Coming Soon",
  heading = "Join the Waiting List",
  description = "Be amongst the first to experience our product. Sign up to be notified when we launch!",
  inputPlaceholder = "Enter your email",
  buttonText = "Join waitlist",
  successMessage = "You're on the list! We'll notify you when we launch.",
  socialProof = {
    avatars: [
      "https://github.com/shadcn.png",
      "https://github.com/shadcn.png",
      "https://github.com/shadcn.png",
      "https://github.com/shadcn.png",
    ],
    text: "Join 2,500+ others on the waitlist",
  },
  className,
}: WaitlistProps) {
  const [email, setEmail] = useState("");
  const [status, setStatus] = useState<SubmitStatus>("idle");
  const [message, setMessage] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      setStatus("error");
      setMessage("Please enter a valid email address");
      return;
    }

    setStatus("loading");
    setMessage("");

    const result = await joinWaitlist(email);

    if (result.error) {
      setStatus("error");
      setMessage(result.error);
    } else {
      setStatus("success");
      setMessage(successMessage);
      setEmail("");
    }
  };

  const isDisabled = status === "loading" || status === "success";

  return (
    <section className={cn("container mx-auto px-6 py-12 md:py-24", className)}>
      <div className="mx-auto max-w-2xl space-y-4 text-center">
        <Badge variant="outline" className="gap-1.5 uppercase">
          <div className="size-1.5 rounded-full bg-primary" />
          {badge}
        </Badge>

        <h2 className="text-4xl font-semibold tracking-tight md:text-5xl">
          {heading}
        </h2>
        <p className="text-muted-foreground">{description}</p>

        <form onSubmit={handleSubmit}>
          <div className="mx-auto flex max-w-md items-center gap-2 rounded-full border bg-background p-1.5 shadow-sm">
            <Input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder={inputPlaceholder}
              disabled={isDisabled}
              className="flex-1 rounded-full border-0 bg-transparent shadow-none focus-visible:ring-0"
            />
            <Button
              type="submit"
              disabled={isDisabled}
              className="rounded-full px-6"
            >
              {status === "loading" && (
                <Loader2 className="mr-2 size-4 animate-spin" />
              )}
              {buttonText}
            </Button>
          </div>
        </form>

        {message && (
          <p
            className={cn(
              "mt-2 text-sm",
              status === "error" ? "text-destructive" : "text-green-600"
            )}
          >
            {message}
          </p>
        )}

        {socialProof && (
          <div className="mt-6 flex items-center justify-center gap-3">
            <div className="flex -space-x-2">
              {socialProof.avatars.map((avatar, index) => (
                <img
                  key={index}
                  src={avatar}
                  alt={`User ${index + 1}`}
                  className="size-8 rounded-full border-2 border-background object-cover"
                />
              ))}
            </div>
            <p className="text-sm text-muted-foreground">{socialProof.text}</p>
          </div>
        )}
      </div>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why this pattern works well with Server Actions:

  • The component is a normal client component ("use client"), so you can use hooks and nice UI interactions.
  • The heavy lifting (DB insert, secrets, RLS) runs only on the server in joinWaitlist.
  • The client just calls joinWaitlist(email) like any async function and displays the returned error/success.

5. Using the Waitlist in a Page

In the App Router, usage is straightforward.

For example, app/page.tsx:

import { Waitlist } from "@/components/waitlist/waitlist";

export default function Page() {
  return (
    <main>
      <Waitlist />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

You can also customize the copy and reuse it on different pages:

<Waitlist
  heading="Get Early Access"
  description="Sign up to be the first to try the beta."
/>
Enter fullscreen mode Exit fullscreen mode

Because all the database logic lives in the server action, the page itself stays simple and doesn’t need to know about Supabase at all.


6. Where to Go Next

From here, you can enhance this pattern by:

  • Adding extra fields (name, source, product choice) to the waitlist table and form
  • Using Server Actions to revalidate pages or send welcome emails after signup
  • Building an internal dashboard that reads from the waitlist table and lets you approve users

This client + server action approach gives you a clean separation: a reusable UI component on the client, and secure Supabase writes on the server.


Want to discover more blocks & Saas components ?

Discover ShadcnShip, 30+ production-ready blocks with preview system, theme switching, and registry:

👉 GitHub – arnaudvolp/shadcn-ui-blocks

Feel free to look at the source code, use it for your registry shadcn, and you can see it live at :

👉 Website – Shadcnship Live demo

Thank you for your time.
Enjoy ! 🚀

Top comments (0)