Building a waitlist for your product with Next.js
Tags: javascript, nextjs, webdev, tutorial
Published: 2021-12-14 02:29:31

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:

![Final product](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v33g7lhgz5altakkouon.png)

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.

```shell
$ 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:

```jsx
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`.

```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.

```jsx
// ...
            <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>
}
```

![Two column layout](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dxyn9pd9ly1uvkxnzet2.png)

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.

```jsx
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>
}
```

![No css formatting yet](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ra6aonxtncpyssh1rwko.png)

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.

```css
.title {
    font-size: 4rem;
    color: white;
}
.titleKeyword {
    color: #909aeb;
}
.subtitle {
    font-size: 1.2rem;
    font-weight: 250;
    color: white;
}
```

![Formatted, but no form yet](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mkretx5mwb7aqydxw2ts.png)

### 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.

```jsx
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](https://reactjs.org/docs/forms.html#controlled-components) 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.

```css
.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;
}
```

![Final product](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v33g7lhgz5altakkouon.png)

### 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:

1. The frontend makes a request to our backend with the email address
2. The backend saves the email address somewhere

The first one is actually pretty simple. Here's our finished `submit` method:

```js
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:

```js
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:

```shell
$ 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`.

```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](https://api.slack.com/messaging/webhooks) to send a message to slack every time a user signs up. Here's how to do that using the [@slack/webhook](https://www.npmjs.com/package/@slack/webhook) library.

```js
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](https://www.cockroachlabs.com/lp/serverless/) recently announced support for a highly available serverless DB that you can write to with any Postgres library, like `pg`:

```js
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](https://www.airtable.com/) 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` from `localStorage`.

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](https://www.propelauth.com) - 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!

## Attributions

- The image of code was generated from [Carbon](https://carbon.now.sh)
- The placeholder logo is from [Logoipsum](https://logoipsum.com/)