Build Next.js forms you can't break. Learn the pattern I use to create type-safe Server Actions that catch errors at compile-time, not in production.
Problem: Inefficient Forms
I have been building forms in applications for years at this point and after building so many forms, I found out some real issues that always stand in place if you want to scale your application because then you would need real strict conventions for your entire stack. When I was building one of my side projects, I was simply building the form just how the docs or any other blogpost suggested on the internet but sooner as my project grew in size, I found several difficulties maintaining the form and the engineer in me thought it wasn’t quite OK to be honest. The form gave me no confidence in pushing it to production as there were multiple return statements in places then too many if-else blocks in my client component it just created cognitive load and didn’t quite work well for complex forms handling multiple data types and different situations and that was when I decided I need to find a proper solution to this, I dug deeper over the internet and docs and questioning LLMs everywhere I found almost the same solution that I was already implementing that’s when I decided to think through forms myself and I came upon this really good solution myself.
Solution: Stateful Contracts
Let’s think of forms in the aspect of “States“ (like states of matter or even state in React if you would). States are an analogy we can apply on forms as well. Any form can have states, for e.g. a form with just 2 fields has a total of 3 states for it’s fields - none filled, 1 filled, all filled. So that means our forms can be in any state and in order to not flush out the input values from the user, we always try to maintain the input data. So in order to maintain the state of our forms we are going to be using the useActionState() hook from React.
Next up, Types!
Types are a really really great way of reducing runtime bugs or even catch bugs in comilation of your code before it hits production ensuring you have a really great developer experience and boosted confidence in your code in production, eventually reducing the 3 AM debuggings you had to do 😂
I have not seen much solutions where developers apply types to React hooks but for a matter of fact and saviour, these hooks do have Types support! I don’t understand why we don’t use types as often, it might increase development time a little bit, but at least after that you have a peace of mind about your code not breaking in production at least without any reason.
Let’s see the solution in Action!
Complete code to copy/refer has been provided at the end of this explanation section
We will create a form where I expect the user to fill in Name, Email, Age. So we have data types of string and number.
Since our forms are states and would be using useActionState hook consider extracting away the form from your server component since we will need to make the form a client component, we first create a TypeScript Interface that looks something like this
remember to export the interface since we are going to need it in our server actions as well since this is what is our Stateful Contract that will be communicated between the form and the server action.
After this we create a new file where we describe our server action and this is how thing look initially
Let’s begin by adding the useActionState hook for the form
as of this, let’s ignore any warnings and errors since we are creating a fully typed form and server action contract this is what’s expected, the TypeScript compiler will definitely throw error at you that is what’s going to force you to write a really good and type-safe code.
I have udpated the server action to be inferring the same types, for the sake of this blog, I won’t be performing any network calls like submitting or anything since that is not our concern in this case
Notice how I have wrapped the age in Number because I have inferred the return type as the contract interface that we defined initially which forced me to make that string value a number since that is what we expect to be.
Now I update the component and add types to my useActionState hook to ensure a proper contract between the form and the server action.
Now that we have completed the solution to the problem, let’s handle the formState using useEffect or you can even handle it in your TSX if you want.
This way we are going to ensure that we don’t exchange any data among the form and the server action that is not present in the interface that we created initially.
I also updated the Server Actions and added some basic validation
This practice of adding type to the useActionState hook and the Server Action return type, TypeScript forces us to make sure the exchanging the data is not hindering the contract and ensure seamless two-way data binding.
Now if you want to add any new field in the form, you will be prompted through an error in both the places in Server Action and the React form that tells you to add it to the contract lowering your chances of breaking anything in production.
Final Code
page.tsx
"use client";
import { useActionState, useEffect, useState } from "react";
import { submitForm } from "./action";
export interface FormState {
name: string;
email: string;
age: number;
error?: string;
}
export default function Home() {
const [formValues, setFormValues] = useState<FormState>({
name: "",
email: "",
age: 0,
});
const [formState, formAction, isFormPending] = useActionState<FormState, FormData>(submitForm, formValues);
useEffect(() => {
if (formState.error) {
alert(formState.error);
}
}, [formState.error]);
return (
<form action={formAction} className="flex flex-col gap-4 max-w-sm mx-auto py-10">
<label htmlFor="name">Name</label>
<input
type="text"
name="name"
id="name"
value={formValues.name}
onChange={(e) => setFormValues({ ...formValues, name: e.target.value })}
/>
<label htmlFor="email">Email</label>
<input
type="email"
name="email"
id="email"
value={formValues.email}
onChange={(e) => setFormValues({ ...formValues, email: e.target.value })}
/>
<label htmlFor="age">Age</label>
<input
type="number"
name="age"
id="age"
value={formValues.age}
onChange={(e) => setFormValues({ ...formValues, age: Number(e.target.value) })}
/>
<button type="submit">{isFormPending ? "Submitting..." : "Submit"}</button>
</form>
);
}
action.ts
"use server";
import { FormState } from "./page";
export async function submitForm(previousState: FormState, formData: FormData): Promise<FormState> {
const submittedFormValues = {
name: formData.get("name") as string,
email: formData.get("email") as string,
age: Number(formData.get("age") as string),
};
console.log("submittedFormValues", submittedFormValues);
if (submittedFormValues.name === "") {
return {
...previousState,
error: "Name is required",
};
}
if (submittedFormValues.email === "") {
return {
...previousState,
error: "Email is required",
};
}
if (submittedFormValues.age === 0 || submittedFormValues.age < 0) {
return {
...previousState,
error: "Age is required and must be greater than 0",
};
}
return submittedFormValues;
}
This way we can handle the errors and the data gracefully while still maintaing the Form State








Top comments (0)