A build log for future-me—and for the next person who does not want to discover every missing step through a series of 500 errors.
It looked like a 10-minute setup
I am working on a product that I am not ready to talk about publicly yet.
Before launching it, I wanted somewhere to send interested people: one page that explains enough about the product, collects their email addresses, and gives me a list of people to invite when it is ready.
In other words, I needed a waitlist.
The plan sounded simple:
- Add a headline.
- Add an email form.
- Save the signups somewhere.
- Send a confirmation email.
- Deploy.
Then I found Waitly, an open-source waitlist template with a good README, and thought I had saved myself a lot of work.
I had saved myself some work.
I had not saved myself from debugging.
The template itself is solid, but several parts of the setup either assume prior knowledge or only become clear after something breaks. This post covers what I set up, what went wrong, and what I wish I had known before starting.
How I found Waitly
My first instinct was to build the waitlist from scratch. I write code, so why not?
But while researching waitlist options through Google’s AI Mode, the search wandered into a place I hadn't expected and surfaced Waitly. Once I opened it, I realized it already had most of what I needed.

Waitly is a Next.js template that connects three services:
- Notion to store signups
- Upstash Redis for rate limiting
- Resend for confirmation emails
The repository is:
https://github.com/revokslab/waitly
Instead of rebuilding the whole signup flow, I decided to customize the template around my product.
That was the easy decision.
The setup was where things became interesting.
Step 1: Fork and keep it separate
I kept the waitlist in its own folder beside my main application instead of nesting it inside the app:
projects/
├── main-product/
└── waitlist/
This kept the Git history, dependencies, and deployment settings separate.
I also forked the repository to my GitHub account before cloning it:
git clone https://github.com/YOUR_USERNAME/waitly.git
cd waitly
pnpm install
A fork made sense for me because I wanted to deploy from my own repository while keeping a link to the original project.
If you want a completely independent repository, GitHub’s Use this template option may suit you better.
Step 2: Turn the template into my product
Once the project was running locally, I replaced the template content with my own branding.
I changed:
- the headline and supporting copy;
- the early-access incentive;
- the FAQ;
- the footer and wordmark;
- the confirmation email;
- the feature section; and
- the primary, accent, and background colors.
I also replaced the template’s logo strip with feature cards because that made more sense for the product I am building.
One small lesson: do not assume every brand color is controlled by one CSS variable. The original accent color appeared in several places, so I searched the whole codebase for the old value and replaced the remaining hardcoded instances.
Three template issues I found
I also found a few things worth checking before assuming your own changes caused the problem.
1. The countdown date was already in the past
The hero countdown used an old hardcoded target, so it displayed a dead timer. I replaced it with my actual launch target.
2. dynamic was misspelled
One page configuration used:
dyamic
instead of:
dynamic
Because the option was misspelled, Next.js ignored it. That could affect whether changing data, such as the signup count, appears as expected.
3. A component had stray characters in a class name
They did not completely break the interface, but they were worth cleaning.
None of these were major issues, but they taught me not to assume every strange behavior was due to my customization.
Step 3: Set up the three services
The interface can load perfectly even when none of the backend integrations work.
To make the full signup flow functional, I needed credentials for Notion, Upstash, and Resend.
Notion: the database is more specific than it looks
At first, I assumed I only needed a name and email field.
That assumption caused several of my errors.
Waitly includes a referral system, so the code expects five Notion properties:
| Property | Type |
|---|---|
Name |
Title |
Email |
|
Referral Code |
Text |
Referred By |
Text |
Referrer |
Relation to the same database |
The names must match what the code expects, including spacing and capitalization.
After creating the database, I:
- Created a Notion integration (https://app.notion.com/developers/connections).
- Copied its internal integration token.
- Connected the integration to the database.
- Copied the database ID from the URL.
The connection step is easy to miss. Creating an integration does not automatically give it access to your database.
The Notion columns trap
My first signup failed with a Notion validation_error.
The code was trying to write properties that did not exist:
Referral CodeReferred ByReferrer
I fixed them one at a time.
First, the terminal listed three missing properties. Then two. Then one.
Finally, I got a different error because I had created Referrer as a text field instead of a relation. I changed it to a Relation pointing back to the same database.
It was one of those debugging sessions where every new error was annoying, but also proof that I was getting closer.
Set up all five fields correctly at the beginning and save yourself that journey.
Upstash: simple until I needed a second database
For Upstash, I needed two values:
- the REST URL;
- the REST token.
Waitly uses Redis for rate limiting so that one person cannot repeatedly spam the signup form.
The setup was straightforward, but I ran into a limitation because I already had a Redis database on my free Upstash account.

When I tried to create another one, Upstash required me to add a payment method, such as a Visa card, Mastercard, or PayPal account, and set a spending limit of at least $20.
At first, it looked like I had to pay $20 just to create the database.
That was not the case.
The $20 was a spending limit, not an upfront deduction. I would only be charged if I used services or resources that exceeded the free allowance.
My options were to:
- add billing details and create a separate database;
- reuse my existing database with clearly separated key prefixes; or
- use another suitable account or workspace.
I preferred keeping the waitlist database separate from my other project.
Once it was available, I added the REST URL and token to .env.local, and that part worked without much drama.
Compared with Notion and Resend, it was refreshingly uneventful.
Resend: testing and real email are different stages
I created a Resend API key and initially used:
onboarding@resend.dev
This was useful for testing, but in my setup it could only deliver to the email address connected to my Resend account.
That meant I could confirm that the route worked, but I could not use it for public signups.
To send confirmation emails to real users, I needed a verified domain.
That requirement turned my waitlist task into a domain, DNS, Vercel, and email-deliverability task too.
Step 4: Add the environment variables
I placed the local credentials in .env.local at the project root:
NOTION_SECRET=...
NOTION_DB_ID=...
UPSTASH_REDIS_REST_URL=...
UPSTASH_REDIS_REST_TOKEN=...
RESEND_API_KEY=...
RESEND_FROM_EMAIL=...
Then I started the project:
pnpm dev
I submitted a test signup and checked three things separately:
- Did the form return a success response?
- Did a row appear in Notion?
- Did the confirmation email arrive?
Testing them separately helped me work out whether the problem was in the interface, Notion, or Resend.
Watch the Notion environment-variable name
In the version I used, the README referred to:
NOTION_DB
but the code expected:
NOTION_DB_ID
An environment variable can exist perfectly and still do nothing if its name does not match what the application reads.
Check the source code, not only the README.
Vercel needs its own environment variables
Vercel does not read the .env.local file on your computer.
I had to add the same variables under:
Vercel → Project Settings → Environment Variables
I also had to redeploy after changing them.
The biggest Resend gotcha: I needed a domain
A .vercel.app address can host the waitlist, but it cannot act as an email-sending domain.
My process became:
- Test with Resend’s development sender.
- Buy a domain.
- Connect the website hostname to Vercel.
- Configure a sending subdomain in Resend.
- Update
RESEND_FROM_EMAIL. - Redeploy and test again.
The Vercel and Resend setups are technically separate, but doing the website side first helped me organise the DNS records without mixing everything together.
Why I used a sending subdomain
Instead of sending directly from the root domain, I used a subdomain such as:
send.yourdomain.com
This keeps automated product emails separate from future business email.
For example:
hello@yourdomain.com
could be used for ordinary communication, while:
updates@send.yourdomain.com
handles automated waitlist confirmations.
This separation can make DNS management cleaner and help isolate the reputation of transactional email from regular business correspondence.
Connecting the waitlist domain to Vercel
One domain can support several services through subdomains:
yourdomain.com → main website
join.yourdomain.com → waitlist
send.yourdomain.com → automated email
I did not need to buy a separate domain for the waitlist.
In my registrar’s DNS dashboard, I removed the default parking records that conflicted with the hostnames I wanted to use. I then added the records Vercel requested.
A typical setup may look like this:
I also added the waitlist hostname to the correct Vercel project under:
Settings → Domains
Both sides have to match:
- The registrar points the hostname to Vercel;
- Vercel connects that hostname to the correct project.
I followed the exact values shown in my Vercel dashboard rather than assuming every setup would use the same records.
One more domain lesson: check the renewal price before buying. The very low price you see is often only for the first year.
The runtime errors I actually hit
A clean build did not mean the waitlist worked.
Most of the real problems appeared only after I submitted the form.
Unable to acquire lock at .next/dev/lock
I also saw a message saying port 3000 was already in use.
A previous Next.js development process was still running and holding the port and development lock.
I stopped the stray Node process and started the server again.
On Windows, check for old terminal sessions or background Node processes before deleting build folders or changing configuration.
POST /api/mail 500
The browser only told me that the mail request failed.
That was not enough information to fix it.
I added server-side error logging and checked the terminal. Resend was rejecting the recipient because I was using its test sender with an email address that was not authorized for that testing mode.
For testing, I used the email connected to my Resend account.
For public signups, I moved to a verified domain.
Once the sender and recipient setup matched Resend’s requirements, the route returned 200.
POST /api/notion 500
This came from the database property mismatch.
The application was sending referral fields that my Notion database did not contain.
The fix was not in the interface or the API route. It was in the Notion schema.
After I added the missing properties and changed Referrer to a relation, the signup finally appeared.
The debugging lesson I am keeping
When the browser says:
500 Internal Server Error
it is only telling you that the server failed.
It is not telling you why.
The frontend showed me that something had broken.
The terminal told me what to fix.
What I would do differently next time
I would still use Waitly. I would just set it up in a better order:
Was it worth it?
Yes.
It took longer than the “quick waitlist” I thought I was setting up, but more importantly, I now understand how the pieces connect.
And now, when future-me forgets why Referrer is a relation instead of a text field, this post will be here.






Top comments (0)