If you are launching a product in beta, one of the fastest wins is a clean holding page with an email signup form.
In this tutorial, I will show you exactly how we set this up with:
- Astro for the site
- Vercel Serverless Functions for the API endpoint
- Mailjet API for storing subscribers in a contact list
This pattern is simple, production-friendly, and easy to extend later.
What We Built
We created a single beta landing page with:
- Clear “project is in beta” messaging
- An email signup form
- A serverless endpoint at
/api/subscribe - Mailjet integration to save users into a specific list
- Environment-variable based secrets in Vercel
1. Start with an Astro Project
If you already have Astro, you can skip this.
npm create astro@latest
npm install
For this tutorial, we used a homepage at src/pages/index.astro and a serverless route at src/pages/api/subscribe.ts.
2. Configure Astro for Vercel Serverless
Install the adapter:
npm i @astrojs/vercel
Update astro.config.mjs:
import { defineConfig } from "astro/config";
import vercel from "@astrojs/vercel";
export default defineConfig({
output: "server",
adapter: vercel({
functionPerRoute: true,
maxDuration: 10
})
});
Why this matters:
output: "server"enables API routesfunctionPerRoute: truekeeps functions modularmaxDurationhelps avoid unexpected timeout defaults
3. Build the Signup API Route in Astro
Create src/pages/api/subscribe.ts:
import type { APIRoute } from "astro";
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAILJET_API_BASE = "https://api.mailjet.com/v3/REST";
type MailjetErrorPayload = {
ErrorInfo?: string;
ErrorMessage?: string;
};
const jsonHeaders = { "Content-Type": "application/json" };
const makeAuthHeader = (apiKey: string, apiSecret: string): string => {
const token = Buffer.from(`${apiKey}:${apiSecret}`).toString("base64");
return `Basic ${token}`;
};
const readMailjetError = async (response: Response): Promise<string> => {
const fallback = `Mailjet request failed with status ${response.status}.`;
try {
const payload = (await response.json()) as MailjetErrorPayload;
return payload.ErrorInfo || payload.ErrorMessage || fallback;
} catch {
return fallback;
}
};
export const POST: APIRoute = async ({ request }) => {
const apiKey = import.meta.env.MAILJET_API_KEY;
const apiSecret = import.meta.env.MAILJET_API_SECRET;
const listId = Number(import.meta.env.LIST_ID);
if (!apiKey || !apiSecret || !Number.isFinite(listId)) {
return new Response(JSON.stringify({ error: "Server is missing Mailjet configuration." }), {
status: 500,
headers: jsonHeaders
});
}
let payload: unknown;
try {
payload = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid request body." }), {
status: 400,
headers: jsonHeaders
});
}
const email =
typeof payload === "object" && payload !== null && "email" in payload
? String((payload as { email: unknown }).email).trim()
: "";
if (!EMAIL_REGEX.test(email)) {
return new Response(JSON.stringify({ error: "Please provide a valid email address." }), {
status: 400,
headers: jsonHeaders
});
}
const authHeader = makeAuthHeader(apiKey, apiSecret);
const createContactResponse = await fetch(`${MAILJET_API_BASE}/contact`, {
method: "POST",
headers: {
...jsonHeaders,
Authorization: authHeader
},
body: JSON.stringify({ Email: email })
});
if (!createContactResponse.ok && createContactResponse.status !== 400) {
const message = await readMailjetError(createContactResponse);
return new Response(JSON.stringify({ error: message }), {
status: 502,
headers: jsonHeaders
});
}
const addToListResponse = await fetch(`${MAILJET_API_BASE}/listrecipient`, {
method: "POST",
headers: {
...jsonHeaders,
Authorization: authHeader
},
body: JSON.stringify({
ContactAlt: email,
ListID: listId
})
});
if (!addToListResponse.ok) {
const message = await readMailjetError(addToListResponse);
const alreadySubscribed = message.toLowerCase().includes("already");
if (!alreadySubscribed) {
return new Response(JSON.stringify({ error: message }), {
status: 502,
headers: jsonHeaders
});
}
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: jsonHeaders
});
};
4. Wire the Frontend Form to the API
In your src/pages/index.astro, submit to /api/subscribe with fetch:
<form id="beta-signup-form" novalidate>
<input id="email" name="email" type="email" required />
<button type="submit">Notify me</button>
<p id="status" aria-live="polite"></p>
</form>
<script>
const form = document.getElementById("beta-signup-form");
const emailInput = document.getElementById("email");
const statusNode = document.getElementById("status");
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const email = emailInput instanceof HTMLInputElement ? emailInput.value.trim() : "";
const validEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
if (!validEmail) {
statusNode.textContent = "Please enter a valid email address.";
return;
}
const response = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email })
});
statusNode.textContent = response.ok
? "Thanks. You are on the beta interest list."
: "Could not submit right now. Please try again.";
});
</script>
5. Add Environment Variables in Vercel
In your Vercel project settings, add:
MAILJET_API_KEYMAILJET_API_SECRETLIST_ID
Set each variable for the environments you use (Preview, Production, and optionally Development).
Then redeploy so your serverless function picks them up.
6. Type Your Env Variables in Astro
In src/env.d.ts:
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly MAILJET_API_KEY: string;
readonly MAILJET_API_SECRET: string;
readonly LIST_ID: string;
}
This keeps TypeScript happy and avoids silent mistakes.
What Could Go Wrong (And How to Fix It)
+`500` from `/api/subscribe` because env vars are missing
Symptoms: API route says server is misconfigured.
Fix: Confirm all three env vars exist in Vercel, are in the correct environment, and redeploy.
+`ENOTFOUND registry.npmjs.org` during install/build
Symptoms: Install fails locally or in restricted environments.
Fix: Ensure outbound network access is available. In restricted sandboxes, rerun with escalated permissions.
+Contact is created but not added to list
Symptoms: Mailjet has the contact, but campaign list remains empty.
Fix: Check `LIST_ID` value and ensure the API key has permission to write to that list.
+Duplicate signup errors
Symptoms: Returning users see errors on re-submit.
Fix: Treat "already exists/already subscribed" responses as success (idempotent behavior).
+Astro build warning about Node version
Symptoms: Local warning that your Node version is not what Vercel serverless uses.
Fix: Align local runtime to Node 24 for parity with Vercel serverless runtime.
+Deprecated adapter imports
Symptoms: Warning about `@astrojs/vercel/serverless` import deprecation.
Fix: Import directly from `@astrojs/vercel`.
Why This Setup Works Well for Beta Launches
This approach is ideal for a beta waiting list because it is:
- Fast to ship
- Low maintenance
- Secure by default (secrets stay server-side)
- Easy to extend later (double opt-in, webhooks, segmentation, CRM sync)
Once signups grow, you can add:
- Double opt-in confirmation flow
- Admin dashboard for signups
- Rate limiting / bot protection
- Analytics events per submission source
Final Thoughts
If you already use Mailjet, this is the shortest path to a reliable beta signup flow on Vercel.
Start simple. Capture interest. Validate demand. Then iterate.
That is exactly what this setup is designed to do.
Benefits of Using Mailjet at the Start
If you are launching a beta or an MVP, Mailjet is a strong option because you can begin without adding much cost or complexity.
At the time of writing, Mailjet’s Free plan includes:
- Up to 6,000 emails per month
- A 200 emails/day sending cap
- Up to 1,000 contacts
- No credit card required to start
That is usually enough to validate demand, collect early subscribers, and run your first launch updates.
As your business grows, you can upgrade to paid plans for higher monthly volume, no daily send cap, and additional features (for example segmentation and stronger support), while keeping the same API integration pattern shown in this guide.
Always check the current pricing and limits before launch, as plan details can change over time.