Skip to content

Build a SaaS waitlist landing page with Appwrite_

Learn how to ship a production-ready waitlist page with Vite, React, and Appwrite, including unique email validation and clean error handling.

Every SaaS launch starts the same way. A landing page, a form, and a quiet hope that the right people leave an email. The shape of that form is simple, but the backend around it has to do a handful of unglamorous jobs well: accept writes from the browser, reject duplicate emails, store entries safely, and stay out of your way.

In this tutorial, you will build exactly that: a polished waitlist landing page in React that stores entries in Appwrite, with duplicate emails rejected by a unique index on the email column. The browser talks to Appwrite directly, so there is no server to run and no secret to ship.

Prerequisites

  • An Appwrite Cloud account or a self-hosted Appwrite instance
  • Node.js 18+ installed
  • Basic knowledge of React and TypeScript

Set up the Appwrite project

Start by creating a new project in the Appwrite Console. Head to the Console, click Create project, name it SaaS Waitlist, pick a region, and open it. On the overview page, copy the Project ID and API endpoint. You will need both in a moment.

SaaS Waitlist project overview in the Appwrite Console
SaaS Waitlist project overview in the Appwrite Console

Register a web platform

Before the browser can talk to Appwrite, you need to tell Appwrite which origins are allowed. On the project overview, scroll to Integrations, open the Platforms tab, and click Add platform. Choose Web, give it a name, and add the hostname of your dev server, for example localhost. For production, you will add your deployed domain later.

Create the database

Navigate to Databases and create a new database called Waitlist (ID: waitlist). Inside it, create a table called Entries (ID: entries) with the following columns:

ColumnTypeRequiredNotes
namevarcharYesSize 255, the person's name
emailemailYesValidated by Appwrite on write

varchar is the right pick for a short, bounded string like a name. The email type validates the format on write, so you do not need to hand-roll a regex on the server. Both columns are required so that the frontend cannot accidentally submit a half-filled row.

Entries table columns in the Appwrite Console
Entries table columns in the Appwrite Console

Prevent duplicate emails with a unique index

A waitlist with three copies of the same email is a broken waitlist. Switch to the Indexes tab, click Create index, and add one with:

FieldValue
Index keyunique_email
TypeUnique
Attributesemail

When the index is in place, the second call to insert an existing email fails with a 409. You will surface that as a friendly "you're already on the list" message in the UI instead of a generic error.

Configure permissions

Open the table's Settings tab and configure permissions there:

  • Scroll to Permissions and click Add role.
  • Pick Any.
  • Check only Create. Leave Read, Update, and Delete unchecked.
  • Keep Row security off.

Permissions panel with Any role granted Create only
Permissions panel with Any role granted Create only

This is the minimum viable permission set for a public waitlist:

  • Anonymous visitors can submit their email, because they have Create.
  • Nobody can read the list back from the browser. Your frontend never needs to.
  • Nobody can edit or delete existing rows from the browser.

When you want to view or export signups, you do it from the Appwrite Console or with an API key on a server you control. The browser only ever writes.

Build the app

Scaffold a new Vite project and install the Appwrite SDK:

Bash
pnpm create vite@latest saas-waitlist -- --template react-ts
cd saas-waitlist
pnpm install
pnpm add appwrite

Feel free to add your preferred styling setup on top, whether that's Tailwind, CSS Modules, or plain stylesheets. We'll focus on the data flow from here on.

Environment variables

Create a .env file in the project root with your Appwrite credentials:

Bash
VITE_APPWRITE_ENDPOINT=https://<REGION>.cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=<YOUR_PROJECT_ID>
VITE_APPWRITE_DATABASE_ID=waitlist
VITE_APPWRITE_TABLE_ID=entries

Replace <YOUR_PROJECT_ID> with your project ID from the Console, and update the endpoint to match your project's region. Vite only exposes variables prefixed with VITE_ to the browser, which is exactly what you want here. These four values are safe to ship to the client; the project ID is a public identifier, not a secret.

The Appwrite helper

Everything Appwrite-related lives in one file. The component never imports the SDK directly, which means you can refactor the backend later without touching your UI. Create src/lib/appwrite.ts:

TypeScript
import { Client, TablesDB, ID, AppwriteException } from "appwrite";
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT;
const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID;
const databaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID;
const tableId = import.meta.env.VITE_APPWRITE_TABLE_ID;
if (!endpoint || !projectId || !databaseId || !tableId) {
throw new Error("Missing Appwrite environment variables.");
}
export const client = new Client().setEndpoint(endpoint).setProject(projectId);
export const tablesDB = new TablesDB(client);
export type WaitlistInput = { name: string; email: string };
export type WaitlistResult =
| { ok: true }
| { ok: false; reason: "duplicate"; message: string }
| { ok: false; reason: "unknown"; message: string };
export async function createWaitlistEntry({
name,
email,
}: WaitlistInput): Promise<WaitlistResult> {
try {
await tablesDB.createRow({
databaseId,
tableId,
rowId: ID.unique(),
data: { name, email },
});
return { ok: true };
} catch (error) {
if (error instanceof AppwriteException) {
if (error.code === 409) {
return {
ok: false,
reason: "duplicate",
message: "You're already on the list. We'll be in touch.",
};
}
return {
ok: false,
reason: "unknown",
message: error.message,
};
}
return {
ok: false,
reason: "unknown",
message: "Network error. Check your connection and try again.",
};
}
}

The helper returns one of three outcomes the UI can react to: a successful create, a duplicate rejection when the unique index on email rejects a repeat, or an unknown failure for anything else.

The landing page

Open src/App.tsx and replace it with the form wiring. The important part is not the styling, which is up to you, but how little the component knows about Appwrite. It calls one helper and branches on the result:

TypeScript
import { useState, type FormEvent } from "react";
import { createWaitlistEntry } from "./lib/appwrite";
type FormStatus =
| { kind: "idle" }
| { kind: "submitting" }
| { kind: "error"; message: string }
| { kind: "success"; tone: "new" | "duplicate"; message: string };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validate(name: string, email: string): string | null {
if (name.trim().length === 0) return "Please tell us your name.";
if (name.trim().length > 120) return "That name is a little too long.";
if (!EMAIL_RE.test(email.trim())) return "That email doesn't look right.";
return null;
}
export default function App() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [status, setStatus] = useState<FormStatus>({ kind: "idle" });
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (status.kind === "submitting") return;
const validationError = validate(name, email);
if (validationError) {
setStatus({ kind: "error", message: validationError });
return;
}
setStatus({ kind: "submitting" });
const result = await createWaitlistEntry({
name: name.trim(),
email: email.trim().toLowerCase(),
});
if (result.ok) {
setStatus({
kind: "success",
tone: "new",
message: "You're in. We'll email you when your seat is ready.",
});
return;
}
if (result.reason === "duplicate") {
setStatus({
kind: "success",
tone: "duplicate",
message: result.message,
});
return;
}
setStatus({ kind: "error", message: result.message });
}
// render form, submitting state, success panel, or error line
// based on `status`
}

A few small conventions make this code easier to live with:

  • Client-side validation first. The form is validated before any request goes out. This does not replace the server check; it just avoids obvious round trips. The Appwrite email column and the unique index remain your source of truth.
  • Treat duplicates as success, not error. From the visitor's point of view they are on the list, so a red error banner is the wrong signal. The helper marks the outcome as success with a duplicate tone, and the UI shows a calm "already on the list" confirmation.

Run the demo

Start the dev server:

Bash
pnpm dev

The landing page renders immediately.

The Stratum waitlist landing page
The Stratum waitlist landing page

Fill in a name and email, submit, and the form is replaced with a confirmation. Behind the scenes, the helper fired a single createRow request to the Appwrite endpoint, which returned a row object with $id, $createdAt, and the values you sent.

Confirmation state after a successful submission
Confirmation state after a successful submission

Open the Entries table in the Appwrite Console and the row is there, timestamped and ready.

The new waitlist entry in the Appwrite Console
The new waitlist entry in the Appwrite Console

Submit the same email again and the helper catches the 409 from the unique index, the UI shifts to the "already on file" confirmation, and no second row is created. Submit a malformed email and the client validator blocks the request before it ever leaves the browser.

Where to go from here

You now have a functioning waitlist with proper deduplication, scoped permissions, and a clean separation between your UI and your backend. There are a few natural next steps depending on where you want to take it.

  • Send a welcome email. Create an Appwrite Function that listens for the tablesdb.*.tables.*.rows.*.create event on your entries table and sends a transactional email through your provider of choice. The user never has to wait for it on the form.
  • Export the list. Use the CSV export feature from the Console when you are ready to invite people, or hit the REST API with a server-side API key for scheduled jobs.
  • Add referral tracking. Add a source enum column on the table and capture utm_source from the URL before calling the helper. The helper signature only needs one more field.
  • Show a live counter. With row security off and a read permission for Any, you can expose a count of entries on the page. If you want exact privacy, keep reads server-only and surface a daily-updated number from a Function instead.

Next steps

Read next

Ready to build?_