← Blog

Process automation

Web intake forms: a half-day playbook with honest checks

Your bookkeeper has a clipboard. Every new client fills the same nine fields by hand, then someone retypes them into the CRM. Here is the half-day fix.

Jacob Molkenboer· Founder · A Brand New Company· 4 Jun 2026· 10 min
Wooden clipboard with printed intake form, chartreuse paper clip, brass pen, red wax stub on ivory desk.

Your bookkeeper has a clipboard. Every new client gets handed the same A4 sheet with nine fields on it: company name, KvK number, VAT number, address, contact name, contact email, phone, payment terms, signature. The client fills it in, hands it back, and someone retypes the lot into the accounting tool and the CRM. Twice, because the two systems do not talk to each other.

You have known this is silly for two years. The reason it has not been fixed is that "build us an intake form" sounds like a three-week project with a developer, a designer, and a sign-off meeting. It does not have to be. The honest version of this job fits in a working afternoon if you do it in the right order and resist the urge to validate things you cannot actually check.

Photograph the form first, decide nothing yet

Open the camera. Take a picture of the paper form. Write every field into a single text file in the order the form has them. Do not redesign anything yet. The paper version has been refined by hundreds of clients who muttered "why are you asking me this" at the counter; the field order encodes information you do not want to lose.

For each field, write three things: the label as the client sees it, the type (text, email, number, date, file, choice), and what your team does with it after submission. That last column is the one that matters. If nobody does anything with "fax number" downstream, the field dies here, on the floor of your kitchen, before it ever reaches the web.

Sort fields into "checked" and "trusted"

This is the step nobody does and it is the reason most web intake forms are theatre. Go down your list and mark each field with one of two letters:

  • C for checked: the system can verify it independently. KvK numbers can be looked up against the chamber of commerce API. VAT numbers can be looked up against VIES. IBANs have a checksum you can run locally.
  • T for trusted: you accept whatever the client types. Company name is T. Contact name is T. Most addresses are T unless you wire up a postcode service.

Most fields are T, and pretending otherwise with a clever regular expression does not move them into C. It just means you reject Mrs. O'Sullivan because the apostrophe broke your pattern, or you swear blind that "info@gmial.com" is valid because it matched the shape. Email regex is the canonical example. The full RFC 5322 grammar runs to roughly 6,500 characters of regex and still accepts addresses your mail server will reject the moment you try to deliver to them. Check that the field contains an @, send a confirmation link, and let the bounce tell you the truth. The MDN reference for the Constraint Validation API is honest about this distinction; the browser flags what looks wrong as a hint to the user, not a guarantee to you.

Pick a stack that fits the budget

Half a day is four hours of focused work. That budget rules out most of what a developer would reach for first. No custom backend. No fresh database schema. No new authentication system. You want a static HTML form, a serverless endpoint that writes to a table you already have, and one email notification.

A concrete pick we have shipped at this size more than once: a Next.js page on Vercel, a Supabase table, a Zod schema shared between the form and the endpoint, and a single Edge Function that writes the row and sends a confirmation email through your existing transactional provider. If your shop runs on PHP, the equivalent is a single submit.php that validates with Respect/Validation and writes to MySQL. The shape of the work is the same.

The shared schema

// schema.ts shared between the form and the endpoint
import { z } from "zod";

export const Intake = z.object({
  company_name: z.string().min(1).max(200),
  kvk: z.string().regex(/^\d{8}$/, "KvK is 8 digits"),
  vat: z.string().optional(),                 // verified server-side via VIES
  address_line: z.string().min(1).max(200),
  postcode: z.string().min(4).max(10),
  city: z.string().min(1).max(100),
  contact_name: z.string().min(1).max(120),
  contact_email: z.string().email(),          // shape only; the real check is the confirmation mail
  phone: z.string().min(6).max(20),
  payment_terms_days: z.number().int().min(0).max(90),
});

export type Intake = z.infer<typeof Intake>;

Notice what this schema does not do. It does not try to fully validate the email. It does not validate the phone with a country-specific regex; you have a contact form, not a telco. It does not validate the VAT shape, because the real answer comes from a server-side VIES lookup, and "valid shape, invalid number" is a worse user experience than "this number is not registered, please check the digits".

Write the form with native HTML first

Before you reach for a React form library, write the HTML form. The browser already knows how to mark required fields, show inline errors, and prevent submission when types are wrong. Use it.

<form id="intake" novalidate>
  <label>
    Bedrijfsnaam
    <input name="company_name" required maxlength="200"
           autocomplete="organization">
  </label>

  <label>
    KvK-nummer
    <input name="kvk" required pattern="\d{8}" inputmode="numeric"
           title="8 cijfers, zoals 12345678">
  </label>

  <label>
    E-mail contactpersoon
    <input name="contact_email" required type="email"
           autocomplete="email">
  </label>

  <button type="submit">Versturen</button>
</form>

Four attributes do most of the work: required, pattern, type, and autocomplete. The last one is the single most under-used attribute on the page. Set autocomplete="organization", autocomplete="email", autocomplete="tel", and the browser fills the form from the user's saved contact card on mobile in one tap. That is the highest-impact line of HTML you will write all afternoon.

The novalidate attribute on the form tag turns off the browser's default popup. You still read the validation state on each input in JavaScript and render your own inline messages, but you skip the unstyled native bubbles that look broken in 2026.

Server-side checks that mean something

The endpoint runs the same Zod schema, then does the one or two checks that actually move a field from T to C:

// /api/intake
import { Intake } from "./schema";

export async function POST(req: Request) {
  const body = await req.json();
  const parsed = Intake.safeParse(body);
  if (!parsed.success) {
    return Response.json(
      { ok: false, errors: parsed.error.flatten() },
      { status: 400 }
    );
  }

  // Real verification, not theatre
  const kvkRes = await fetch(
    `https://api.kvk.nl/api/v2/zoeken?kvkNummer=${parsed.data.kvk}`,
    { headers: { apikey: process.env.KVK_API_KEY! } }
  );
  const kvk = await kvkRes.json();
  if (!kvk.resultaten?.length) {
    return Response.json(
      { ok: false, errors: { kvk: ["onbekend bij KvK"] } },
      { status: 400 }
    );
  }

  // Write the row, queue the confirmation mail
  const row = await db.from("intakes")
    .insert({ ...parsed.data, status: "pending_confirmation" })
    .select().single();
  await sendConfirmation(parsed.data.contact_email, row.data.id);

  return Response.json({ ok: true, id: row.data.id });
}

That is under forty lines. It does two real checks (schema shape, KvK existence) and one piece of theatre-free trust (the email gets a confirmation link, and if the address bounces, the row stays unverified). That is the entire backend. Resist the urge to add more.

Takeaway

A form that says "this field is required" is fine. A form that claims to validate a VAT number had better actually check, or it is lying to the user and to you.

The confirmation step everyone skips

The submission is not done when the spinner stops. It is done when the client has confirmed their email address. Build the confirmation in the same afternoon, because if you defer it, you will be deduplicating typo-ridden rows in a spreadsheet six months from now.

The pattern is boring on purpose:

  1. Insert the row with status pending_confirmation and a random token.
  2. Send an email with a link containing the token.
  3. That link hits a route that flips the row to confirmed and shows a thank-you page.
  4. Notify your team only on confirmation, not on submission. This single rule kills most of the "wrong email" noise.

The OWASP guidance on input validation is worth a five-minute read here. The interesting failure modes are not the obvious ones (SQL injection, XSS) but the boring ones: a 50,000-character "company name" that breaks your PDF generator two weeks later, a unicode right-to-left override character that flips the displayed sender name in your CRM. Enforce maxlength on the server, normalise the unicode, and you have skipped two future incidents.

Test with a real submission, then a hostile one

The form is not done when it works for you in dev. Open it on your phone, on the actual production URL, and fill it in as a real client. Use the autofill. Submit. Check that the row landed in the table, that the confirmation email arrived in under ten seconds, and that the link in that email worked on first tap from the mail client (not from copy-paste).

Then do the hostile pass. Submit an empty form. Submit with every field at its max length. Paste an emoji into every input. Paste a curly quote into the name field and check that it survives the round trip through your database, your email template, and your PDF generator without becoming a question mark inside a black diamond. If any one of those steps mangles the text, you have a charset problem, and it is better to find it now than during a client onboarding next month.

Four hours later

Done in this order, the afternoon gives you a public URL the client can fill in from their phone, a row in a real table, an email confirmation that proves the address works, and one notification to your team when (and only when) a submission is actually valid. You have not built a customer portal. You have not built a CRM. You have replaced a clipboard with a link, which is the whole job.

The thing that gets cut to fit half a day is the admin UI. You do not build one. The team reads new rows in Supabase Studio, Airtable, or whichever spreadsheet-on-a-database you already pay for. When the volume justifies it, you graduate to a real internal tool; until then, the row in the table is the interface.

When we built the intake form for a Dutch bookkeeping firm earlier this year, the thing we ran into was that the KvK API returns 200 OK with an empty result set for unknown numbers, not a 404. Our first version cheerfully accepted "00000000" as a real company. We ended up checking the response payload length rather than the status code, and added a one-line test that submits the all-zeros KvK on every deploy. If that test passes, the validation is lying again. That kind of work sits inside our usual process automation for small ops teams.

If you want the smallest possible version of this today: photograph your current paper form, list the fields, mark each one C or T, and delete every field marked T that nobody downstream actually uses. You will be surprised how short the list gets.

Key takeaway

A form that says 'this field is required' is fine. A form that claims to validate a VAT number had better actually check, or it is lying to you.

FAQ

Do I really need a confirmation email step?

Yes. Without it you cannot tell typos from real addresses, and six months from now you will be deduplicating a spreadsheet of pending_confirmation rows by hand.

Can I skip server-side validation if the form has client-side validation?

No. Client validation is for UX, not security. Anyone can post to your endpoint with curl. Run the same schema server-side, always.

What if I do not have a developer for half a day?

Use a hosted form service like Tally or Formspree, then upgrade later. The field-sorting work in step one is the part you cannot outsource.

process automationworkflowuxtoolingoperations

Building something?

Start a project