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
waitlisttable in Supabase - Setting up a Supabase server client in Next.js
- Writing a
joinWaitlistserver action - Building a
Waitlistclient 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
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);
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;
},
},
}
);
}
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 };
}
Key ideas:
- You use the Supabase server client to perform the
insert, which keeps credentials and logic off the client. - Error code
23505corresponds 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
joinWaitlistserver 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>
);
}
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>
);
}
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."
/>
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
waitlisttable and form - Using Server Actions to revalidate pages or send welcome emails after signup
- Building an internal dashboard that reads from the
waitlisttable 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:
Feel free to look at the source code, use it for your registry shadcn, and you can see it live at :
Thank you for your time.
Enjoy ! 🚀
Top comments (0)