Process automation
Intakeformulier op het web: halve dag, eerlijke validatie
Je boekhouder heeft een klembord. Elke nieuwe klant vult met de hand dezelfde negen velden in, en iemand typt het over in het CRM. Dit is de fix voor een halve dag.

Je boekhouder heeft een klembord. Elke nieuwe klant krijgt hetzelfde A4'tje met negen velden: bedrijfsnaam, KvK-nummer, btw-nummer, adres, contactpersoon, e-mail, telefoon, betalingstermijn, handtekening. De klant vult het in, geeft het terug, en iemand typt het hele zooitje over in de boekhouding en in het CRM. Twee keer, want die twee systemen praten niet met elkaar.
Je weet al twee jaar dat dit zonde is. De reden dat het er nog niet van gekomen is: "bouw ons een intakeformulier" klinkt als een traject van drie weken met een developer, een designer en een akkoordvergadering. Dat hoeft niet. De eerlijke versie van dit klusje past in een werkmiddag, mits je het in de juiste volgorde doet en de neiging weerstaat om dingen te valideren die je eigenlijk niet kunt controleren.
Fotografeer het formulier eerst, beslis nog niks
Open de camera. Maak een foto van het papieren formulier. Schrijf elk veld in één tekstbestand in de volgorde van het formulier. Ga niets herontwerpen. Die papieren versie is verfijnd door honderden klanten die aan de balie mompelden "waarom vraag je dit eigenlijk"; de veldvolgorde bevat informatie die je niet kwijt wilt.
Zet bij elk veld drie dingen: het label zoals de klant het ziet, het type (tekst, e-mail, getal, datum, bestand, keuze), en wat je team ermee doet na verzending. Die laatste kolom is degene die telt. Als niemand verderop in het proces iets doet met "faxnummer", sterft het veld hier op je keukenvloer, voordat het ooit het web bereikt.
Sorteer velden in "gecheckt" en "vertrouwd"
Dit is de stap die niemand doet en de reden dat de meeste web-intakeformulieren toneel zijn. Loop je lijst af en geef elk veld één van twee letters:
- C voor checked: het systeem kan het zelfstandig verifiëren. KvK-nummers kun je opzoeken via de KvK-API. Btw-nummers via VIES. IBANs hebben een checksum die je lokaal kunt draaien.
- T voor trusted: je accepteert wat de klant intypt. Bedrijfsnaam is T. Contactnaam is T. De meeste adressen zijn T, tenzij je een postcodeservice koppelt.
De meeste velden zijn T, en doen alsof dat niet zo is met een slimme regex verplaatst ze niet naar C. Het betekent alleen dat je mevrouw O'Sullivan afwijst omdat de apostrof je patroon brak, of dat je hardop zweert dat "info@gmial.com" geldig is omdat de vorm klopt. E-mailregex is het schoolvoorbeeld. De volledige RFC 5322-grammatica beslaat ongeveer 6.500 tekens regex en accepteert nog steeds adressen die je mailserver afwijst zodra je probeert te bezorgen. Controleer dat het veld een @ bevat, stuur een bevestigingslink, en laat de bounce je de waarheid vertellen. De MDN-referentie over de Constraint Validation API is eerlijk over dit onderscheid: de browser geeft aan wat verkeerd lijkt als hint voor de gebruiker, niet als garantie voor jou.
Kies een stack die in het budget past
Een halve dag is vier uur geconcentreerd werk. Dat budget sluit het meeste uit waar een developer eerst naar zou grijpen. Geen eigen backend. Geen vers database-schema. Geen nieuw authenticatiesysteem. Je wilt een statisch HTML-formulier, een serverless endpoint dat schrijft naar een tabel die je al hebt, en één e-mailmelding.
Een concrete keuze die we op deze schaal vaker dan eens hebben opgeleverd: een Next.js-pagina op Vercel, een Supabase-tabel, een Zod-schema gedeeld tussen formulier en endpoint, en één Edge Function die de rij wegschrijft en een bevestigingsmail stuurt via je bestaande transactionele provider. Draait je shop op PHP, dan is het equivalent één submit.php die valideert met Respect/Validation en wegschrijft naar MySQL. De vorm van het werk is identiek.
Het gedeelde 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>;
Let op wat dit schema niet doet. Het probeert het e-mailadres niet volledig te valideren. Het valideert het telefoonnummer niet met een landgebonden regex; je hebt een contactformulier, geen telecombedrijf. Het valideert de btw-vorm niet, want het echte antwoord komt van een server-side VIES-lookup, en "juiste vorm, ongeldig nummer" is een slechtere gebruikerservaring dan "dit nummer staat niet geregistreerd, controleer de cijfers".
Schrijf het formulier eerst in native HTML
Voordat je naar een React-form-library grijpt, schrijf je het HTML-formulier. De browser weet al hoe hij verplichte velden moet markeren, inline-fouten moet tonen en verzending moet blokkeren als de types niet kloppen. Gebruik dat.
<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>
Vier attributen doen het meeste werk: required, pattern, type en autocomplete. Die laatste is het meest onderbenutte attribuut op de pagina. Zet autocomplete="organization", autocomplete="email" en autocomplete="tel", en de browser vult het formulier op mobiel in één tap vanuit de opgeslagen contactkaart. Dat is de regel HTML met de meeste impact die je vanmiddag schrijft.
Het attribuut novalidate op de form-tag zet de standaard browser-pop-up uit. Je leest nog steeds de validatie-staat van elke input in JavaScript en rendert je eigen inline-meldingen, maar je slaat de ongestylde native bubbles over die in 2026 stuk lijken te zijn.
Server-side checks die ergens op slaan
Het endpoint draait hetzelfde Zod-schema, en doet daarna die ene of twee checks die een veld werkelijk van T naar C verplaatsen:
// /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 });
}
Dat is onder de veertig regels. Twee echte checks (de schema-vorm en het bestaan bij KvK) en één stuk toneelvrij vertrouwen (de e-mail krijgt een bevestigingslink, en als het adres bounct, blijft de rij ongeverifieerd). Dat is de hele backend. Weersta de neiging om er meer aan te plakken.
Een formulier dat zegt "dit veld is verplicht" is prima. Een formulier dat claimt een btw-nummer te valideren kan dat maar beter ook echt doen, anders lieg je tegen de gebruiker en tegen jezelf.
De bevestigingsstap die iedereen overslaat
De inzending is niet klaar als de spinner stopt. Hij is klaar als de klant zijn e-mailadres heeft bevestigd. Bouw de bevestiging in dezelfde middag, want stel je het uit, dan zit je over zes maanden met de hand typo-rijen in een spreadsheet te ontdubbelen.
Het patroon is bewust saai:
- Voeg de rij toe met status
pending_confirmationen een random token. - Stuur een mail met een link die het token bevat.
- Die link raakt een route die de rij omzet naar
confirmeden een bedankpagina toont. - Meld je team alleen bij bevestiging, niet bij inzending. Deze ene regel doodt het meeste "verkeerde e-mail"-geruis.
De OWASP-richtlijn over input-validatie is hier vijf minuten lezen waard. De interessante faalmodi zijn niet de voor de hand liggende (SQL-injectie, XSS), maar de saaie: een "bedrijfsnaam" van 50.000 tekens die twee weken later je PDF-generator sloopt, een unicode right-to-left-override die de afzendernaam in je CRM omdraait. Forceer maxlength op de server, normaliseer de unicode, en je hebt twee toekomstige incidenten alvast overgeslagen.
Test met een echte inzending, daarna met een vijandige
Het formulier is niet klaar als het bij jou in dev werkt. Open het op je telefoon, op de echte productie-URL, en vul het in als een echte klant. Gebruik de autofill. Verstuur. Controleer dat de rij in de tabel staat, dat de bevestigingsmail binnen tien seconden binnen is, en dat de link in die mail in één tap werkt vanuit de mailclient (niet via copy-paste).
Dan de vijandige ronde. Verstuur een leeg formulier. Verstuur met elk veld op maximale lengte. Plak een emoji in elke input. Plak een typografisch aanhalingsteken in het naamveld en check dat het de heen-en-weer overleeft door je database, je e-mailtemplate en je PDF-generator zonder een vraagteken in een zwart ruitje te worden. Verminkt één van die stappen de tekst, dan heb je een charset-probleem, en je kunt het beter nu vinden dan tijdens een klant-onboarding volgende maand.
Vier uur later
In deze volgorde gedaan levert de middag je een publieke URL op die de klant vanaf zijn telefoon kan invullen, een rij in een echte tabel, een e-mailbevestiging die bewijst dat het adres werkt, en één melding voor je team wanneer (en alleen wanneer) een inzending ook echt geldig is. Je hebt geen klantportaal gebouwd. Je hebt geen CRM gebouwd. Je hebt een klembord vervangen door een link, en dat is de hele klus.
Wat sneuvelt om in een halve dag te passen, is de admin-UI. Die bouw je niet. Het team leest nieuwe rijen in Supabase Studio, Airtable, of welke spreadsheet-op-een-database je toch al betaalt. Rechtvaardigt het volume het, dan promoveer je naar een echte interne tool; tot die tijd is de rij in de tabel de interface.
Toen we eerder dit jaar het intakeformulier bouwden voor een Nederlands boekhoudkantoor, liepen we aan tegen het feit dat de KvK-API een 200 OK met een lege resultatenlijst teruggeeft voor onbekende nummers, geen 404. Onze eerste versie accepteerde vrolijk "00000000" als echt bedrijf. We zijn uiteindelijk de lengte van de response-payload gaan checken in plaats van de status code, en we hebben er een test van één regel bijgezet die bij elke deploy een KvK van enkel nullen instuurt. Slaagt die test, dan liegt de validatie weer. Dat soort werk past in onze gebruikelijke procesautomatisering voor kleine operations-teams.
Wil je vandaag de kleinst mogelijke versie hiervan: fotografeer je huidige papieren formulier, lijst de velden op, markeer elk veld als C of T, en schrap elk T-veld dat niemand verderop in het proces gebruikt. Je zult versteld staan hoe kort die lijst wordt.
Kern
Een formulier dat zegt 'dit veld is verplicht' is prima. Een formulier dat claimt een btw-nummer te valideren kan dat maar beter ook echt doen, anders lieg je tegen jezelf.
FAQ
Heb ik die bevestigingsmail-stap echt nodig?
Ja. Zonder kun je typo's niet onderscheiden van echte adressen, en over zes maanden zit je met de hand een spreadsheet vol pending_confirmation-rijen te ontdubbelen.
Kan ik server-side validatie overslaan als het formulier client-side validatie heeft?
Nee. Client-validatie is voor UX, niet voor security. Iedereen kan met curl naar je endpoint posten. Draai altijd hetzelfde schema ook server-side.
Wat als ik geen developer voor een halve dag heb?
Gebruik een hosted form-dienst zoals Tally of Formspree, en upgrade later. Het sorteerwerk uit stap één is het deel dat je niet kunt uitbesteden.