Building a Type-Safe Form Pipeline with Formspree, Airtable, and TypeScript
Use a single Zod schema as the shared contract between your React form, Formspree, and Airtable, so field-name drift fails the build instead of shipping.
Every form-to-database integration depends on an implicit contract about field names and types. On a small project, developers hold that contract in their heads. On anything larger, it becomes a steady source of the exact kind of bug TypeScript was designed to catch. It slips through anyway, because the contract lives across service boundaries where the compiler can’t see it.
This tutorial makes that contract explicit. You’ll wire React Hook Form, Formspree, and Airtable together with a single Zod schema as their shared source of truth. Rename a field in the schema and the build fails until every layer catches up. The example is deliberately small: five fields, one webhook, one destination. The pattern scales to a codebase where dozens of forms feed dozens of destinations.
Defining the schema
Install Zod 4, React Hook Form, and the resolvers package:
npm install zod@^4 @hookform/resolvers react-hook-form
Pinning zod@^4 matters. The schema below uses Zod 4’s top-level format helpers like z.email, and a future major bump could change them.
Define the schema in schema.ts. The example throughout this article is an event registration form:
import { z } from "zod";
export const registrationSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.email("Enter a valid email"),
company: z.string().optional(),
role: z.enum(["developer", "designer", "manager", "other"]),
message: z.string().max(500).optional(),
});
export type RegistrationData = z.infer<typeof registrationSchema>;
z.infer extracts the TypeScript type from the schema automatically. RegistrationData becomes { name: string; email: string; company?: string; role: "developer" | "designer" | "manager" | "other"; message?: string }.
A plain TypeScript type or interface gives you compile-time checking but disappears at runtime. If a user submits { name: "", email: "notanemail" }, TypeScript won’t catch it. The values are wrong at runtime, after they’ve already passed through your type system. Zod validates the actual values at the boundary before they go anywhere else. One declaration does both jobs. (For a deeper look at Zod itself, see our Zod validation guide.)
Everything else in this pipeline imports from schema.ts.
Wiring the form
React Hook Form and Zod connect through zodResolver from @hookform/resolvers. The resolver runs your Zod schema on every submission and surfaces validation errors through React Hook Form’s error state. If you want to compare approaches to React form validation, we cover those separately.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registrationSchema, type RegistrationData } from "./schema";
import { useState } from "react";
const ROLES = registrationSchema.shape.role.options;
type Status = "idle" | "submitting" | "done" | "error";
async function submitRegistration(data: RegistrationData) {
return fetch("https://formspree.io/f/YOUR_FORM_ID", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(data),
});
}
export function RegistrationForm() {
const [status, setStatus] = useState<Status>("idle");
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
});
const onSubmit = handleSubmit(async (data: RegistrationData) => {
setStatus("submitting");
try {
const res = await submitRegistration(data);
setStatus(res.ok ? "done" : "error");
} catch {
setStatus("error");
}
});
if (status === "done") {
return <p>You're registered. See you there.</p>;
}
return (
<form onSubmit={onSubmit}>
<input {...register("name")} placeholder="Name" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} type="email" placeholder="Work email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register("company")} placeholder="Company (optional)" />
<select {...register("role")}>
<option value="">Your role</option>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
{errors.role && <span>{errors.role.message}</span>}
<textarea {...register("message")} placeholder="Anything to add? (optional)" />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? "Registering..." : "Register"}
</button>
{status === "error" && <p>Something went wrong. Try again in a moment.</p>}
</form>
);
}
ROLES comes from registrationSchema.shape.role.options rather than a separate hardcoded array. Add or remove an enum value in the schema and the select element updates without a second edit.
register("emial") (a typo) produces a TypeScript error: Argument of type '"emial"' is not assignable to parameter of type '"company" | "email" | "message" | "name" | "role"'. A plain <input name="emial" /> compiles without complaint.
handleSubmit calls its callback only when the Zod schema passes. By the time data arrives inside onSubmit, it’s validated and typed. The client doesn’t need to check it again. The webhook handler validates a second time, because it receives data from the network rather than from this component.
Submitting to Formspree
Formspree accepts application/json, stores every submission, and applies its own server-side email validation and spam filtering on top of what Zod already checked client-side. The two validation layers serve different purposes. Zod enforces the data contract, Formspree checks deliverability and catches bot traffic.
Create a form in Formspree, then replace YOUR_FORM_ID with the form’s hashid from the dashboard. submitRegistration accepts only RegistrationData, so passing it an object with a missing or misspelled field fails the build. The fetch call here follows Formspree’s AJAX submission pattern. If you’d rather not hand-roll it, the Formspree React library wraps the same request.
Formspree’s Workflow tab controls what happens after a submission arrives: email notifications, webhooks, integrations. None of it requires touching the frontend.
Routing submissions to Airtable
Formspree has a native Airtable plugin that maps form fields to columns from the dashboard with no code. For most setups, that’s enough. This article is about getting TypeScript to enforce the field-to-column mapping at build time, and that takes a webhook handler.
In the Formspree Workflow tab, add a Webhook action that points to an API route in your application. Formspree POSTs JSON in this shape on every new submission:
{
"form": "form-id",
"keys": ["name", "email", "company", "role", "message"],
"submission": {
"_date": "2026-07-04T10:00:00",
"name": "Alice Chen",
"email": "alice@example.com",
"company": "Example Co",
"role": "developer",
"message": "Looking forward to it"
}
}
Map schema keys to Airtable column names with a satisfies constraint:
import { type RegistrationData } from "./schema";
const AIRTABLE_FIELDS = {
name: "Name",
email: "Email",
company: "Company",
role: "Role",
message: "Notes",
} satisfies Record<keyof RegistrationData, string>;
satisfies Record<keyof RegistrationData, string> means this object must contain exactly the keys of RegistrationData. Add a field to the schema without adding it here and the next compile fails.
Before the handler, a few setup details. The form component works in any React app, but the handler below is a Next.js App Router route, so it assumes a project created with create-next-app. Its @/schema import relies on the @/* path alias that create-next-app configures in tsconfig.json, so put schema.ts at the root the alias points to (the project root, or src/ if you chose that option). The form component imports it with a relative path, as shown above.
The handler also reads three environment variables. Add them to .env.local and restart the dev server:
AIRTABLE_API_KEY=your_personal_access_token
AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX
AIRTABLE_TABLE_NAME=Registrations
Create the token in Airtable’s developer hub as a personal access token with the data.records:write scope and access to your base. The base ID starts with app and appears in the base’s URL. AIRTABLE_TABLE_NAME can be the table’s name or its ID. The table needs columns named to match the values in AIRTABLE_FIELDS below: Name, Email, Company, Role, and Notes.
The webhook handler parses the Formspree payload, validates it against the schema, and builds the Airtable record from the typed mapping:
// app/api/formspree-webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import { registrationSchema, type RegistrationData } from "@/schema";
const AIRTABLE_FIELDS = {
name: "Name",
email: "Email",
company: "Company",
role: "Role",
message: "Notes",
} satisfies Record<keyof RegistrationData, string>;
export async function POST(req: NextRequest) {
const body = await req.json();
const result = registrationSchema.safeParse(body.submission);
if (!result.success) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const data = result.data;
const fields: Record<string, unknown> = {};
for (const [key, airtableField] of Object.entries(AIRTABLE_FIELDS)) {
const value = data[key as keyof RegistrationData];
if (value !== undefined && value !== "") {
fields[airtableField] = value;
}
}
const base = encodeURIComponent(process.env.AIRTABLE_BASE_ID!);
const table = encodeURIComponent(process.env.AIRTABLE_TABLE_NAME!);
const airtableRes = await fetch(
`https://api.airtable.com/v0/${base}/${table}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AIRTABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ fields, typecast: true }),
}
);
if (!airtableRes.ok) {
const detail = await airtableRes.text();
return NextResponse.json(
{ error: "Airtable rejected the record", detail },
{ status: 502 }
);
}
return NextResponse.json({ ok: true });
}
No Airtable column name appears more than once in the codebase. The for loop builds the fields object from AIRTABLE_FIELDS, so every entry in the map reaches Airtable.
Three details in the handler you should look at:
encodeURIComponenton the base and table names. Table names with spaces are common in Airtable, and names containing/,?, or#would otherwise change the shape of the URL and hit the wrong route. (You can also use the table ID, which Airtable accepts interchangeably with the name and which survives renames.)typecast: true. Airtable single-select columns (a natural fit for theroleenum) reject unknown option values with a 422 unless typecast is enabled. With typecast on, Airtable creates the option on the fly, provided your API token’s user has creator permissions on the base.- Returning
502when Airtable rejects the write. Formspree expects a 2xx within 3 seconds and retries any other response up to 10 times, so a transient Airtable outage doesn’t drop the submission. Return200regardless, as many quick examples do, and Formspree marks the delivery successful while the row never reaches Airtable. - Skipping empty strings. Optional text inputs arrive as
""rather than missing, so the!== undefinedcheck alone would write blank values into Airtable.
Retries have two consequences for the handler. A 400 for an invalid payload is retried too, so a payload that fails validation once will fail the same way up to ten times. And a retried delivery can arrive more than once, so in production, guard against duplicate rows. Airtable has no unique constraint on a column, so store an identifier such as the submission’s _date plus email in a field and look it up (for example with the filterByFormula query parameter) before creating the record.
A production handler should also verify the webhook signature. Formspree signs webhook payloads with a shared secret so the endpoint can reject forged requests. Otherwise anyone can POST to a public URL. The signature arrives in the Formspree-Signature header as t=<unix>,v1=<hex>, and it’s an HMAC-SHA256 of {t}.{raw_body}. Because the signature covers the raw body, read it with await req.text(), verify, and only then JSON.parse it, rather than calling req.json() first. Reject any request whose computed signature doesn’t match before calling safeParse.
What changes when you change the schema
Add a phone number field to registrationSchema:
export const registrationSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.email("Enter a valid email"),
company: z.string().optional(),
role: z.enum(["developer", "designer", "manager", "other"]),
message: z.string().max(500).optional(),
phone: z.string().optional(), // added
});
TypeScript reports an error on AIRTABLE_FIELDS: Property 'phone' is missing in type '{ name: string; email: string; company: string; role: string; message: string; }' but required in type 'Record<"company" | "email" | "message" | "name" | "phone" | "role", string>'. Add phone: "Phone" to AIRTABLE_FIELDS and the error clears. The for loop picks up the new key, so every Airtable record now includes the phone value with no further code changes. Add a Phone column to the Airtable table as well. TypeScript can’t see your base, and Airtable rejects writes to columns that don’t exist.
Add <input {...register("phone")} placeholder="Phone (optional)" /> to the form and TypeScript confirms "phone" is a valid key of RegistrationData.
Without types, the same change fails differently. You add the field to the form, add the Airtable column, and ship. If you forget to update the webhook handler’s field mapping, nothing errors. The column stays blank for weeks before a data audit surfaces it. The satisfies constraint on AIRTABLE_FIELDS turns that silent gap into a compile-time error.
The form, the submission, and the Airtable record all derive from the schema.
The same structure works for any submission target. Replace the Airtable fetch call with a Notion API call, a Google Sheets append, or a direct database insert. The schema file and the satisfies-constrained field map keep the same shape for any destination.
Formspree stores every submission, retries webhook deliveries your endpoint rejects, and runs the integrations you configure in the dashboard. Zod validates data at runtime and gives TypeScript enough to catch field-name drift before it ships.
Start with the schema.