● tutorial 05 · you've built a server function before

An audience you own, not one you rent.

Every follower on every platform is borrowed. The algorithm decides who sees you, and it can change its mind tomorrow. An email list is the one channel where you keep the actual list, forever, no matter what any platform does.

This tutorial builds the real signup system running in a site's own footer. Same shape as Tutorial 04's payment function, aimed at a different problem, which is the part worth noticing: you already know more of this than it looks like.

💡 What you need: Tutorial 04 completed, or at least comfortable with the idea of a server function calling a secret key. Everything here builds on that.

Side Quest badge
The list is the one thing here no platform can take away.
1

Why Own Anything At All

rented reach vs. owned reach

Post on TikTok and a piece of software decides who sees it. Change that software's rules, or have your account flagged, or just have the algorithm cool on you for a month, and every one of those followers becomes unreachable. You never had them. You had access, on loan.

An email address works differently. Once someone gives you theirs, you can reach them directly, forever, using nothing but the address itself. No platform sits in the middle deciding whether your message gets delivered to people who already asked for it.

⚠️ This isn't a reason to quit social platforms. It's a reason not to depend on them as your only way to reach people. Use them for discovery. Use email for the relationship that survives whatever happens to the discovery channel.

That's the whole argument for this tutorial. Everything from here is just the mechanics of building it properly.

2

The Shape You Already Know

recognizing a repeating pattern

If you've built a payment button before, you've already built most of this. Look at the shape of both problems side by side.

1
Someone
fills a form
→
2
Browser
calls your function
→
3
Function calls
a real API
→
4
Secret key
lives server-side

A checkout button and an email signup are the same shape wearing different clothes. Both are: collect one piece of information from a stranger, hand it to a service you don't control, using a credential that must never reach their browser.

💰 Once you see this shape, you'll notice it everywhere: sending a text message, posting to a Discord server, saving a file to cloud storage. Different destination, identical skeleton. Learn the skeleton once.

The only genuinely new thing in this tutorial is which service sits on the other end, and one new wrinkle around consent that payments don't have to think about. Everything else is Tutorial 04 again.

3

Pick By Criteria, Not By Brand

what actually matters in a list provider

There are a lot of email list services and most tutorials just tell you to use one. That's not useful once the one they picked changes its pricing. Here's what to actually check, so you can evaluate whatever exists when you're reading this.

  • Does the free tier have a real API? Some services gate their API behind a paid plan. If you can't call it from code, this whole tutorial doesn't apply to that service.
  • What's the free subscriber ceiling, and does it expire? A free tier that requires a credit card "just in case" is a paid tier with extra steps.
  • Can you export your list as a plain file? If the answer is unclear or buried, that's a sign the service wants your list harder to leave with than to arrive with.
  • Does it handle unsubscribe links automatically? This one isn't optional. Lesson 8 explains why.

💡 This tutorial's code was written against ConvertKit, because at the time of writing it cleared all four checks with a generous free tier. If you're reading this later and something else fits better, the only file that changes is the function in Lesson 5. The form, the button, the styling, none of it cares which service is behind the curtain.

4

The Form, Built Right

what the browser is actually responsible for

Start with what a person sees. Kept deliberately plain, because the form's only job is collecting one string and handing it off.

the form itself
<form id="signup-form">
  <input
    type="email"
    name="email"
    placeholder="[email protected]"
    required
    autocomplete="email"
  >
  <button type="submit">Keep me posted</button>
</form>

type="email" gets you free validation. Most browsers refuse to submit an obviously malformed address before your code ever runs. autocomplete="email" lets a browser fill it from memory, which measurably increases how many people finish filling out a one-field form instead of abandoning it.

⚠️ Client-side validation is a courtesy to the visitor, not a security measure. Anyone can open dev tools and submit whatever they want, bypassing required entirely. Lesson 5 validates again on the server, for real, because this check is the one that actually counts.

Sending it without reloading the page

the submit handler
form.addEventListener('submit', async (e) => {
  e.preventDefault();  // stop the browser's default full-page reload

  const email = form.email.value.trim();
  const btn = form.querySelector('button');
  btn.disabled = true;
  btn.textContent = 'Sending...';

  const res = await fetch('/api/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email })
  });
  const data = await res.json();
  // show data.ok or data.error — see Lesson 7
});

e.preventDefault() is doing real work here. Without it, submitting a form navigates the browser to a new page, which is how forms worked before JavaScript existed and is almost never what you want on a modern page. Stopping that default is what lets you handle the response yourself and show a message without the page reloading out from under it.

Exercise 01

Build the form alone first

Before writing any server code, get this form submitting to a fake endpoint and logging what it would have sent. Confirm the button disables and the text changes. Get the frontend feeling right before the backend exists to receive it.

What to check

Open dev tools, submit the form, and look at the Network tab. You should see a request to /api/subscribe with your email in the request body, even though nothing is listening yet and it'll come back as an error. That's expected and correct at this stage — you're confirming the browser half works before building the half that answers it.

5

The Function

the actual code, from a real site

This is the function actually running behind a live signup form, lightly trimmed. Same file structure as Tutorial 04: a file in functions/api/ becomes a URL automatically.

functions/api/subscribe.js
function isValidEmail(email) {
  // simple on purpose — good enough to catch typos,
  // not a full email-format validator
  return typeof email === "string"
    && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export async function onRequestPost(context) {
  const { request, env } = context;

  const body = await request.json();
  const email = (body.email || "").trim().toLowerCase();

  if (!isValidEmail(email)) {
    return json({ ok: false, error: "That doesn't look like a real email." }, 400);
  }

  const res = await fetch(
    `https://api.convertkit.com/v3/forms/${env.CONVERTKIT_FORM_ID}/subscribe`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        api_key: env.CONVERTKIT_API_KEY,
        email: email
      })
    }
  );

  const data = await res.json();
  if (!res.ok) {
    return json({ ok: false, error: data.message || "Signup failed." }, 400);
  }
  return json({ ok: true });
}

function json(data, status = 200) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" }
  });
}

Walk it top to bottom. The regex check is the server actually enforcing what type="email" only suggested. env.CONVERTKIT_API_KEY is read from an environment variable, never typed into this file as text. The function talks to ConvertKit, gets an answer, and translates that into a small JSON object your form already knows how to read from Lesson 4.

💰 Notice this function never tells the browser why ConvertKit might have rejected something beyond a short message. That's deliberate. The full error from a third-party API can leak details about your account setup. Pass along a clean, safe message, log the messy real one only where you can see it.

Exercise 02

Swap the API by hand

Pick a different list provider's API documentation and rewrite just the middle fetch call to match its shape. Everything before and after that block should survive completely untouched.

Why this exercise matters

If you can do this in twenty minutes, you've actually learned the pattern rather than memorized one API. The isValidEmail check, the JSON response shape, the disabled-button dance in the form: none of that is ConvertKit-specific. Only the one fetch call is.

6

Secrets, Reinforced

why one key hides and the other doesn't

This function needs two pieces of configuration, and they get treated differently on purpose.

  • CONVERTKIT_API_KEY — stored as a Secret variable. If this leaked, someone could add or remove subscribers from your list, or worse depending on what your account can do. Same category as the Stripe key from Tutorial 04.
  • CONVERTKIT_FORM_ID — stored as a plain Text variable. It's just a number identifying which signup form to add someone to. Knowing it doesn't let anyone do anything they couldn't already do by finding your public signup form.

💡 The test for which type to pick: if someone else having this value lets them act as you, it's a Secret. If it only tells them which thing you're pointing at, Text is fine. A key is almost always a Secret. An ID number almost never is.

⚠️ Both still live in environment variables, not typed into the code. The difference between Secret and Text is about whether the dashboard hides the value after you save it. Neither type belongs in a file you'd commit to a public repository.

7

Test Without Spamming Yourself

a real address, checked in two places

There's no test mode for a mailing list the way Stripe has fake card numbers. The honest way to test this is to use one real email address you actually check, on purpose, and confirm the round trip in both directions.

  1. Submit the form with a real address you control
  2. Confirm the browser shows the success message, not an error
  3. Open your list provider's dashboard and confirm the address actually landed there
  4. Check whether a confirmation email arrived, and whether it looks like something you'd want a stranger to receive

⚠️ If the browser shows success but the address never shows up in the dashboard, the most common cause is a typo in the Form ID, or the environment variables not having been redeployed since you added them. Same rule as Tutorial 04: a new environment variable only applies to deployments made after it was set.

Exercise 03

Break it on purpose

Temporarily change one character in your Form ID and submit again. Confirm you get a readable error message, not a blank failure or a page crash. Then fix it back.

What you should see

A clean message like "Signup failed" in the little red box, because res.ok came back false and the function's error handling caught it. If instead the whole page breaks or nothing visibly happens, that's a sign the front-end error handling from Lesson 4 needs a second look.

8

Keep Your Own Copy

what "owning" the list actually means

Here's the uncomfortable truth this tutorial has been circling: right now, your list still lives on someone else's server. ConvertKit, or whichever service you picked, could raise prices, change features, or shut down. "Owning" your list doesn't mean the data lives nowhere else. It means you always have your own copy.

Export your list periodically. Every real provider has a CSV export button somewhere in settings. Download it monthly, or whenever it crosses your mind. That file is your actual insurance policy — if you ever needed to move to a different service, that export is what makes the move possible instead of catastrophic.

💰 A subscriber list you can export as plain text is a real asset. A subscriber list trapped behind a login you can't get data out of isn't yours no matter what the marketing page calls it. This is the single question worth re-asking any time you're evaluating a new tool: can I leave with my own data?

One legal thing that actually matters

In the US, the CAN-SPAM Act requires every marketing email you send to include a working unsubscribe link, and requires you to honor unsubscribe requests within 10 business days. Every reputable list provider handles this automatically once you're sending through them properly. Don't try to build your own send system to avoid this rule; build it in because it's the law, and because it's genuinely the right way to treat people who trusted you with their address.

⚠️ This applies even to a list of twelve people. The size of your list has nothing to do with whether the law applies to it.

★

Your Turn

practice challenges
Level 1

Honeypot Field

Add a hidden input real users never see but bots often fill in. If it has a value on submit, silently reject it. A simple first line of spam defense.

Level 2

Rate Limit One IP

Using whatever your host exposes about the request, add a basic check that one visitor can't submit the form fifty times a minute.

Level 3

Tag on Signup

Most providers let you attach a tag when someone subscribes. Add a hidden field noting which page they signed up from, and pass it through so you can see which content actually converts.

?

Glossary

words you'll meet again
Deliverability — whether your emails actually land in an inbox rather than spam. Determined by your provider's reputation, not just your code.
Double opt-in — requiring a click on a confirmation email before someone's fully subscribed. Slower growth, much cleaner list.
CAN-SPAM — the US law requiring a working unsubscribe link and honoring it within 10 business days.
Honeypot field — a hidden form field real humans never fill in, used to catch basic bots.
Webhook — the reverse of what this tutorial builds: a provider calling your server when something happens on their end.
List hygiene — periodically removing addresses that bounce or never engage, which protects your deliverability for everyone else on the list.
Segment / tag — a label on a subscriber so you can email a subset of your list instead of everyone at once.
Rate limiting — capping how many requests one source can make in a given window, to blunt spam and abuse.