Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. Nextjs Waitlist Page: Countdown, Referrals, Real Counts
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. Nextjs Waitlist Page: Countdown, Referrals, Real Counts
Build it: screens and apps
Build it: screens and apps

Nextjs Waitlist Page: Countdown, Referrals, Real Counts

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 10 min read

On this page

0%
  1. What does a waitlist landing page need?
  2. How do I install the Next.js waitlist template?
  3. How do I store signups and compute a place in line?
  4. How do I connect the form with a Server Action?
  5. Should a waitlist page show the signup count?
  6. How do I add a launch countdown in Next.js?
  7. What should I add before I share the waitlist link?
  8. Where to go from here
  9. FAQ
    1. How do I build a waitlist page in Next.js?
    2. How does a referral waitlist calculate someone's place in line?
    3. Should a waitlist page show how many people signed up?
    4. Is the Wingo UI waitlist template free?
    5. How do I stop people from gaming a referral waitlist?

TL;DR

A Next.js waitlist page needs five parts: a countdown to the launch, one email field, the visitor's place in line, a referral link that moves them up, and a signup count shown only when it is real. Build the screen with the Wingo UI Waitlist Page template and save signups through a Server Action into one Postgres table. The position and the referral code then come from your database instead of the template's sample numbers.

Published Oct 16, 2026

A Nextjs waitlist page is often the first page a product has, and it is easy to ship with the template's sample numbers still in it. Our own template's defaults show a "Join 4,812 people" line, a position that is just the count plus one, and a referral code that no database knows about. This tutorial builds a pre-launch page in Next.js 16 with a countdown to the launch, an email signup, the visitor's real place in line, a referral link that moves them up, and a signup count that only appears when it is true.

What does a waitlist landing page need?

A waitlist landing page needs five parts, because it has one job: get an email address from someone who is interested, and give them a reason to bring a friend.

  1. A launch date or a clear "coming soon". A countdown works when you have a date you will keep. Without one, say "coming soon" and skip the timer.
  2. One email field. Each field you add is one more thing to fill in before the payoff. An optional role select is the most we would add.
  3. A place in line after signup. "You're #213" is the payoff for typing the email, and it gives the referral link a purpose.
  4. A referral link with one-tap copy and share. Each signup gets a unique code; friends who join with it move the referrer up.
  5. Social proof only if it is real. KickoffLabs' pre-launch waitlist guide (opens in a new tab) recommends showing the count and says small numbers hurt: "Zero feels bad. Ten feels bad." It names 500 or more as the size where a count starts to create FOMO (checked October 16, 2026). That pressure is how fake counts end up in production. The fix is to read the count from the database and hide it until it is worth showing.

If you want a Next.js starter instead of a component library, Vercel lists Waitly (opens in a new tab), which stores signups in Notion, rate limits with Upstash Redis and sends email through Resend; its Notion schema includes referral code and referred-by fields (checked October 16, 2026). This post takes the other route: a finished screen from a component library and storage you own.

How do I install the Next.js waitlist template?

Wingo UI ships the screen as the Waitlist Page template: the countdown, the email form, the success card with the position and referral link, feature teasers and an FAQ. If you searched for a "nextjs waitlist template", this is one in the shadcn sense: the source is copied into your project, so you can change any line.

bash
npx wingo-ui@latest add waitlist-page

That adds components/templates/waitlist-page.tsx, its sample data file and the parts it uses: the Countdown, the Copy Button for the referral link, the Accordion for the FAQ, the Select for the optional role, plus the marketing header, footer and section blocks.

Try the flow before wiring anything. The demo below uses a pretend backend that waits 900 ms and returns a position.

Type an email and press Join the waitlist: the button spins, then the form turns into your place in line with a referral link you can copy and share. A bad address shows an inline error first
$ npx wingo-ui@latest add waitlist-page
ProWaitlist Page docs

The props that matter for this tutorial, all taken from the template's source:

PropDefaultWhat it does
launchAt"2026-11-17T17:00:00.000Z"The countdown target and the date in the pill; null gives a coming soon page
count4812The "Join N people" line and the fallback position (count + 1)
peoplesix sample namesThe initials stack next to the count
onJoinwaits 900 msReceives { email, role }; may resolve { position, referralCode }; a rejection shows the form error
referralUrlhttps://acme.com/mobile?ref=The share link is this plus the code
spotsPerReferral10The number in "Move up 10 spots for every friend who joins"
rolessix sample rolesOptions of the optional role select; [] hides it
showSocialstrueThe "Follow the build" band with sample social links
labels, features, faqsAcme sample copyEvery visible string

Note the defaults for count and people. They exist so the screen looks finished in a preview. If you ship them, your page claims 4,812 signups you do not have. The rest of this tutorial replaces both with data.

How do I store signups and compute a place in line?

One Postgres table is enough for the data. The referral bonus lives in a tiny shared file, because both the query and the page copy need it:

ts
// app/waitlist/config.ts
export const SPOTS_PER_REFERRAL = 10

The table holds the rest. Each row has the email, an optional role, a random referral code, and the code of whoever referred them.

sql
create table waitlist (
id bigserial primary key,
email text not null unique,
role text,
referral_code text not null unique,
referred_by text references waitlist (referral_code),
created_at timestamptz not null default now()
);
create index waitlist_referred_by on waitlist (referred_by);

The position rule: your score is your join order minus 10 for every person who joined with your code. Your position is one plus the number of people with a lower score, with ties going to whoever joined first. Join order comes from row_number(), not from id directly: an insert ... on conflict burns a sequence value even when it updates, so repeat signups leave gaps in id, and a gap would make a 10-spot bonus worth fewer than 10 people. This sketch uses the postgres (opens in a new tab) client (npm install postgres); any driver that runs SQL works the same way.

ts
// app/waitlist/waitlist-db.ts
import "server-only"
import { randomBytes } from "node:crypto"
import postgres from "postgres"
import { SPOTS_PER_REFERRAL } from "./config"
const sql = postgres(process.env.DATABASE_URL!)
export async function waitlistCount() {
const [row] = await sql`select count(*)::int as count from waitlist`
return row.count as number
}
async function positionOf(code: string) {
const [row] = await sql`
with scored as (
select w.id, w.referral_code,
row_number() over (order by w.id) - ${SPOTS_PER_REFERRAL} * (
select count(*) from waitlist r where r.referred_by = w.referral_code
) as score
from waitlist w
)
select count(*)::int + 1 as position
from scored s
join scored me on me.referral_code = ${code}
where s.score < me.score or (s.score = me.score and s.id < me.id)`
return row.position as number
}
export async function addToWaitlist(input: { email: string; role?: string; ref?: string }) {
// a referral only counts when the code exists
const [referrer] = input.ref
? await sql`select referral_code from waitlist where referral_code = ${input.ref}`
: []
// joining twice returns the first row: same code, same referrer
const [row] = await sql`
insert into waitlist (email, role, referral_code, referred_by)
values (
${input.email},
${input.role ?? null},
${randomBytes(6).toString("hex")},
${referrer?.referral_code ?? null}
)
on conflict (email) do update set email = excluded.email
returning referral_code`
const referralCode = row.referral_code as string
return { referralCode, position: await positionOf(referralCode) }
}

Two details carry most of the weight. The on conflict clause makes a repeat signup return the original code instead of an error, so someone who joins twice sees the same link. And because the referrer is only written on the first insert, joining again with your own link cannot change who referred you. The template also exports a referralCode(email) helper that derives a short code from the address; it is a fine fallback for previews, but a random code stored in the database cannot be guessed from someone's email.

Each position query scores the whole table, which is cheap while the list is small and referred_by is indexed. Once it grows, keep a referrals counter column, increment it when a referred signup lands, and score from that column instead of the subquery.

How do I connect the form with a Server Action?

The template is a client component, so the page splits in two: a server component that reads data, and a small client wrapper that renders the template. The save goes through a Server Action that validates the email and returns exactly the shape onJoin expects.

ts
// app/waitlist/actions.ts
"use server"
import { addToWaitlist } from "./waitlist-db"
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export async function joinWaitlist(entry: { email: string; role?: string }, ref?: string) {
const email = entry.email.trim().toLowerCase()
if (email.length > 254 || !EMAIL.test(email)) throw new Error("Invalid email")
return addToWaitlist({ email, role: entry.role?.slice(0, 40), ref })
}

When the action throws, the promise rejects and the form shows its "That didn't work" message. When it resolves, the success card shows the position and builds the share link from referralUrl plus the returned code.

The server page reads the ref code from the URL and the real count. In Next.js 16 searchParams is a promise, and reading it makes the page render per request, which you want here anyway because the count changes.

tsx
// app/waitlist/page.tsx
import type { Metadata } from "next"
import { Waitlist } from "./waitlist"
import { waitlistCount } from "./waitlist-db"
export const metadata: Metadata = {
title: "Acme Notes waitlist",
description: "Acme Notes opens on December 1. Join the waitlist to get an invite.",
}
export default async function WaitlistRoute({ searchParams }: PageProps<"/waitlist">) {
const { ref } = await searchParams
const count = await waitlistCount()
return <Waitlist count={count} referredBy={typeof ref === "string" ? ref : undefined} />
}

Should a waitlist page show the signup count?

Show it only when it is real and big enough to help. Pass the real count from the database, and pass 0 until it crosses a threshold you pick: the template renders the "Join N people" line and the initials stack only when count is above zero. The wrapper below uses 500, the size the KickoffLabs guide names. Pass people={[]} regardless: those are sample names, and showing real people's initials needs their permission.

tsx
// app/waitlist/waitlist.tsx
"use client"
import Link from "next/link"
import { WaitlistPage } from "@/components/templates/waitlist-page"
import { joinWaitlist } from "./actions"
import { SPOTS_PER_REFERRAL } from "./config"
// below this, a small count works against you, so the line stays hidden
const MIN_SOCIAL_PROOF = 500
export function Waitlist({ count, referredBy }: { count: number; referredBy?: string }) {
return (
<WaitlistPage
linkComponent={Link}
launchAt="2026-12-01T17:00:00Z"
count={count >= MIN_SOCIAL_PROOF ? count : 0}
people={[]}
roles={[]}
spotsPerReferral={SPOTS_PER_REFERRAL}
referralUrl="https://example.com/waitlist?ref="
onJoin={(entry) => joinWaitlist(entry, referredBy)}
showSocials={false}
labels={{
pillSoon: "Acme Notes · Coming soon",
pillLaunch: (date) => `Acme Notes · Opens ${date}`,
title: "Notes that sort themselves",
description: "Write anywhere. Acme Notes files it, links it and finds it again. Join the waitlist for an early invite.",
countdownDone: "Acme Notes is open. Invites are going out now.",
shareText: "I just joined the Acme Notes waitlist.",
emailSubject: "Join me on the Acme Notes waitlist",
}}
features={[
{ title: "Write first", description: "Open the app and type. Folders and tags come later, or never." },
{ title: "Linked by default", description: "Mention a note and Acme links both ways." },
{ title: "Search that reads", description: "Find a note by what it says, not by what you named it." },
]}
faqs={[
{ question: "When does Acme Notes open?", answer: "December 1, 2026. Invites go out in order of the list." },
{ question: "How do I move up?", answer: `Every friend who joins with your link moves you up ${SPOTS_PER_REFERRAL} spots.` },
{ question: "Can I leave the list?", answer: "Any time, from the link in any email we send." },
]}
/>
)
}

The constant comes from config.ts instead of waitlist-db.ts, because importing the database file into a client component would pull the Postgres driver and DATABASE_URL toward the browser bundle. The import "server-only" line at the top of waitlist-db.ts turns that mistake into a build error instead of a runtime surprise; Next.js handles the import itself, and installing the server-only package is optional.

Keep the FAQ and the success card in sync with your actual rules. The template's sample FAQ promises 10 spots per referral; if your query uses 5, both the prop and the copy have to say 5. Replace every sample string in labels, features and faqs before launch, including the pill: the template formats the launch date in New York time, so if your launch is midnight in Europe, write the date yourself in pillLaunch instead of using its argument.

This is the whole referral waitlist React flow: a visitor opens /waitlist?ref=3f9a1c2b7d04, the server page reads the code, the form sends it with the email, and the new row credits the referrer on the next position query.

How do I add a launch countdown in Next.js?

Pass the launch moment as an ISO string to launchAt, and the template hands it to the Countdown component, which does the ticking, the hydration gap and the finish for you. The Countdown drops leading zero units as time passes (days while the launch is far away, only minutes and seconds in the last hour), ticks on the unit boundaries and swaps in the countdownDone message at zero. Until the client knows the time after hydration, the digits render as -- at their final width, so nothing shifts. If you render the page for screenshots or need fixed server HTML, the now prop pins the clock.

You can use the same component outside the page, for example in an announcement bar on the main site. The text variant writes the time left in words inside a sentence:

tsx
import { Countdown } from "@/components/ui/countdown"
export function LaunchBar() {
return (
<p className="text-sm text-muted-foreground">
Acme Notes opens{" "}
<Countdown to="2026-12-01T17:00:00Z" variant="text" completedContent="today" />
</p>
)
}

Screen readers do not hear every tick; the component updates a sentence once a minute and announces the finish once.

Pick when the launch is with the buttons under the card: in 10 seconds it ticks down and swaps in the done message, in 45 days it shows days, and a past date renders done right away
$ npx wingo-ui@latest add countdown
ProCountdown docs

What should I add before I share the waitlist link?

Add rate limiting, email confirmation and a way to leave the list. The page works at this point, but these three belong on the server before the link goes out:

  • Rate limiting. A Server Action is a public endpoint. Limit it per IP the way you limit any signup form; Waitly uses Upstash Redis for this.
  • Email confirmation. Send a confirmation link and count a referral only once the referred person confirms. Otherwise a script with fake addresses climbs to #1. Add a confirmed_at column and filter the referral count on it.
  • Unsubscribe and deletion. If your FAQ promises people can leave any time, build that path before launch.

On phones the template already does the mobile work: if you keep the role select, it opens as a bottom sheet titled with its label below 768px, and with layout="split" the phone mockup moves under the copy below 1024px. When launch day comes, the Launch Page template can take over the same URL with its live phase, and the Referral Page template covers an ongoing referral program after the waitlist closes.

More screens built this way are in the Build it category, starting with the Next.js admin dashboard tutorial. Once people are in, the React onboarding flow guide picks up the first run, and the shadcn pricing page tutorial covers the page they see when the free invite ends.

Where to go from here

Install the template with npx wingo-ui@latest add waitlist-page, swap the sample copy and data for yours, and connect the Server Action to your database. The Waitlist Page and the Countdown are part of Wingo UI Pro, which costs $8 a month, $80 a year or $150 once as of October 2026 (pricing); the Copy Button, Accordion and Select it uses are free.

Components in this post

  • Waitlist Page

    React waitlist page template: a launch countdown, an email signup with social proof, your place in line with a referral link and FAQ.

    Pro
  • Countdown

    A React countdown timer to a launch, sale or deadline, shown as rolling digit blocks, inline in a sentence or in words, with done content.

    Pro

FAQ

How do I build a waitlist page in Next.js?

Render the page from a server component that reads the real signup count and the ?ref= code, pass both to a client component with the form, and save each email through a Server Action that returns the person's position and referral code. The Wingo UI Waitlist Page template covers the screen; you supply the storage.

How does a referral waitlist calculate someone's place in line?

One simple rule is join order minus a bonus per referral: each friend who joins with your link takes a fixed number of spots off your score, and your position is one plus the number of people with a lower score. One Postgres query with row_number() and a referral count computes it.

Should a waitlist page show how many people signed up?

Only when the number is real. Read it from your database and hide the line until it is large enough to mean something; the Waitlist Page template hides the count and the avatar stack when you pass count={0}.

Is the Wingo UI waitlist template free?

No. The Waitlist Page template and the Countdown are part of Wingo UI Pro, while the parts it builds on, such as the Copy Button, Accordion, Button and Select, are free. Everything installs with npx wingo-ui@latest add.

How do I stop people from gaming a referral waitlist?

Make the email column unique, never let a second signup change who referred someone, rate limit the action, and count a referral only after the new person confirms their email. Some fake addresses will still get through, so do not promise invites strictly by position.

  • Next.js
  • Waitlist
  • Landing page
  • React
  • Tailwind CSS

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

React Countdown Timer: Blocks, Inline, Sleeping Tabs

Build a React countdown timer as rolling digit blocks, inline text or words, and handle time zones, sleeping tabs, hydration and the finish without bugs.

SRSerban Rusu·Oct 19, 2026·9 min read
Build it: screens and apps

Nextjs Blog Template: Markdown, Live Demos and JSON-LD

How we built a nextjs blog template on Markdown files: zod frontmatter, live React demos, a sticky table of contents, JSON-LD, feeds and a strict validator.

SRSerban Rusu·Oct 16, 2026·10 min read
Build it: screens and apps

Nextjs SaaS Landing Page: What Each Section Must Prove

Build a nextjs saas landing page section by section: what the hero, proof, features, pricing and FAQ must prove, what to cut, and how each reads on a phone.

SRSerban Rusu·Oct 16, 2026·11 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI