Simple, tinkerer-friendly, mostly-drop-in waitlist widget built to work on static sites: Jekyll, plain HTML, React, Astro, Hugo, or anything that can POST JSON.
New to this project? Check out the Quick Start Guide to get up and running in minutes.
Have questions? See the FAQ for common questions about DNS records, troubleshooting, customization, and more.
Copy these 9 files from this repository to your Jekyll site:
| Source File | Destination in Jekyll Site |
|---|---|
the-widget/jekyll/_includes/waitlist-form.html |
_includes/waitlist-form.html |
the-widget/assets/waitlist-form.css |
assets/waitlist-form.css |
the-widget/assets/waitlist-form.js |
assets/waitlist-form.js |
the-widget/assets/waitlist-pages.css |
assets/waitlist-pages.css |
the-widget/assets/waitlist-style.js |
assets/waitlist-style.js |
the-widget/jekyll/waitlist-confirmed.html |
Root folder (same level as _config.yml) |
the-widget/jekyll/waitlist-error.html |
Root folder (same level as _config.yml) |
the-widget/jekyll/unsubscribe-success.html |
Root folder (same level as _config.yml) |
the-widget/jekyll/unsubscribe-error.html |
Root folder (same level as _config.yml) |
Quick copy commands (replace PATH_TO_REPO with your repository path):
cp PATH_TO_REPO/the-widget/jekyll/_includes/waitlist-form.html _includes/
cp PATH_TO_REPO/the-widget/assets/waitlist-form.css assets/
cp PATH_TO_REPO/the-widget/assets/waitlist-form.js assets/
cp PATH_TO_REPO/the-widget/assets/waitlist-pages.css assets/
cp PATH_TO_REPO/the-widget/assets/waitlist-style.js assets/
cp PATH_TO_REPO/the-widget/jekyll/waitlist-confirmed.html .
cp PATH_TO_REPO/the-widget/jekyll/waitlist-error.html .
cp PATH_TO_REPO/the-widget/jekyll/unsubscribe-success.html .
cp PATH_TO_REPO/the-widget/jekyll/unsubscribe-error.html .Then:
-
Add the form to any page:
{% include waitlist-form.html api_url="https://your-api.vercel.app/api/subscribe" %} -
(Optional) Add meta tags to your layout to match form style and color with email settings:
<meta name="waitlist-style" content="minimal"> <meta name="waitlist-color" content="#4f46e5">
waitlist-style: Useminimal,professional, orbrandedto match yourEMAIL_TEMPLATE_STYLEsettingwaitlist-color: Use yourEMAIL_PRIMARY_COLORvalue (e.g.,#4f46e5)
If omitted, pages will automatically use the style and color from API redirects, and the form defaults to
minimalstyle with purple color. -
The CSS/JS files are already loaded in the included files and pages - no additional setup needed!
While this widget is customizable for use with probably whatever set up you've got, it was created with the following stack in mind:
| Name | Layer | Is this layer required? |
|---|---|---|
| Jekyll | website/blog framework / you have a website, yeah? | yes |
| Supabase | database / where subscriber info gets stored | yes |
| Resend | email API / what does the sending | yes |
| Upstash | database / used for rate limiting | no |
| Cloudflare Turnstile | CAPTCHA / anti-spam | no |
Could you use just Supabase for both subscriber info and rate limiting? Technically yes, but Upstash Redis is better for the type and speed of data transfer required for this feature. Anyway, it's also got a very generous free tier. 😉
| Upstash Redis | Supabase | |
|---|---|---|
| Speed | ~1-5ms (in-memory) | ~50-100ms (database query) |
| Atomicity | Built-in atomic INCR | Requires transactions/locks |
| TTL | Automatic key expiration | Need cleanup job or triggers |
| Complexity | @upstash/ratelimit does everything | Custom implementation needed |
| Free tier? | yes | yes |
This is a drop-in plugin with a separate API backend:
- The API (
the-widgetfrom this repository) → Deploy to Vercel- This is a one-time setup and shouldn't take more than 30min to an hour, including time spent signing up for (the free tier of) required services that you don't have yet, template customization, and testing.
- Your website (Jekyll, React, etc.) → Stays wherever it's already hosted (Could be a separate project instance on Vercel, GitHub Pages, Netlify, etc.)
- The form → You copy some files (CSS/JS/HTML) to your existing website
- They communicate → Your form sends requests to the API you just deployed
- ✅ Email validation and duplicate prevention
- ✅ Unified contact system with deduplication and merging
- ✅ Resend Contacts sync with two-way sync (optional - sync confirmed users to Resend Audience)
- ✅ 3 email template styles (minimal/default, professional, branded)
- ✅ Logo support (optional logos for Professional and Branded templates; branded includes placeholder SVG by default)
- ✅ Full customization (colors, content, subjects, preheaders, logos)
- ✅ Email preview system for testing templates
- ✅ SPAM compliance (CAN-SPAM, GDPR: unsubscribe links, physical address, optional advertisement disclosure)
- ✅ GDPR compliance (privacy policy links in emails and form, consent checkbox) - Note: Data deletion/export endpoints not yet implemented
- ✅ Welcome emails via Resend (required - see note below)
- ✅ Rate limiting via Upstash Redis (optional)
- ✅ CAPTCHA via Cloudflare Turnstile (optional)
- ✅ Double opt-in email confirmation (optional, on by default)
- ✅ Supabase storage with contact activity tracking
- ✅ CORS configuration
- ✅ Dark mode support
api/subscribe.js and api/confirm.js) is hardcoded to use Resend. If you want to use a different email service (SendGrid, Mailgun, AWS SES, etc.), you will need to modify the API code yourself. The database schema is email-service agnostic and will work with any provider.
┌─────────────────┐ ┌──────────────────────────────────────┐
│ Frontend │ │ Vercel Functions │
│ (Any Framework)│ │ │
│ │ │ /api/subscribe │
│ waitlist-form │────▶│ ├─ Rate limit check (Upstash) │
│ │ │ ├─ CAPTCHA verify (Turnstile) │
└─────────────────┘ │ ├─ Contact deduplication │
│ ├─ Store signup (Supabase) │
│ └─ Send confirmation (Resend) │
│ │
│ /api/confirm │
│ ├─ Validate token │
│ ├─ Mark confirmed (Supabase) │
│ ├─ Sync to Resend Contacts ←───────── Optional
│ └─ Send welcome email (Resend) │
│ │
│ /api/unsubscribe │
│ ├─ Validate token/email │
│ ├─ Update unsubscribe status │
│ ├─ Sync to Resend Contacts ←───────── Optional
│ └─ Log activity │
│ │
│ /api/webhooks/resend ←───────────────── Optional
│ └─ Receive Resend events │
│ (bounces, unsubscribes) │
└──────────────────────────────────────┘
│
┌──────────────────┴───────────────────┐
│ │
┌─────────▼─────────┐ ┌─────────────▼─────────┐
│ Database Layer │ │ Resend Contacts │
│ (Supabase) │◄────────────►│ (Optional) │
│ │ Two-way │ │
│ waitlist │ sync │ Audience for │
│ └─contact_id──┼──contacts │ marketing emails │
│ contact_activity │ │ │
└───────────────────┘ └───────────────────────┘
Database relationships:
waitlist.contact_id→contacts.id- Links each signup to a unified contact- One contact can have multiple waitlist signups from different sources
contacts.user_id→auth.users.id- Optional link to Supabase Auth users
The API (api/ folder) is completely framework-agnostic: any frontend that can POST JSON will work. The jekyll/ folder contains ready-to-use components for Jekyll, but they're easy to adapt.
| Component | Framework-specific? | To adapt |
|---|---|---|
api/* |
No: works with anything | Just deploy to Vercel |
api/shared/* |
No: shared utilities | Modify if customizing email service |
the-widget/supabase/setup.sql |
No: just SQL | Run in Supabase SQL Editor |
assets/waitlist-form.css |
No: pure CSS | Use as-is or customize |
assets/waitlist-form.js |
No: vanilla JS | Use as-is or customize |
jekyll/_includes/waitlist-form.html |
Light Jekyll (Liquid tags) | See below |
jekyll/waitlist-*.html |
Light Jekyll (front matter) | See below |
The API is organized into shared modules for easier customization:
api/subscribe.js- Main subscription endpointapi/confirm.js- Email confirmation endpointapi/unsubscribe.js- Unsubscribe endpointapi/webhooks/resend.js- Resend webhook handler (for two-way sync)api/shared/config.js- Centralized configuration (CORS, feature flags, etc.)api/shared/email-service.js- Email sending abstraction (modify here to use different email service)api/shared/resend-contacts.js- Resend Contacts API integration (for syncing to Audience)api/shared/contacts.js- Contact management utilitiesapi/shared/database.js- Database client initializationapi/shared/utils.js- Shared utility functionsscripts/sync-contacts-to-resend.js- Bulk sync script for existing contacts
Plain HTML / Vanilla JS:
Copy the HTML from waitlist-form.html, and include the CSS/JS files:
- Copy
assets/waitlist-form.cssto your site - Copy
assets/waitlist-form.jsto your site - Add data attributes to the form container:
data-api-url,data-source,data-turnstile-site-key - Link the CSS and JS files in your HTML
React / Next.js:
Import assets/waitlist-form.css and adapt assets/waitlist-form.js to React hooks. The JavaScript logic maps directly to React patterns with useState for loading/message states.
Astro:
Nearly drop-in. Astro supports similar component includes. Swap Liquid syntax for Astro's Astro.props.
Hugo:
Move to layouts/partials/, replace Liquid ({{ include.x }}) with Go templating ({{ .Get "x" }}).
Webflow / Squarespace / No-code:
Upload assets/waitlist-form.css and assets/waitlist-form.js to your site's assets folder, then embed the HTML from waitlist-form.html in a custom code block. Set data attributes: data-api-url, data-source, data-turnstile-site-key.
📖 For step-by-step setup instructions, see the Quick Start Guide.
Quick overview:
- Supabase - Create project, run
the-widget/supabase/setup.sql, get API credentials - Resend - Create account, verify domain, create API key
- Vercel - Deploy
the-widget/folder, import.env.localwith your credentials - Your Site - Copy form files and add to your website
What you'll need:
- Supabase account (free tier)
- Resend account (free tier: 3,000 emails/month)
- Vercel account (free tier)
- Node.js installed
Optional (add later):
- Upstash for rate limiting (free tier: 10k requests/day)
- Cloudflare Turnstile for CAPTCHA (always free)
The provided API code (api/subscribe.js and api/confirm.js) requires Resend and is hardcoded to use it. If you want to use a different email service, you'll need to modify the API code. The database schema is email-service agnostic and will work with any provider.
What gets created in Supabase:
waitlisttable - Stores signup entries (linked to contacts viacontact_id)contactstable - Unified contact management (single source of truth for email identity)- Email status fields:
email_verified,email_bounced,email_unsubscribed - Lifecycle tracking:
first_seen_at,last_contacted_at - Optional
user_idcolumn for Supabase Auth integration (links toauth.userswhen contact creates account) - NULL for waitlist signups that never create accounts - this is expected and safe
- Email status fields:
contact_activitytable - Timeline tracking for all contact interactions (optional, for CRM features)waitlist_statsview - Statistics including unique contacts count- Helper functions:
get_or_create_contact(), migration functions, merge/deduplication functions, unsubscribe token generation - Indexes for performance (including
email_normalizedfor fast lookups)
Optional but recommended if you want to send marketing emails to confirmed subscribers.
What this does: Syncs confirmed waitlist subscribers to a Resend Audience, enabling two-way sync of subscription preferences (bounces, unsubscribes).
Sync is automatic once configured. A manual migration script is available to sync existing contacts from Supabase to Resend.
| Direction | Trigger | Automatic? |
|---|---|---|
| Supabase → Resend | User confirms email | ✅ Yes |
| Supabase → Resend | User unsubscribes via your app | ✅ Yes |
| Resend → Supabase | Bounce/complaint/unsubscribe | ✅ Yes (webhook) |
| Existing contacts | One-time migration | Manual script |
Quick setup:
- Create a Resend Audience at resend.com/audiences
- Add
RESEND_AUDIENCE_IDto Vercel environment variables (via dashboard) - Set up webhooks at resend.com/webhooks pointing to
https://your-api.vercel.app/api/webhooks/resend - Add
RESEND_WEBHOOK_SECRETto Vercel environment variables - Redeploy
For detailed setup instructions, see docs/QUICKSTART.md.
Bulk sync existing contacts:
# Preview what would be synced
node scripts/sync-contacts-to-resend.js --dry-run
# Sync all confirmed contacts
node scripts/sync-contacts-to-resend.jsWhat this does: Deploys the the-widget/ folder to Vercel as a serverless API. This is separate from your website - your website stays wherever it's hosted.
Quick steps:
- Navigate to
the-widgetfolder:cd the-widget - Install dependencies:
npm install - Create environment file:
cp env.example .env.local - Fill in REQUIRED values in
.env.local:SUPABASE_URL,SUPABASE_SERVICE_KEY(from Step 1)RESEND_API_KEY,FROM_EMAIL(from Step 2)BASE_URL(your website URL)CORS_ALLOWED_ORIGINS(your domains)
- Deploy via Vercel dashboard (import
.env.local) or CLI:vercel --prod - Set
BASE_URLto Production environment only in Vercel dashboard
📖 For detailed step-by-step instructions with ELI5 explanations, see docs/QUICKSTART.md.
Rate Limiting (Upstash): Prevents spam by limiting signups per IP address. Default: 5 signups per IP per hour.
CAPTCHA (Cloudflare Turnstile): Adds bot protection to your form.
📖 For setup instructions, see docs/QUICKSTART.md and docs/QUICKSTART.md.
The jekyll/ folder contains ready-to-use components. Adapt as needed for your framework.
For Jekyll:
📖 For detailed step-by-step instructions, see docs/QUICKSTART.md - it includes ELI5-level explanations for each step.
Quick summary:
-
Copy 9 files to your Jekyll site:
jekyll/_includes/waitlist-form.html→_includes/(the form component)assets/waitlist-form.css→assets/(form styling)assets/waitlist-form.js→assets/(form functionality)assets/waitlist-pages.css→assets/(confirmation page styling)assets/waitlist-style.js→assets/(automatic style matching)jekyll/waitlist-confirmed.html→ your site root (success page)jekyll/waitlist-error.html→ your site root (error page)jekyll/unsubscribe-success.html→ your site root (unsubscribe success page)jekyll/unsubscribe-error.html→ your site root (unsubscribe error page)
-
Add the form to any page:
{% include waitlist-form.html api_url="https://your-waitlist-api.vercel.app/api/subscribe" %}Replace
your-waitlist-api.vercel.appwith your actual Vercel API URL. -
(Optional) Match form style and color with email settings - Add to your layout file (
_layouts/default.html):<meta name="waitlist-style" content="minimal"> <meta name="waitlist-color" content="#4f46e5">
waitlist-style: Useminimal,professional, orbrandedto match yourEMAIL_TEMPLATE_STYLEwaitlist-color: Use yourEMAIL_PRIMARY_COLORvalue (e.g.,#4f46e5)
If omitted, pages automatically use the style and color from API redirects, and the form defaults to
minimalstyle with purple color.
Note: CSS/JS files are automatically loaded by the included files and pages - no manual setup needed!
Advanced options:
With source tracking:
{% include waitlist-form.html api_url="https://your-api.vercel.app/api/subscribe" source="homepage" %}With CAPTCHA:
{% include waitlist-form.html api_url="https://your-api.vercel.app/api/subscribe" turnstile_site_key="0x4AAAAAAA..." %}With privacy policy (enables consent checkbox for GDPR compliance):
{% include waitlist-form.html api_url="https://your-api.vercel.app/api/subscribe" privacy_policy_url="https://yourdomain.com/privacy" %}All options:
{% include waitlist-form.html api_url="https://your-api.vercel.app/api/subscribe" source="landing" turnstile_site_key="0x4AAAAAAA..." privacy_policy_url="https://yourdomain.com/privacy" %}For other frameworks:
See the "Framework Compatibility" section above for adaptation instructions.
The jekyll/ folder exists because Jekyll is the lowest common denominator for static sites:
- No build step required: The components are just HTML, CSS, and vanilla JS. There's no JSX to transpile, no bundler to configure.
- Easy to copy-paste: If you can read the Jekyll version, you can adapt it to anything. React developers know how to convert vanilla JS to hooks; the reverse is harder.
- Static site = needs external API: Jekyll can't run server-side code, which is exactly why this Vercel-based architecture exists. If you're using Next.js, you might just put the API route in your own app.
- Common for landing pages: Many indie projects use Jekyll (or Hugo, or plain HTML) for marketing sites while building the actual product in something else.
The Jekyll-specific bits are minimal: just Liquid's {{ include.param }} syntax and the YAML front matter on the confirmation pages. The CSS and JavaScript in assets/ are framework-agnostic and can be used with any static site generator or framework.
When Upstash is configured, each IP address is limited to 5 signups per hour. This prevents:
- Spam attacks
- Email list bombing
- Resource exhaustion
The limit can be adjusted in api/subscribe.js:
limiter: Ratelimit.slidingWindow(5, '1 h'), // Change as neededWhen enabled, users must complete a CAPTCHA challenge before submitting. Turnstile is:
- Privacy-friendly (no tracking)
- Usually invisible (smart challenge)
- Free for any volume
Enabled by default. The flow is:
- User submits email → receives confirmation email
- User clicks confirmation link → marked as confirmed, receives welcome email
Benefits:
- Prevents fake signups
- Confirms email ownership
- Reduces spam complaints
- Required for GDPR compliance in some cases
To disable, set DOUBLE_OPTIN=false in your environment.
Request:
{
"email": "user@example.com",
"source": "homepage",
"turnstileToken": "..." // Only if CAPTCHA enabled
}Responses:
| Status | Body | Meaning |
|---|---|---|
| 200 | { success: true, requiresConfirmation: true } |
Signup saved, confirmation email sent |
| 200 | { success: true } |
Signup confirmed (if double opt-in disabled) |
| 409 | { error: "already_subscribed" } |
Email already on waitlist |
| 429 | { error: "rate_limited" } |
Too many requests from this IP |
| 400 | { error: "captcha_required" } |
CAPTCHA token missing |
| 400 | { error: "captcha_failed" } |
CAPTCHA verification failed |
Handles email confirmation clicks. Redirects to:
/waitlist-confirmedon success/waitlist-error?error=<code>on failure
Handles unsubscribe requests. Accepts either token (secure) or email (fallback). Redirects to:
/unsubscribe-successon success/unsubscribe-error?reason=<code>on failure
Optional - only if Resend Contacts sync is configured.
Receives webhook events from Resend for two-way sync:
| Event | Action |
|---|---|
email.bounced |
Sets email_bounced = true in contacts |
email.complained |
Sets email_unsubscribed = true |
contact.unsubscribed |
Sets email_unsubscribed = true |
email.delivered/opened/clicked |
Logged to contact_activity |
Response: 200 OK with { received: true }
The database includes a unified contacts system that links waitlist signups to contacts and optionally to Supabase Auth users.
Main tables:
-- Waitlist signups (linked to contacts via contact_id)
create table waitlist (
id uuid primary key default gen_random_uuid(),
email text unique not null,
source text default 'website',
created_at timestamptz default now(),
confirmed boolean default false,
confirmed_at timestamptz,
confirmation_token text,
token_expires_at timestamptz,
metadata jsonb default '{}',
contact_id uuid references contacts(id) on delete cascade -- Links to unified contact
);
-- Unified contacts (single source of truth for email identity)
create table contacts (
id uuid primary key default gen_random_uuid(),
email text unique not null,
email_normalized text unique not null, -- Lowercase, trimmed for deduplication
created_at timestamptz default now(),
updated_at timestamptz default now(),
-- Supabase Auth integration (optional)
user_id uuid references auth.users(id) on delete set null,
-- Email status (email-service agnostic, updated via webhooks)
email_verified boolean default false,
email_verified_at timestamptz,
email_bounced boolean default false,
email_bounced_at timestamptz,
email_unsubscribed boolean default false,
email_unsubscribed_at timestamptz,
unsubscribe_token text unique, -- For secure unsubscribe links
-- Lifecycle tracking
first_seen_at timestamptz default now(), -- First contact with this email
last_contacted_at timestamptz, -- Last time email was sent
-- Flexible metadata
metadata jsonb default '{}'::jsonb
);
-- Contact activity timeline (optional, for CRM features)
create table contact_activity (
id uuid primary key default gen_random_uuid(),
contact_id uuid references contacts(id) on delete cascade,
activity_type text not null, -- 'waitlist_signup', 'email_sent', etc.
activity_data jsonb default '{}'::jsonb,
created_at timestamptz default now()
);Key relationships:
waitlist.contact_id→contacts.id- Links each waitlist signup to a unified contactcontacts.user_id→auth.users.id- Optionally links contacts to Supabase Auth userscontact_activity.contact_id→contacts.id- Timeline of all interactions
Supabase Auth Integration:
- The
contacts.user_idcolumn optionally links contacts to Supabase Auth users - When a waitlist signup creates an account, you can link them:
UPDATE contacts SET user_id = auth_user_id WHERE email = user_email - NULL for waitlist signups that never create accounts - this is expected and safe
- Enables cross-system queries: "All waitlist signups who became app users"
select * from waitlist_stats;Returns:
{
"confirmed_signups": 89,
"pending_signups": 12,
"total_signups": 101,
"confirmed_last_24h": 5,
"confirmed_last_7d": 23,
"unique_sources": 3,
"unique_contacts": 95
}Note: unique_contacts counts distinct contacts linked to waitlist entries (one contact can have multiple signups from different sources).
The API is organized with shared modules, making it simple to customize.
To use SendGrid, Mailgun, AWS SES, or another email service:
-
Modify
api/shared/email-service.js:- Replace the
sendConfirmationEmail()function - Replace the
sendWelcomeEmail()function - Update initialization to use your email service's SDK
- Replace the
-
Update
api/shared/config.js:- Add your email service's API key to
EMAIL_CONFIG - Update
fromEmailif needed
- Add your email service's API key to
-
Update
package.json:- Remove
resenddependency - Add your email service's SDK
- Remove
That's it! The rest of the code will continue to work. The database schema is email-service agnostic.
Quick Template Selection (Recommended):
# In your .env file, choose one of:
EMAIL_TEMPLATE_STYLE=minimal # Simple, clean design (default)
EMAIL_TEMPLATE_STYLE=professional # Elegant serif, formal
EMAIL_TEMPLATE_STYLE=branded # Custom brand colors
# Note: 'default' is an alias for 'minimal' (backward compatibility)Template Examples:
Confirmation email templates (minimal, professional, branded)
Welcome email templates (minimal, professional, branded)
Template Files:
- Configuration: Environment variables or
the-widget/templates/config.js - Default templates (minimal style):
the-widget/templates/ - Style variations:
the-widget/templates/examples/ - Complete guide:
the-widget/templates/TEMPLATE_README.md
Quick customization via environment variables:
EMAIL_TEMPLATE_STYLE=minimal # or professional, branded
EMAIL_PROJECT_NAME=Your Project
EMAIL_SENDER_NAME=The Team
EMAIL_PRIMARY_COLOR=#4f46e5
EMAIL_LOGO_URL=https://yourdomain.com/logo.png
EMAIL_BRANDED_TEXT_ONLY=true # For branded templates: show text header instead of logo
# Reply-to address (optional)
REPLY_TO_EMAIL="Support <support@yourdomain.com>" # Optional: set reply-to address for emails
# SPAM Compliance (CAN-SPAM Act requirements)
EMAIL_SENDER_ADDRESS="123 Main St, City, State 12345" # REQUIRED for marketing emails
EMAIL_ADVERTISEMENT_DISCLOSURE= # Optional: only if emails are promotional
# GDPR Compliance (Recommended)
EMAIL_PRIVACY_POLICY_URL=https://yourdomain.com/privacy # Privacy policy link in emails and formOr edit templates/config.js for detailed message customization.
Preview Templates:
npm run generate-email-previews
open example_emails/index.htmlFor detailed instructions, see the Template Editing Guide.
⚖️ Legal Compliance: All emails include required elements to comply with CAN-SPAM Act and GDPR requirements.
Required elements:
- Unsubscribe links - All welcome emails include functional unsubscribe links
- Physical postal address - Required for commercial emails (set via
EMAIL_SENDER_ADDRESS) - Advertisement disclosure - Optional, only needed if emails are promotional (set via
EMAIL_ADVERTISEMENT_DISCLOSURE)
How it works:
- Secure tokens: Each contact gets a unique unsubscribe token for secure unsubscribe links
- One-click unsubscribe: Users can unsubscribe via
/api/unsubscribe?token=...or/api/unsubscribe?email=... - Activity tracking: Unsubscribe events are logged in the contact activity timeline
- Success/error pages: Users see appropriate feedback after unsubscribe attempts
- Physical address: Automatically included in email footers when
EMAIL_SENDER_ADDRESSis set - Advertisement disclosure: Automatically included in welcome emails when
EMAIL_ADVERTISEMENT_DISCLOSUREis set
Configuration:
# REQUIRED for marketing emails (CAN-SPAM Act)
EMAIL_SENDER_ADDRESS="123 Main St, City, State 12345"
# Can be street address, P.O. Box, or private mailbox
# Multi-line addresses supported (use \n for line breaks)
# Optional - only if emails are promotional
EMAIL_ADVERTISEMENT_DISCLOSURE="This email is an advertisement."
# Leave blank if emails are transactional/relationship-based
# Default waitlist emails are NOT promotionalCustomization:
// In the-widget/templates/config.js
unsubscribeText: "Unsubscribe from these emails",
unsubscribeFooter: "You're receiving this email because you're subscribed to our emails. You can unsubscribe at any time.",Note: The default unsubscribe message is generic and works seamlessly with both waitlist and future CRM features (newsletters, product updates, etc.). Physical address appears in both confirmation and welcome emails when configured.
⚖️ Legal Compliance: Additional GDPR compliance features for transparency and consent.
Features:
- Privacy policy links - Automatically included in email footers when configured
- Consent checkbox - Optional consent checkbox in waitlist form (appears when privacy policy URL is provided)
Configuration:
# Privacy policy URL (recommended for GDPR compliance)
EMAIL_PRIVACY_POLICY_URL=https://yourdomain.com/privacy
# Will appear in email footers and enable consent checkbox in formHow it works:
- Email footers: Privacy policy link automatically appears in both confirmation and welcome emails when
EMAIL_PRIVACY_POLICY_URLis set - Form consent: When privacy policy URL is provided to the form, a consent checkbox appears requiring users to agree before submitting
- Conditional display: Both features only appear when the privacy policy URL is configured
Form usage:
{% include waitlist-form.html
api_url="https://your-api.vercel.app/api/subscribe"
privacy_policy_url="https://yourdomain.com/privacy" %}Note: Privacy policy links and consent checkboxes are optional but recommended for GDPR compliance, especially if you serve EU users. The privacy policy content itself must be created by you - this widget only provides the link/checkbox infrastructure.
The widget currently provides:
- ✅ Privacy policy links (transparency)
- ✅ Consent checkboxes (explicit consent)
- ✅ Unsubscribe functionality (marketing opt-out)
Not yet implemented:
- ❌ GDPR "Right to be Forgotten" (data deletion endpoint)
- ❌ GDPR "Data Portability" (data export endpoint)
If users request data deletion: You must handle deletion requests manually. See docs/GDPR_DATA_ENDPOINTS_SCOPE.md for implementation guidance. The scope document outlines what needs to be built, but the endpoints are not yet implemented.
Manual deletion options:
- Delete records directly in Supabase dashboard
- Use SQL queries to anonymize data
- Remove from Resend Contacts manually (if synced)
For full GDPR compliance, you may need to implement deletion/export endpoints or handle requests manually until they are built.
Database fields:
contacts.email_unsubscribed- Boolean flagcontacts.email_unsubscribed_at- Timestampcontacts.unsubscribe_token- Secure token for unsubscribe links
Redirect pages (on your Jekyll site):
/unsubscribe-success- Confirmation page (configurable viaUNSUBSCRIBE_SUCCESS_URL)/unsubscribe-error- Error handling page (configurable viaUNSUBSCRIBE_ERROR_URL)
Style variations are available in the-widget/templates/examples/:
- Professional (elegant serif, formal business style)
- Branded (colorful with header/footer sections)
- Note: Minimal style is now the default (root templates)
Templates use a simple {{variable}} system.
The form uses scoped CSS classes (.waitlist-form__*) defined in assets/waitlist-form.css. You can:
- Override styles in your Jekyll theme's CSS
- Edit
assets/waitlist-form.cssdirectly to customize - Use CSS variables or custom classes to theme the widget
Adjust in api/subscribe.js:
limiter: Ratelimit.slidingWindow(10, '1 h'), // 10 per hour
limiter: Ratelimit.slidingWindow(3, '10 m'), // 3 per 10 minutesEdit waitlist-confirmed.html and waitlist-error.html to match your site's design.
cd the-widget
npm install
# Create .env.local with your credentials
cp env.example .env.local
# Run locally
npm run start:devAPI available at http://localhost:3000/api/subscribe.
Test the form locally:
- Open
http://localhost:3000/in your browser (testing hub) - Or directly:
http://localhost:3000/local-test/test-form.html - This test page allows you to test the waitlist form without setting up a Jekyll site
- Make sure
npm run start:devis running before testing - See
the-widget/local-test/README.mdfor detailed testing instructions
| Service | Free Tier | Notes |
|---|---|---|
| Vercel | 100GB bandwidth | More than enough |
| Supabase | 500MB database | Millions of emails |
| Resend | 3,000 emails/mo | Then $20/mo for 50k |
| Upstash | 10k requests/day | Then $0.20/100k |
| Turnstile | Unlimited | Always free |
Total: $0/month for most indie projects.
If you need to merge multiple databases or import external contact data:
- Backup everything first - Export all databases before merging
- Import data - Use pg_dump/restore or manual import to combine databases
- Analyze duplicates:
SELECT * FROM public.analyze_duplicate_contacts(); - Merge duplicates:
SELECT * FROM public.merge_duplicate_contacts();
See docs/DATABASE_MERGE_GUIDE.md for detailed instructions.
For common issues and solutions, see the FAQ section.
Quick fixes:
- CORS errors: Add your domain to
CORS_ALLOWED_ORIGINSenvironment variable and redeploy - Emails not sending: Verify domain in Resend, check
FROM_EMAILmatches - Rate limiting not working: Check Upstash credentials are set correctly
- CAPTCHA always fails: Ensure site key (frontend) and secret key (backend) are from the same Turnstile widget
- Confirmation links broken: Check
BASE_URLis set to your website URL (where confirmation pages live)
For more help: See docs/FAQ.md for detailed troubleshooting and common questions.
Contributions are welcome! Here's how you can help:
Found a bug or have a feature request? Please open an issue with:
- A clear description of the problem or feature
- Steps to reproduce (for bugs)
- Your environment details (Node version, framework, etc.)
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes
- Test your changes thoroughly
- Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Follow existing code style and patterns
- Add comments for complex logic
- Update documentation if needed
- Test your changes before submitting
- Additional framework integrations
- UI/UX improvements
- Performance optimizations
- Documentation improvements
- Bug fixes
- Website: emberwisp.xyz
- Email: k@emberwisp.xyz
- GitHub: @sturdy-barnacle
GNU GPLv3