Building a waitlist allows your future users to express interest in you, before you've even started your MVP. You can see if your messaging resonates with potential customers, and when you are ready to launch, the users from your waitlist will make excellent early product testers.
In this post, we'll build the following Next.js application:
We will use Next.js for both the frontend and backend thanks to Next.js API routes. API routes are great for this because they are serverless. If we get a sudden burst of users, it will scale up to handle the additional load. We also don't have to pay for any servers when no one is signing up.
Since there's not that much code, we'll walk through and explain all of it.
Creating our Next.js Application
Creating a blank project
Use create-next-app
to set up a new project, and then yarn dev
to run it.
$ npx create-next-app@latest waitlist
$ cd waitlist
$ yarn dev
I like to start with a blank project, so let's replace the existing code in pages/index.js
with this:
import Head from 'next/head'
import styles from '../styles/Home.module.css'
export default function Home() {
return (
<div className={styles.container}>
<Head>
<title>Waitlist</title>
<meta name="description" content="A quick, scalable waitlist"/>
<link rel="icon" href="/favicon.ico"/>
</Head>
</div>
)
}
We can also delete everything in styles/Home.module.css
, we'll replace it shortly. If you go to http://localhost:3000
, you'll see a blank page with Waitlist as the title.
Creating a two column layout
As you saw before, we want a classic two column layout with an image on the right and some marketing text on the left. We'll use a flexbox layout. Add the following to your styles/Home.module.css
.
.container {
background-color: #293747; /* background color */
min-height: 100vh; /* cover at least the whole screen */
height: 100%;
display: flex; /* our flex layout */
flex-wrap: wrap;
}
.column {
flex: 50%; /* each column takes up half the screen */
margin: auto; /* vertically align each column */
padding: 2rem;
}
/* On small screens, we no longer want to have two columns since they
* would be too small. Increasing it to 100% will make the two columns
* stack on top of each other */
@media screen and (max-width: 600px) {
.column {
flex: 100%;
}
}
Back in pages/index.js
, we will add two components for the left and right columns. On the right side, we'll put an image of some code. You can put an image of the product, a mockup, something fun from unsplash, or anything really. For now, the left side will have some placeholder text.
// ...
<Head>
<title>Waitlist</title>
<meta name="description" content="A quick, scalable waitlist"/>
<link rel="icon" href="/favicon.ico"/>
</Head>
// New components
<LeftSide/>
<RightSide/>
</div>
)
}
// These functions can be moved into their own files
function LeftSide() {
return <div className={styles.column}>
Hello from the left side
</div>
}
function RightSide() {
return <div className={styles.column}>
<img width="100%" height="100%" src="/code.svg"/>
</div>
}
The right side looks great! It covers the right half of the screen like we expected. The left side, however, is pretty ugly and unreadable. Let's address that now.
Formatting our marketing text
We know what we want our LeftSide
to say, let's start by updating it so the text matches our image above. For now, we'll also put in placeholder styles which we will add afterwards.
function LeftSide() {
return <div className={styles.column}>
<img width="154" height="27" src="/logo.svg"/>
<h1 className={styles.title}>
Quick Scalable<br/>
<span className={styles.titleKeyword}>Waitlist</span>
</h1>
<div className={styles.subtitle}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore
et dolore magna aliqua.
</div>
</div>
}
If it wasn't for the bad contrast between the black text and the background, this wouldn't look too bad. Now we can add the title
, titleKeyword
, and subtitle
classes (in styles/Home.module.css
) to clean it up.
.title {
font-size: 4rem;
color: white;
}
.titleKeyword {
color: #909aeb;
}
.subtitle {
font-size: 1.2rem;
font-weight: 250;
color: white;
}
Adding the waitlist form
Our frontend is really coming together. The only remaining part is the form where the user can submit their email address. We'll place this in a separate component called Form
and add it to the bottom of our LeftSide
component.
function LeftSide() {
return <div className={styles.column}>
{/* same as before */}
<Form />
</div>
}
function Form() {
const [email, setEmail] = useState("");
const [hasSubmitted, setHasSubmitted] = useState(false);
const [error, setError] = useState(null);
const submit = async (e) => {
// We will submit the form ourselves
e.preventDefault()
// TODO: make a POST request to our backend
}
// If the user successfully submitted their email,
// display a thank you message
if (hasSubmitted) {
return <div className={styles.formWrapper}>
<span className={styles.subtitle}>
Thanks for signing up! We will be in touch soon.
</span>
</div>
}
// Otherwise, display the form
return <form className={styles.formWrapper} onSubmit={submit}>
<input type="email" required placeholder="Email"
className={[styles.formInput, styles.formTextInput].join(" ")}
value={email} onChange={e => setEmail(e.target.value)}/>
<button type="submit" className={[styles.formInput, styles.formSubmitButton].join(" ")}>
Join Waitlist
</button>
{error ? <div className={styles.error}>{error}</div> : null}
</form>
}
A few things to note about the Form
component:
- We use a controlled component for the email input.
- We set up an error at the bottom that is conditionally displayed
- Once
hasSubmitted
is true, we stop displaying the form and instead display a thank you message.
Let's clean it up with css before we finish the submit
method.
.formWrapper {
padding-top: 3rem;
display: flex; /* two column display for input + button */
flex-wrap: wrap;
}
/* Shared by the input and button so they are the same size and style */
.formInput {
padding: 12px 20px;
box-sizing: border-box;
border: none;
border-radius: 5px;
font-size: 1.1rem;
}
.formTextInput {
flex: 70%; /* take up most of the available space */
background-color: #232323;
color: white;
}
.formSubmitButton {
flex: 30%; /* take up the rest of the space */
background-color: #7476ED;
color: white;
}
.error {
color: red;
}
Making a request to a Next.js API route
Our design is finished! Now all we have to do is make sure when you click submit that two things happen:
- The frontend makes a request to our backend with the email address
- The backend saves the email address somewhere
The first one is actually pretty simple. Here's our finished submit
method:
const submit = async (e) => {
e.preventDefault();
let response = await fetch("/api/waitlist", {
method: "POST",
body: JSON.stringify({email: email})
})
if (response.ok) {
setHasSubmitted(true);
} else {
setError(await response.text())
}
}
We use the fetch method to send a post request to /api/waitlist
with a JSON body that includes our user's email. If the request succeeds, we flip hasSubmitted
and the user gets a nice message. Otherwise, the user sees an error returned from our backend.
/api/waitlist
refers to an API route that we have not yet created, which is our only remaining step.
Creating a Next.js API route
Creating an empty route
Our blank application actually started with an API route in /pages/api/hello.js
which looks like this:
export default function handler(req, res) {
res.status(200).json({ name: 'John Doe' })
}
Since this route is in /pages/api/hello.js
, it will be hosted under /api/hello
. We can test this with curl:
$ curl localhost:3000/api/hello
{"name":"John Doe"}
Our frontend is making a request to /api/waitlist
, however, so let's delete hello.js
and make a new file /pages/api/waitlist.js
.
// To make sure only valid emails are sent to us, install email validator:
// $ yarn add email-validator
// $ # or
// $ npm i --save email-validator
import validator from "email-validator"
export default async function handler(req, res) {
// We only want to handle POST requests, everything else gets a 404
if (req.method === 'POST') {
await postHandler(req, res);
} else {
res.status(404).send("");
}
}
async function postHandler(req, res) {
const body = JSON.parse(req.body);
const email = parseAndValidateEmail(body, res);
await saveEmail(email);
res.status(200).send("")
}
async function saveEmail(email) {
// TODO: what to do here?
console.log("Got email: " + email)
}
// Make sure we receive a valid email
function parseAndValidateEmail(body, res) {
if (!body) {
res.status(400).send("Malformed request");
}
const email = body["email"]
if (!email) {
res.status(400).send("Missing email");
} else if (email.length > 300) {
res.status(400).send("Email is too long");
} else if (!validator.validate(email)) {
res.status(400).send("Invalid email");
}
return email
}
Most of the work there is just boilerplate for validating the JSON body and email that we get. But, this is actually all you need to handle the request that the frontend makes.
Go back to your frontend, type in an email, and click Join Waitlist. You should see your success message, and in the logs you should see Got email: {YOUR EMAIL}
.
How to persist waitlist emails
While logging the email is fine, you are probably going to want something more durable. This part is really dependent on your stack.
As an example, if you don't expect a lot of users and are already using Slack, you can use a Webhook integration to send a message to slack every time a user signs up. Here's how to do that using the @slack/webhook library.
const { IncomingWebhook } = require('@slack/webhook');
const url = process.env.SLACK_WEBHOOK_URL;
async function saveEmail(email) {
const webhook = new IncomingWebhook(url);
await webhook.send({
text: 'New waitlist request: ' + email,
});
}
You could also save it to a database. CockroachDB recently announced support for a highly available serverless DB that you can write to with any Postgres library, like pg
:
import { Pool, Client } from 'pg'
const connectionString = process.env.DB_CONNECTION_STRING;
async function saveEmail(email) {
try {
const client = new Client({connectionString})
await client.connect()
const query = 'INSERT INTO waitlist(email) VALUES($1)'
const values = [email]
const res = await client.query(query, values)
await client.end()
} catch (err) {
console.log(err.stack)
res.status(503).send("An unexpected error has occurred, please try again");
}
}
Or you could use services like Airtable which has its own API for saving to a sheet. If you have a CRM, you might want to save entries directly to that instead. There are a lot of options to choose from.
Extra features
This waitlist is pretty easy to extend. You may, for example, want to:
- Collect more information - Just add more fields to the frontend and parse/save them on the backend.
- Persist whether the user has ever signed up - Right now if the user refreshes, they are always set back to the "has not submitted" state. You can address this by saving/reading
hasSubmitted
fromlocalStorage
.
Ultimately, the important thing is you are getting the information you need from your future users, and you are saving it durably.
Next steps/Plug
After building out your waitlist, you will probably begin to build out an MVP of your product. You can dramatically speed up that process by using PropelAuth - a hosted authentication service which provides a complete login and account management experience for both B2C and B2B businesses.
All UIs that your users will need are already built (from login to profile pages to organization management) and configurable via a simple UI. Your users get powerful features like 2FA and it only takes minutes to set up. We hope you'll check it out!